@iamsaroj/replicax 0.0.2 → 0.0.4

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 (3) hide show
  1. package/README.md +118 -12
  2. package/dist/index.js +1354 -175
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -60,21 +60,24 @@ var logger = {
60
60
  };
61
61
 
62
62
  // src/commands/init.ts
63
- import path6 from "path";
64
- import fs5 from "fs-extra";
63
+ import path7 from "path";
64
+ import fs6 from "fs-extra";
65
65
  import ora from "ora";
66
66
  import { confirm } from "@inquirer/prompts";
67
67
 
68
68
  // src/constants.ts
69
69
  var REPLICAX_DIR = ".replicax";
70
70
  var IGNORE_FILE = ".replicaxignore";
71
- var REPLICAX_VERSION = "2.0.0";
71
+ var INCLUDE_FILE = ".replicaxinclude";
72
+ var ROOT_SKILL_FILE = "SKILL.md";
73
+ var REPLICAX_VERSION = "2.1.0";
72
74
  var PROFILE_FILES = {
73
75
  profile: "profile.json",
74
76
  tooling: "tooling.json",
75
77
  structure: "structure.json",
76
78
  metadata: "metadata.json",
77
- checksum: "checksum.json"
79
+ checksum: "checksum.json",
80
+ manifest: "manifest.json"
78
81
  };
79
82
  var SCAN_PRUNE_GLOBS = [
80
83
  "**/node_modules/**",
@@ -99,6 +102,7 @@ var SCAN_PRUNE_GLOBS = [
99
102
  "**/.fleet/**",
100
103
  "**/.zed/**"
101
104
  ];
105
+ var INCLUDE_PRUNE_GLOBS = ["**/node_modules/**", "**/.git/**", `**/${REPLICAX_DIR}/**`];
102
106
  var DEFAULT_IGNORE_PATTERNS = [
103
107
  "node_modules/",
104
108
  ".git/",
@@ -129,15 +133,19 @@ var SECRET_GUARD_GLOBS = [
129
133
  "**/*.key",
130
134
  "**/*.p12",
131
135
  "**/*.pfx",
136
+ "**/*.p8",
132
137
  "**/*.cert",
133
138
  "**/*.crt",
134
139
  "**/*.keystore",
135
140
  "**/*.jks",
141
+ "**/*.ppk",
136
142
  "**/id_rsa*",
137
143
  "**/id_dsa*",
138
144
  "**/id_ecdsa*",
139
145
  "**/id_ed25519*",
140
146
  "**/.netrc",
147
+ "**/.pgpass",
148
+ "**/.htpasswd",
141
149
  "**/secrets.*",
142
150
  "**/*.secret",
143
151
  "**/*.secrets"
@@ -177,9 +185,9 @@ yarn-error.log*
177
185
  `;
178
186
 
179
187
  // src/core/scanner.ts
180
- import path4 from "path";
181
- import fs3 from "fs-extra";
182
- import fg from "fast-glob";
188
+ import path5 from "path";
189
+ import fs4 from "fs-extra";
190
+ import fg2 from "fast-glob";
183
191
 
184
192
  // src/config/supported-files.ts
185
193
  var CONFIG_CATEGORIES = [
@@ -284,6 +292,38 @@ var CONFIG_CATEGORIES = [
284
292
  label: "Git Hooks",
285
293
  patterns: [".husky/*"]
286
294
  },
295
+ {
296
+ id: "jvm-build",
297
+ label: "JVM Build",
298
+ // The captured surface is language-agnostic: Maven/Gradle build files are
299
+ // setup, not application code. Globbed with `**/` so monorepos (a Spring
300
+ // backend beside a JS frontend) are captured too. The gradle wrapper JAR is
301
+ // binary and deliberately excluded — only its text `.properties` is kept.
302
+ patterns: [
303
+ "**/pom.xml",
304
+ "**/build.gradle",
305
+ "**/build.gradle.kts",
306
+ "**/settings.gradle",
307
+ "**/settings.gradle.kts",
308
+ "gradle.properties",
309
+ "mvnw",
310
+ "mvnw.cmd",
311
+ "gradlew",
312
+ "gradlew.bat",
313
+ "**/gradle/wrapper/gradle-wrapper.properties"
314
+ ]
315
+ },
316
+ {
317
+ id: "jvm-config",
318
+ label: "JVM Config",
319
+ // Spring-style externalized config. Scoped to `src/main/resources/` so we
320
+ // capture application config without sweeping up unrelated `.properties`.
321
+ patterns: [
322
+ "**/src/main/resources/application*.yml",
323
+ "**/src/main/resources/application*.yaml",
324
+ "**/src/main/resources/application*.properties"
325
+ ]
326
+ },
287
327
  {
288
328
  id: "misc",
289
329
  label: "Miscellaneous Tooling",
@@ -304,6 +344,13 @@ var CONFIG_CATEGORIES = [
304
344
  ];
305
345
  var ALL_CONFIG_PATTERNS = CONFIG_CATEGORIES.flatMap((c) => c.patterns);
306
346
  var CATEGORY_BY_ID = new Map(CONFIG_CATEGORIES.map((c) => [c.id, c]));
347
+ var EXTRA_CATEGORY_LABELS = {
348
+ // Files pulled in explicitly via `.replicaxinclude`.
349
+ included: "Included files"
350
+ };
351
+ function categoryLabel(id) {
352
+ return CATEGORY_BY_ID.get(id)?.label ?? EXTRA_CATEGORY_LABELS[id] ?? id;
353
+ }
307
354
 
308
355
  // src/utils/paths.ts
309
356
  import path from "path";
@@ -349,6 +396,7 @@ import fs from "fs-extra";
349
396
  import ignore from "ignore";
350
397
  var IgnoreEngine = class _IgnoreEngine {
351
398
  ig;
399
+ userIg;
352
400
  secrets;
353
401
  userPatterns;
354
402
  constructor(userPatterns = []) {
@@ -357,6 +405,7 @@ var IgnoreEngine = class _IgnoreEngine {
357
405
  return t.length > 0 && !t.startsWith("#");
358
406
  });
359
407
  this.ig = ignore().add(DEFAULT_IGNORE_PATTERNS).add(this.userPatterns);
408
+ this.userIg = ignore().add(this.userPatterns);
360
409
  this.secrets = ignore().add(SECRET_GUARD_GLOBS);
361
410
  }
362
411
  /** Build an engine from a project's `.replicaxignore`, if present. */
@@ -373,6 +422,15 @@ var IgnoreEngine = class _IgnoreEngine {
373
422
  if (!relPosixPath || relPosixPath === ".") return false;
374
423
  return this.ig.ignores(relPosixPath);
375
424
  }
425
+ /**
426
+ * Whether a path is excluded by the user's `.replicaxignore` *only* (ignoring
427
+ * the built-in defaults). Used to apply `.replicaxignore`'s precedence over an
428
+ * explicit `.replicaxinclude` without the defaults vetoing the include.
429
+ */
430
+ isUserIgnored(relPosixPath) {
431
+ if (!relPosixPath || relPosixPath === ".") return false;
432
+ return this.userIg.ignores(relPosixPath);
433
+ }
376
434
  /** Whether a path is a protected secret that must never be captured. */
377
435
  isSecret(relPosixPath) {
378
436
  if (!relPosixPath || relPosixPath === ".") return false;
@@ -435,6 +493,11 @@ async function detectLanguage(root, pkg) {
435
493
  return file === "jsconfig.json" ? "javascript" : "typescript";
436
494
  }
437
495
  }
496
+ if (!pkg) {
497
+ for (const file of ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"]) {
498
+ if (await fs2.pathExists(path3.join(root, file))) return "java";
499
+ }
500
+ }
438
501
  return "javascript";
439
502
  }
440
503
  function detectFramework(pkg) {
@@ -454,7 +517,7 @@ function detectFramework(pkg) {
454
517
  [has("svelte"), "svelte"],
455
518
  [has("solid-js"), "solid"],
456
519
  [has("react"), "react"],
457
- [has("@fastify/fastify") || has("fastify"), "fastify"],
520
+ [has("fastify"), "fastify"],
458
521
  [has("koa"), "koa"],
459
522
  [has("express"), "express"]
460
523
  ];
@@ -550,6 +613,464 @@ function renderPackageJson(template, projectName) {
550
613
  return JSON.stringify(ordered, null, 2) + "\n";
551
614
  }
552
615
 
616
+ // src/core/detection/context.ts
617
+ import path4 from "path";
618
+ import fs3 from "fs-extra";
619
+ import fg from "fast-glob";
620
+ var EXACT_CANDIDATES = [
621
+ // package managers / lockfiles
622
+ "package-lock.json",
623
+ "npm-shrinkwrap.json",
624
+ "yarn.lock",
625
+ "pnpm-lock.yaml",
626
+ "bun.lockb",
627
+ "bun.lock",
628
+ "pnpm-workspace.yaml",
629
+ // docker
630
+ "Dockerfile",
631
+ ".dockerignore",
632
+ // ci
633
+ ".gitlab-ci.yml",
634
+ ".circleci/config.yml",
635
+ "Jenkinsfile",
636
+ "azure-pipelines.yml",
637
+ // monorepo / build
638
+ "turbo.json",
639
+ "nx.json",
640
+ "lerna.json",
641
+ // git hooks
642
+ ".husky",
643
+ // editors / ai assistants
644
+ ".vscode",
645
+ ".cursor",
646
+ ".cursorrules",
647
+ ".claude",
648
+ "CLAUDE.md",
649
+ ".windsurf",
650
+ ".windsurfrules",
651
+ ".devcontainer",
652
+ ".devcontainer.json",
653
+ // lint / format (exact flavours)
654
+ ".eslintrc",
655
+ ".prettierrc",
656
+ // language
657
+ "tsconfig.json",
658
+ "jsconfig.json",
659
+ // jvm build
660
+ "pom.xml",
661
+ "mvnw",
662
+ "mvnw.cmd",
663
+ "build.gradle",
664
+ "build.gradle.kts",
665
+ "settings.gradle",
666
+ "settings.gradle.kts",
667
+ "gradlew",
668
+ "gradlew.bat"
669
+ ];
670
+ var GLOB_CANDIDATES = [
671
+ ".github/workflows/*.yml",
672
+ ".github/workflows/*.yaml",
673
+ "Dockerfile.*",
674
+ "docker-compose*.yml",
675
+ "docker-compose*.yaml",
676
+ "compose.yml",
677
+ "compose.yaml",
678
+ "eslint.config.*",
679
+ ".eslintrc.*",
680
+ "prettier.config.*",
681
+ ".prettierrc.*",
682
+ "commitlint.config.*",
683
+ ".commitlintrc",
684
+ ".commitlintrc.*",
685
+ "lint-staged.config.*",
686
+ ".lintstagedrc",
687
+ ".lintstagedrc.*",
688
+ "vitest.config.*",
689
+ "jest.config.*",
690
+ "playwright.config.*",
691
+ "cypress.config.*",
692
+ ".husky/*",
693
+ // JVM build files + Spring resources, globbed with `**/` so a backend nested
694
+ // inside a monorepo (e.g. fullstack JS + Spring) is still detected.
695
+ "**/pom.xml",
696
+ "**/build.gradle",
697
+ "**/build.gradle.kts",
698
+ "**/settings.gradle",
699
+ "**/settings.gradle.kts",
700
+ "**/src/main/resources/application*.yml",
701
+ "**/src/main/resources/application*.yaml",
702
+ "**/src/main/resources/application*.properties"
703
+ ];
704
+ async function gatherContext(root, pkg) {
705
+ const present = /* @__PURE__ */ new Set();
706
+ await Promise.all(
707
+ EXACT_CANDIDATES.map(async (rel) => {
708
+ if (await fs3.pathExists(path4.join(root, rel))) present.add(rel);
709
+ })
710
+ );
711
+ const matches = await fg(GLOB_CANDIDATES, {
712
+ cwd: root,
713
+ dot: true,
714
+ onlyFiles: true,
715
+ unique: true,
716
+ suppressErrors: true,
717
+ followSymbolicLinks: false,
718
+ ignore: SCAN_PRUNE_GLOBS
719
+ });
720
+ for (const m of matches) present.add(toPosix(m));
721
+ const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
722
+ return {
723
+ root,
724
+ pkg,
725
+ deps,
726
+ present,
727
+ has: (rel) => present.has(rel),
728
+ hasUnder: (prefix) => {
729
+ const p = prefix.replace(/\/+$/, "");
730
+ if (present.has(p)) return true;
731
+ const needle = `${p}/`;
732
+ for (const x of present) if (x.startsWith(needle)) return true;
733
+ return false;
734
+ },
735
+ hasDep: (name) => name in deps
736
+ };
737
+ }
738
+
739
+ // src/core/detection/types.ts
740
+ var Confidence = {
741
+ /** A canonical, unambiguous artifact is present (e.g. a Dockerfile). */
742
+ Confirmed: 1,
743
+ /** Strong signal (e.g. a declared dependency, a pinned field). */
744
+ Strong: 0.9,
745
+ /** Secondary/heuristic evidence (e.g. a related config but not the tool itself). */
746
+ Likely: 0.7,
747
+ /** Weak hint. */
748
+ Possible: 0.5
749
+ };
750
+ function defineDetector(meta, fn) {
751
+ return {
752
+ ...meta,
753
+ detect(ctx) {
754
+ const hit2 = fn(ctx);
755
+ return hit2 ? { ...meta, confidence: hit2.confidence, evidence: hit2.evidence } : null;
756
+ }
757
+ };
758
+ }
759
+ function hit(confidence, ...evidence) {
760
+ return { confidence, evidence };
761
+ }
762
+
763
+ // src/core/detection/detectors/languages.ts
764
+ var LANGUAGE_NAMES = {
765
+ typescript: "TypeScript",
766
+ javascript: "JavaScript",
767
+ java: "Java",
768
+ unknown: null
769
+ };
770
+ var FRAMEWORK_LABELS = {
771
+ next: "Next.js",
772
+ nuxt: "Nuxt",
773
+ remix: "Remix",
774
+ astro: "Astro",
775
+ angular: "Angular",
776
+ sveltekit: "SvelteKit",
777
+ nestjs: "NestJS",
778
+ expo: "Expo",
779
+ "react-native": "React Native",
780
+ vue: "Vue",
781
+ svelte: "Svelte",
782
+ solid: "SolidJS",
783
+ react: "React",
784
+ fastify: "Fastify",
785
+ koa: "Koa",
786
+ express: "Express"
787
+ };
788
+ var FRAMEWORK_SKIP = /* @__PURE__ */ new Set(["unknown", "node"]);
789
+ function metadataDetections(metadata) {
790
+ const out = [];
791
+ const languageName = LANGUAGE_NAMES[metadata.language];
792
+ if (languageName) {
793
+ out.push({
794
+ id: metadata.language,
795
+ name: languageName,
796
+ category: "language",
797
+ confidence: Confidence.Confirmed,
798
+ evidence: ["metadata.language"]
799
+ });
800
+ }
801
+ if (metadata.framework && !FRAMEWORK_SKIP.has(metadata.framework)) {
802
+ out.push({
803
+ id: metadata.framework,
804
+ name: FRAMEWORK_LABELS[metadata.framework] ?? metadata.framework,
805
+ category: "framework",
806
+ confidence: Confidence.Confirmed,
807
+ evidence: ["package.json"]
808
+ });
809
+ }
810
+ return out;
811
+ }
812
+
813
+ // src/core/detection/detectors/packageManagers.ts
814
+ function pmField(ctx) {
815
+ const field = ctx.pkg?.packageManager;
816
+ if (typeof field !== "string") return null;
817
+ return field.split("@")[0]?.trim().toLowerCase() ?? null;
818
+ }
819
+ var packageManagerDetectors = [
820
+ defineDetector({ id: "npm", name: "npm", category: "package-manager" }, (ctx) => {
821
+ if (ctx.has("package-lock.json")) return hit(Confidence.Confirmed, "package-lock.json");
822
+ if (ctx.has("npm-shrinkwrap.json")) return hit(Confidence.Confirmed, "npm-shrinkwrap.json");
823
+ if (pmField(ctx) === "npm") return hit(Confidence.Strong, "package.json#packageManager");
824
+ return null;
825
+ }),
826
+ defineDetector({ id: "pnpm", name: "pnpm", category: "package-manager" }, (ctx) => {
827
+ if (ctx.has("pnpm-lock.yaml")) return hit(Confidence.Confirmed, "pnpm-lock.yaml");
828
+ if (ctx.has("pnpm-workspace.yaml")) return hit(Confidence.Strong, "pnpm-workspace.yaml");
829
+ if (pmField(ctx) === "pnpm") return hit(Confidence.Strong, "package.json#packageManager");
830
+ return null;
831
+ }),
832
+ defineDetector({ id: "yarn", name: "Yarn", category: "package-manager" }, (ctx) => {
833
+ if (ctx.has("yarn.lock")) return hit(Confidence.Confirmed, "yarn.lock");
834
+ if (pmField(ctx) === "yarn") return hit(Confidence.Strong, "package.json#packageManager");
835
+ return null;
836
+ }),
837
+ defineDetector({ id: "bun", name: "Bun", category: "package-manager" }, (ctx) => {
838
+ if (ctx.has("bun.lockb")) return hit(Confidence.Confirmed, "bun.lockb");
839
+ if (ctx.has("bun.lock")) return hit(Confidence.Confirmed, "bun.lock");
840
+ if (pmField(ctx) === "bun") return hit(Confidence.Strong, "package.json#packageManager");
841
+ return null;
842
+ })
843
+ ];
844
+
845
+ // src/core/detection/detectors/tooling.ts
846
+ function match(ctx, re) {
847
+ for (const p of ctx.present) if (re.test(p)) return p;
848
+ return void 0;
849
+ }
850
+ function hasPkgKey(ctx, key) {
851
+ return Boolean(ctx.pkg && typeof ctx.pkg === "object" && key in ctx.pkg);
852
+ }
853
+ var toolingDetectors = [
854
+ // --- Containers ----------------------------------------------------------
855
+ defineDetector({ id: "docker", name: "Docker", category: "container" }, (ctx) => {
856
+ const dockerfile = ctx.has("Dockerfile") ? "Dockerfile" : match(ctx, /^Dockerfile(\.|$)/);
857
+ if (dockerfile) return hit(Confidence.Confirmed, dockerfile);
858
+ if (ctx.has(".dockerignore")) return hit(Confidence.Likely, ".dockerignore");
859
+ return null;
860
+ }),
861
+ defineDetector({ id: "docker-compose", name: "Docker Compose", category: "container" }, (ctx) => {
862
+ const compose = match(ctx, /^(docker-compose|compose).*\.ya?ml$/);
863
+ return compose ? hit(Confidence.Confirmed, compose) : null;
864
+ }),
865
+ // --- CI/CD ---------------------------------------------------------------
866
+ defineDetector({ id: "github-actions", name: "GitHub Actions", category: "ci" }, (ctx) => {
867
+ if (ctx.hasUnder(".github/workflows")) {
868
+ return hit(
869
+ Confidence.Confirmed,
870
+ match(ctx, /^\.github\/workflows\//) ?? ".github/workflows/"
871
+ );
872
+ }
873
+ return null;
874
+ }),
875
+ defineDetector(
876
+ { id: "gitlab-ci", name: "GitLab CI", category: "ci" },
877
+ (ctx) => ctx.has(".gitlab-ci.yml") ? hit(Confidence.Confirmed, ".gitlab-ci.yml") : null
878
+ ),
879
+ defineDetector(
880
+ { id: "circleci", name: "CircleCI", category: "ci" },
881
+ (ctx) => ctx.has(".circleci/config.yml") ? hit(Confidence.Confirmed, ".circleci/config.yml") : null
882
+ ),
883
+ defineDetector(
884
+ { id: "jenkins", name: "Jenkins", category: "ci" },
885
+ (ctx) => ctx.has("Jenkinsfile") ? hit(Confidence.Confirmed, "Jenkinsfile") : null
886
+ ),
887
+ defineDetector(
888
+ { id: "azure-pipelines", name: "Azure Pipelines", category: "ci" },
889
+ (ctx) => ctx.has("azure-pipelines.yml") ? hit(Confidence.Confirmed, "azure-pipelines.yml") : null
890
+ ),
891
+ // --- Monorepo ------------------------------------------------------------
892
+ defineDetector(
893
+ { id: "turborepo", name: "Turborepo", category: "monorepo" },
894
+ (ctx) => ctx.has("turbo.json") ? hit(Confidence.Confirmed, "turbo.json") : null
895
+ ),
896
+ defineDetector(
897
+ { id: "nx", name: "Nx", category: "monorepo" },
898
+ (ctx) => ctx.has("nx.json") ? hit(Confidence.Confirmed, "nx.json") : null
899
+ ),
900
+ // --- Git hooks / commit --------------------------------------------------
901
+ defineDetector({ id: "husky", name: "Husky", category: "git-hooks" }, (ctx) => {
902
+ if (ctx.hasUnder(".husky")) return hit(Confidence.Confirmed, ".husky/");
903
+ if (ctx.hasDep("husky")) return hit(Confidence.Strong, "package.json#husky");
904
+ return null;
905
+ }),
906
+ defineDetector({ id: "lint-staged", name: "lint-staged", category: "commit" }, (ctx) => {
907
+ const cfg = match(ctx, /^(lint-staged\.config\.|\.lintstagedrc)/);
908
+ if (cfg) return hit(Confidence.Confirmed, cfg);
909
+ if (hasPkgKey(ctx, "lint-staged") || hasPkgKey(ctx, "nano-staged")) {
910
+ return hit(Confidence.Confirmed, "package.json#lint-staged");
911
+ }
912
+ if (ctx.hasDep("lint-staged")) return hit(Confidence.Strong, "package.json#lint-staged");
913
+ return null;
914
+ }),
915
+ defineDetector({ id: "commitlint", name: "Commitlint", category: "commit" }, (ctx) => {
916
+ const cfg = match(ctx, /^(commitlint\.config\.|\.commitlintrc)/);
917
+ if (cfg) return hit(Confidence.Confirmed, cfg);
918
+ if (hasPkgKey(ctx, "commitlint")) return hit(Confidence.Confirmed, "package.json#commitlint");
919
+ if (ctx.hasDep("@commitlint/cli")) return hit(Confidence.Strong, "@commitlint/cli");
920
+ return null;
921
+ }),
922
+ // --- Lint / format -------------------------------------------------------
923
+ defineDetector({ id: "eslint", name: "ESLint", category: "lint" }, (ctx) => {
924
+ const cfg = ctx.has(".eslintrc") ? ".eslintrc" : match(ctx, /^(eslint\.config\.|\.eslintrc\.)/);
925
+ if (cfg) return hit(Confidence.Confirmed, cfg);
926
+ if (hasPkgKey(ctx, "eslintConfig"))
927
+ return hit(Confidence.Confirmed, "package.json#eslintConfig");
928
+ if (ctx.hasDep("eslint")) return hit(Confidence.Strong, "eslint");
929
+ return null;
930
+ }),
931
+ defineDetector({ id: "prettier", name: "Prettier", category: "format" }, (ctx) => {
932
+ const cfg = ctx.has(".prettierrc") ? ".prettierrc" : match(ctx, /^(prettier\.config\.|\.prettierrc\.)/);
933
+ if (cfg) return hit(Confidence.Confirmed, cfg);
934
+ if (hasPkgKey(ctx, "prettier")) return hit(Confidence.Confirmed, "package.json#prettier");
935
+ if (ctx.hasDep("prettier")) return hit(Confidence.Strong, "prettier");
936
+ return null;
937
+ }),
938
+ // --- Testing -------------------------------------------------------------
939
+ defineDetector({ id: "vitest", name: "Vitest", category: "test" }, (ctx) => {
940
+ const cfg = match(ctx, /^vitest\.config\./);
941
+ if (cfg) return hit(Confidence.Confirmed, cfg);
942
+ if (ctx.hasDep("vitest")) return hit(Confidence.Strong, "vitest");
943
+ return null;
944
+ }),
945
+ defineDetector({ id: "jest", name: "Jest", category: "test" }, (ctx) => {
946
+ const cfg = match(ctx, /^jest\.config\./);
947
+ if (cfg) return hit(Confidence.Confirmed, cfg);
948
+ if (hasPkgKey(ctx, "jest")) return hit(Confidence.Confirmed, "package.json#jest");
949
+ if (ctx.hasDep("jest")) return hit(Confidence.Strong, "jest");
950
+ return null;
951
+ }),
952
+ defineDetector({ id: "playwright", name: "Playwright", category: "test" }, (ctx) => {
953
+ const cfg = match(ctx, /^playwright\.config\./);
954
+ if (cfg) return hit(Confidence.Confirmed, cfg);
955
+ if (ctx.hasDep("@playwright/test") || ctx.hasDep("playwright")) {
956
+ return hit(Confidence.Strong, "playwright");
957
+ }
958
+ return null;
959
+ }),
960
+ defineDetector({ id: "cypress", name: "Cypress", category: "test" }, (ctx) => {
961
+ const cfg = match(ctx, /^cypress\.config\./);
962
+ if (cfg) return hit(Confidence.Confirmed, cfg);
963
+ if (ctx.hasDep("cypress")) return hit(Confidence.Strong, "cypress");
964
+ return null;
965
+ }),
966
+ // --- Dev containers ------------------------------------------------------
967
+ defineDetector({ id: "devcontainer", name: "Dev Container", category: "devcontainer" }, (ctx) => {
968
+ if (ctx.hasUnder(".devcontainer")) return hit(Confidence.Confirmed, ".devcontainer/");
969
+ if (ctx.has(".devcontainer.json")) return hit(Confidence.Confirmed, ".devcontainer.json");
970
+ return null;
971
+ })
972
+ ];
973
+
974
+ // src/core/detection/detectors/editors.ts
975
+ var editorDetectors = [
976
+ defineDetector(
977
+ { id: "vscode", name: "VS Code", category: "editor" },
978
+ (ctx) => ctx.hasUnder(".vscode") ? hit(Confidence.Confirmed, ".vscode/") : null
979
+ ),
980
+ defineDetector({ id: "cursor", name: "Cursor", category: "ai" }, (ctx) => {
981
+ if (ctx.hasUnder(".cursor")) return hit(Confidence.Confirmed, ".cursor/");
982
+ if (ctx.has(".cursorrules")) return hit(Confidence.Confirmed, ".cursorrules");
983
+ return null;
984
+ }),
985
+ defineDetector({ id: "claude-code", name: "Claude Code", category: "ai" }, (ctx) => {
986
+ if (ctx.hasUnder(".claude")) return hit(Confidence.Confirmed, ".claude/");
987
+ if (ctx.has("CLAUDE.md")) return hit(Confidence.Confirmed, "CLAUDE.md");
988
+ return null;
989
+ }),
990
+ defineDetector({ id: "windsurf", name: "Windsurf", category: "ai" }, (ctx) => {
991
+ if (ctx.hasUnder(".windsurf")) return hit(Confidence.Confirmed, ".windsurf/");
992
+ if (ctx.has(".windsurfrules")) return hit(Confidence.Confirmed, ".windsurfrules");
993
+ return null;
994
+ })
995
+ ];
996
+
997
+ // src/core/detection/detectors/jvm.ts
998
+ function match2(ctx, re) {
999
+ for (const p of ctx.present) if (re.test(p)) return p;
1000
+ return void 0;
1001
+ }
1002
+ var POM = /(^|\/)pom\.xml$/;
1003
+ var GRADLE = /(^|\/)(build|settings)\.gradle(\.kts)?$/;
1004
+ var APP_CONFIG = /(^|\/)src\/main\/resources\/application[^/]*\.(ya?ml|properties)$/;
1005
+ var jvmDetectors = [
1006
+ defineDetector({ id: "maven", name: "Maven", category: "jvm" }, (ctx) => {
1007
+ const pom = match2(ctx, POM);
1008
+ if (pom) return hit(Confidence.Confirmed, pom);
1009
+ if (ctx.has("mvnw") || ctx.has("mvnw.cmd")) return hit(Confidence.Strong, "mvnw");
1010
+ return null;
1011
+ }),
1012
+ defineDetector({ id: "gradle", name: "Gradle", category: "jvm" }, (ctx) => {
1013
+ const build = match2(ctx, GRADLE);
1014
+ if (build) return hit(Confidence.Confirmed, build);
1015
+ if (ctx.has("gradlew") || ctx.has("gradlew.bat")) return hit(Confidence.Strong, "gradlew");
1016
+ return null;
1017
+ }),
1018
+ defineDetector({ id: "spring-boot", name: "Spring Boot", category: "framework" }, (ctx) => {
1019
+ const appConfig = match2(ctx, APP_CONFIG);
1020
+ const hasBuild = Boolean(match2(ctx, POM) || match2(ctx, GRADLE));
1021
+ if (appConfig && hasBuild) return hit(Confidence.Strong, appConfig);
1022
+ if (appConfig) return hit(Confidence.Likely, appConfig);
1023
+ return null;
1024
+ })
1025
+ ];
1026
+
1027
+ // src/core/detection/registry.ts
1028
+ var DETECTORS = [
1029
+ ...packageManagerDetectors,
1030
+ ...toolingDetectors,
1031
+ ...editorDetectors,
1032
+ ...jvmDetectors
1033
+ ];
1034
+ var CATEGORY_ORDER = [
1035
+ "language",
1036
+ "framework",
1037
+ "jvm",
1038
+ "package-manager",
1039
+ "monorepo",
1040
+ "build",
1041
+ "lint",
1042
+ "format",
1043
+ "test",
1044
+ "container",
1045
+ "ci",
1046
+ "git-hooks",
1047
+ "commit",
1048
+ "devcontainer",
1049
+ "editor",
1050
+ "ai"
1051
+ ];
1052
+ function sortDetections(list) {
1053
+ return [...list].sort((a, b) => {
1054
+ const ca = CATEGORY_ORDER.indexOf(a.category);
1055
+ const cb = CATEGORY_ORDER.indexOf(b.category);
1056
+ if (ca !== cb) return ca - cb;
1057
+ return a.name.localeCompare(b.name);
1058
+ });
1059
+ }
1060
+ function runDetectors(ctx) {
1061
+ return DETECTORS.map((d) => d.detect(ctx)).filter((d) => d !== null);
1062
+ }
1063
+ async function detectStack(root, pkg, metadata) {
1064
+ const ctx = await gatherContext(root, pkg);
1065
+ const fromDetectors = runDetectors(ctx);
1066
+ const fromMetadata = metadata ? metadataDetections(metadata) : [];
1067
+ const byId = /* @__PURE__ */ new Map();
1068
+ for (const d of [...fromMetadata, ...fromDetectors]) {
1069
+ if (!byId.has(d.id)) byId.set(d.id, d);
1070
+ }
1071
+ return sortDetections([...byId.values()]);
1072
+ }
1073
+
553
1074
  // src/core/scanner.ts
554
1075
  var FG_BASE_OPTIONS = {
555
1076
  dot: true,
@@ -568,10 +1089,16 @@ function sanitizeNpmrc(content) {
568
1089
  });
569
1090
  return kept.join("\n");
570
1091
  }
1092
+ async function readIncludePatterns(root) {
1093
+ const file = path5.join(root, INCLUDE_FILE);
1094
+ if (!await fs4.pathExists(file)) return [];
1095
+ const content = await fs4.readFile(file, "utf8");
1096
+ return content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((line) => line.endsWith("/") ? `${line}**` : line);
1097
+ }
571
1098
  async function scanToolingFiles(root, ignore2) {
572
1099
  const categoryOf = /* @__PURE__ */ new Map();
573
1100
  for (const category of CONFIG_CATEGORIES) {
574
- const found = await fg(category.patterns, {
1101
+ const found = await fg2(category.patterns, {
575
1102
  cwd: root,
576
1103
  onlyFiles: true,
577
1104
  unique: true,
@@ -582,43 +1109,67 @@ async function scanToolingFiles(root, ignore2) {
582
1109
  if (!categoryOf.has(norm)) categoryOf.set(norm, category.id);
583
1110
  }
584
1111
  }
1112
+ const includePatterns = await readIncludePatterns(root);
1113
+ const included = /* @__PURE__ */ new Set();
1114
+ if (includePatterns.length > 0) {
1115
+ const found = await fg2(includePatterns, {
1116
+ cwd: root,
1117
+ onlyFiles: true,
1118
+ unique: true,
1119
+ dot: true,
1120
+ followSymbolicLinks: false,
1121
+ suppressErrors: true,
1122
+ ignore: INCLUDE_PRUNE_GLOBS
1123
+ });
1124
+ for (const rel of found) {
1125
+ const norm = toPosix(rel);
1126
+ if (!categoryOf.has(norm)) included.add(norm);
1127
+ }
1128
+ }
1129
+ const candidates = [
1130
+ ...[...categoryOf.entries()].map(
1131
+ ([rel, category]) => ({ rel, source: "catalogue", category })
1132
+ ),
1133
+ ...[...included].map((rel) => ({ rel, source: "include", category: "included" }))
1134
+ ].sort((a, b) => a.rel.localeCompare(b.rel));
585
1135
  const files = [];
586
1136
  const skippedSecrets = [];
587
- for (const rel of [...categoryOf.keys()].sort()) {
1137
+ for (const { rel, source, category } of candidates) {
588
1138
  if (rel === "package.json") continue;
589
1139
  if (ignore2.isSecret(rel)) {
590
1140
  skippedSecrets.push(rel);
591
1141
  logger.detail(`skipped (secret guard): ${rel}`);
592
1142
  continue;
593
1143
  }
594
- if (ignore2.isIgnored(rel)) {
1144
+ const excluded = source === "include" ? ignore2.isUserIgnored(rel) : ignore2.isIgnored(rel);
1145
+ if (excluded) {
595
1146
  logger.detail(`skipped (.replicaxignore): ${rel}`);
596
1147
  continue;
597
1148
  }
598
- const abs = path4.join(root, rel);
1149
+ const abs = path5.join(root, rel);
599
1150
  let stat;
600
1151
  try {
601
- stat = await fs3.stat(abs);
1152
+ stat = await fs4.stat(abs);
602
1153
  } catch {
603
1154
  continue;
604
1155
  }
605
1156
  if (!stat.isFile()) continue;
606
- let content = await fs3.readFile(abs, "utf8");
607
- if (path4.basename(rel) === ".npmrc") content = sanitizeNpmrc(content);
1157
+ let content = await fs4.readFile(abs, "utf8");
1158
+ if (path5.basename(rel) === ".npmrc") content = sanitizeNpmrc(content);
608
1159
  files.push({
609
1160
  path: rel,
610
- category: categoryOf.get(rel) ?? "misc",
1161
+ category,
611
1162
  variant: detectVariant(rel),
612
1163
  encoding: "utf8",
613
1164
  content,
614
1165
  bytes: Buffer.byteLength(content, "utf8")
615
1166
  });
616
- logger.detail(`captured: ${rel}`);
1167
+ logger.detail(`captured${source === "include" ? " (include)" : ""}: ${rel}`);
617
1168
  }
618
1169
  return { files, skippedSecrets };
619
1170
  }
620
1171
  async function scanStructure(root, ignore2) {
621
- const dirs = await fg("**", {
1172
+ const dirs = await fg2("**", {
622
1173
  cwd: root,
623
1174
  onlyDirectories: true,
624
1175
  unique: true,
@@ -626,13 +1177,13 @@ async function scanStructure(root, ignore2) {
626
1177
  });
627
1178
  const directories = dirs.map(toPosix).filter((d) => d.length > 0 && d !== ".").filter((d) => !ignore2.isIgnored(d)).sort();
628
1179
  return {
629
- root: path4.basename(path4.resolve(root)) || "project",
1180
+ root: path5.basename(path5.resolve(root)) || "project",
630
1181
  directories
631
1182
  };
632
1183
  }
633
1184
  async function scanProject(root) {
634
- const resolved = path4.resolve(root);
635
- if (!await fs3.pathExists(resolved)) {
1185
+ const resolved = path5.resolve(root);
1186
+ if (!await fs4.pathExists(resolved)) {
636
1187
  throw new Error(`Directory does not exist: ${resolved}`);
637
1188
  }
638
1189
  const ignore2 = await IgnoreEngine.fromProject(resolved);
@@ -642,11 +1193,13 @@ async function scanProject(root) {
642
1193
  scanStructure(resolved, ignore2),
643
1194
  detectMetadata(resolved, pkg)
644
1195
  ]);
1196
+ const detections = await detectStack(resolved, pkg, metadata);
1197
+ metadata.detections = detections;
645
1198
  const tooling = {
646
1199
  files,
647
1200
  packageJson: buildPackageTemplate(pkg)
648
1201
  };
649
- return { tooling, structure, metadata, pkg, skippedSecrets };
1202
+ return { tooling, structure, metadata, pkg, detections, skippedSecrets };
650
1203
  }
651
1204
 
652
1205
  // src/core/checksum.ts
@@ -684,6 +1237,34 @@ function verifyChecksum(tooling, stored) {
684
1237
  return mismatches;
685
1238
  }
686
1239
 
1240
+ // src/core/manifest.ts
1241
+ function buildManifest(tooling, checksum) {
1242
+ const entries = tooling.files.map((file) => ({
1243
+ path: file.path,
1244
+ category: file.category,
1245
+ variant: file.variant,
1246
+ bytes: file.bytes,
1247
+ sha256: checksum.files[file.path] ?? ""
1248
+ }));
1249
+ if (tooling.packageJson) {
1250
+ entries.push({
1251
+ path: PACKAGE_JSON_KEY,
1252
+ category: "package",
1253
+ variant: "json",
1254
+ // The curated template is a derived artifact, not a captured file on disk,
1255
+ // so byte size is not meaningful — recorded as 0.
1256
+ bytes: 0,
1257
+ sha256: checksum.files[PACKAGE_JSON_KEY] ?? ""
1258
+ });
1259
+ }
1260
+ entries.sort((a, b) => a.path.localeCompare(b.path));
1261
+ return {
1262
+ schemaVersion: REPLICAX_VERSION,
1263
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1264
+ entries
1265
+ };
1266
+ }
1267
+
687
1268
  // src/core/profile-generator.ts
688
1269
  function buildBundle(args) {
689
1270
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -700,28 +1281,42 @@ function buildBundle(args) {
700
1281
  replicaxVersion: REPLICAX_VERSION,
701
1282
  ...args.description ? { description: args.description } : {}
702
1283
  };
1284
+ const checksum = computeChecksum(args.tooling);
703
1285
  return {
704
1286
  profile,
705
1287
  tooling: args.tooling,
706
1288
  structure: args.structure,
707
1289
  metadata: args.metadata,
708
- checksum: computeChecksum(args.tooling)
1290
+ checksum,
1291
+ manifest: buildManifest(args.tooling, checksum)
709
1292
  };
710
1293
  }
711
1294
 
712
1295
  // src/core/profile-store.ts
713
- import path5 from "path";
714
- import fs4 from "fs-extra";
1296
+ import path6 from "path";
1297
+ import fs5 from "fs-extra";
715
1298
 
716
1299
  // src/schema.ts
717
1300
  import { z } from "zod";
1301
+ var RegistrySchema = z.object({
1302
+ /** Stable identifier within a registry, e.g. "acme/react-enterprise". */
1303
+ id: z.string().optional(),
1304
+ /** Owning namespace/org. */
1305
+ namespace: z.string().optional(),
1306
+ /** Intended visibility once published. */
1307
+ visibility: z.enum(["public", "private"]).optional(),
1308
+ /** Where the profile originated (URL, registry name, …). */
1309
+ source: z.string().optional()
1310
+ });
718
1311
  var ProfileSchema = z.object({
719
1312
  name: z.string().min(1),
720
1313
  version: z.string().min(1),
721
1314
  createdAt: z.string().min(1),
722
1315
  updatedAt: z.string().optional(),
723
1316
  replicaxVersion: z.string().min(1),
724
- description: z.string().optional()
1317
+ description: z.string().optional(),
1318
+ /** Optional registry metadata (future registry compatibility). */
1319
+ registry: RegistrySchema.optional()
725
1320
  });
726
1321
  var FileVariantSchema = z.enum(["ts", "js", "mjs", "cjs", "json", "yaml", "other"]);
727
1322
  var ToolingFileSchema = z.object({
@@ -755,32 +1350,114 @@ var StructureSchema = z.object({
755
1350
  root: z.string(),
756
1351
  directories: z.array(z.string())
757
1352
  });
1353
+ var DetectionCategorySchema = z.enum([
1354
+ "language",
1355
+ "framework",
1356
+ "package-manager",
1357
+ "monorepo",
1358
+ "container",
1359
+ "ci",
1360
+ "git-hooks",
1361
+ "commit",
1362
+ "lint",
1363
+ "format",
1364
+ "test",
1365
+ "build",
1366
+ "editor",
1367
+ "ai",
1368
+ "devcontainer",
1369
+ "jvm"
1370
+ ]);
1371
+ var DetectionSchema = z.object({
1372
+ /** Stable id, e.g. "docker", "github-actions". */
1373
+ id: z.string().min(1),
1374
+ /** Human-friendly label, e.g. "Docker". */
1375
+ name: z.string().min(1),
1376
+ category: DetectionCategorySchema,
1377
+ /** 0..1 — how sure we are this tool is in use. */
1378
+ confidence: z.number().min(0).max(1),
1379
+ /** Paths/fields that justify the detection (e.g. ["Dockerfile"]). */
1380
+ evidence: z.array(z.string()).default([])
1381
+ });
758
1382
  var MetadataSchema = z.object({
759
1383
  nodeVersion: z.string(),
760
1384
  packageManager: z.enum(["npm", "yarn", "pnpm", "bun", "unknown"]),
761
1385
  framework: z.string(),
762
- language: z.enum(["typescript", "javascript"]),
763
- platform: z.string()
1386
+ language: z.enum(["typescript", "javascript", "java", "unknown"]),
1387
+ platform: z.string(),
1388
+ /** Detected tools/technologies with confidence (added in schema 2.1.0). */
1389
+ detections: z.array(DetectionSchema).optional()
764
1390
  });
765
1391
  var ChecksumSchema = z.object({
766
1392
  algorithm: z.literal("sha256"),
767
1393
  files: z.record(z.string(), z.string())
768
1394
  });
1395
+ var ManifestEntrySchema = z.object({
1396
+ path: z.string().min(1),
1397
+ category: z.string().min(1),
1398
+ variant: FileVariantSchema,
1399
+ bytes: z.number().int().nonnegative(),
1400
+ sha256: z.string()
1401
+ });
1402
+ var ManifestSchema = z.object({
1403
+ schemaVersion: z.string().min(1),
1404
+ generatedAt: z.string().min(1),
1405
+ entries: z.array(ManifestEntrySchema)
1406
+ });
1407
+
1408
+ // src/core/migrations.ts
1409
+ var MIGRATIONS = [
1410
+ {
1411
+ from: "2.0.0",
1412
+ to: "2.1.0",
1413
+ apply(raw) {
1414
+ const metadata = raw.metadata;
1415
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata.detections)) {
1416
+ metadata.detections = [];
1417
+ }
1418
+ return raw;
1419
+ }
1420
+ }
1421
+ ];
1422
+ var KNOWN_VERSIONS = /* @__PURE__ */ new Set([
1423
+ REPLICAX_VERSION,
1424
+ ...MIGRATIONS.flatMap((m) => [m.from, m.to])
1425
+ ]);
1426
+ function migrateRawBundle(raw, detectedVersion) {
1427
+ const steps = [];
1428
+ let current = detectedVersion;
1429
+ let data = raw;
1430
+ for (let guard = 0; guard < MIGRATIONS.length + 1; guard += 1) {
1431
+ if (current === REPLICAX_VERSION) break;
1432
+ const next = MIGRATIONS.find((m) => m.from === current);
1433
+ if (!next) break;
1434
+ data = next.apply(data);
1435
+ steps.push(`${next.from} \u2192 ${next.to}`);
1436
+ current = next.to;
1437
+ }
1438
+ return {
1439
+ raw: data,
1440
+ from: detectedVersion,
1441
+ to: current,
1442
+ migrated: steps.length > 0,
1443
+ steps
1444
+ };
1445
+ }
769
1446
 
770
1447
  // src/core/profile-store.ts
771
1448
  function profileDir(root) {
772
- return path5.join(path5.resolve(root), REPLICAX_DIR);
1449
+ return path6.join(path6.resolve(root), REPLICAX_DIR);
773
1450
  }
774
1451
  async function profileExists(dir) {
775
- return fs4.pathExists(path5.join(dir, PROFILE_FILES.profile));
1452
+ return fs5.pathExists(path6.join(dir, PROFILE_FILES.profile));
776
1453
  }
777
1454
  async function resolveProfileDir(input) {
778
- const resolved = path5.resolve(input);
779
- if (!await fs4.pathExists(resolved)) {
1455
+ const resolved = path6.resolve(input);
1456
+ if (!await fs5.pathExists(resolved)) {
780
1457
  throw new ReplicaxError(`Profile path not found: ${input}`);
781
1458
  }
782
1459
  if (await profileExists(resolved)) return resolved;
783
- const nested = path5.join(resolved, REPLICAX_DIR);
1460
+ const nested = path6.join(resolved, REPLICAX_DIR);
784
1461
  if (await profileExists(nested)) return nested;
785
1462
  throw new ReplicaxError(`No ReplicaX profile found at: ${input}`, [
786
1463
  `Looked for ${PROFILE_FILES.profile} in ${resolved} and ${nested}.`,
@@ -788,26 +1465,29 @@ async function resolveProfileDir(input) {
788
1465
  ]);
789
1466
  }
790
1467
  async function saveBundle(dir, bundle) {
791
- await fs4.ensureDir(dir);
1468
+ await fs5.ensureDir(dir);
1469
+ const manifest = bundle.manifest ?? buildManifest(bundle.tooling, bundle.checksum);
792
1470
  await Promise.all([
793
- fs4.writeJson(path5.join(dir, PROFILE_FILES.profile), bundle.profile, { spaces: 2 }),
794
- fs4.writeJson(path5.join(dir, PROFILE_FILES.tooling), bundle.tooling, { spaces: 2 }),
795
- fs4.writeJson(path5.join(dir, PROFILE_FILES.structure), bundle.structure, { spaces: 2 }),
796
- fs4.writeJson(path5.join(dir, PROFILE_FILES.metadata), bundle.metadata, { spaces: 2 }),
797
- fs4.writeJson(path5.join(dir, PROFILE_FILES.checksum), bundle.checksum, { spaces: 2 })
1471
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.profile), bundle.profile, { spaces: 2 }),
1472
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.tooling), bundle.tooling, { spaces: 2 }),
1473
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.structure), bundle.structure, { spaces: 2 }),
1474
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.metadata), bundle.metadata, { spaces: 2 }),
1475
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.checksum), bundle.checksum, { spaces: 2 }),
1476
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.manifest), manifest, { spaces: 2 })
798
1477
  ]);
799
1478
  }
800
- async function readAndParse(dir, file, schema) {
801
- const full = path5.join(dir, file);
802
- if (!await fs4.pathExists(full)) {
1479
+ async function readRawFile(dir, file) {
1480
+ const full = path6.join(dir, file);
1481
+ if (!await fs5.pathExists(full)) {
803
1482
  throw new ReplicaxError(`Profile is missing ${file}`, [`Expected at ${full}.`]);
804
1483
  }
805
- let raw;
806
1484
  try {
807
- raw = await fs4.readJson(full);
1485
+ return await fs5.readJson(full);
808
1486
  } catch {
809
1487
  throw new ReplicaxError(`Profile file ${file} is not valid JSON`, [`Path: ${full}`]);
810
1488
  }
1489
+ }
1490
+ function parseFile(file, schema, raw) {
811
1491
  const result = schema.safeParse(raw);
812
1492
  if (!result.success) {
813
1493
  const issues = result.error.issues.slice(0, 5).map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`);
@@ -821,17 +1501,35 @@ async function loadBundle(dir) {
821
1501
  "Run `replicax init` to create one."
822
1502
  ]);
823
1503
  }
824
- const [profile, tooling, structure, metadata, checksum] = await Promise.all([
825
- readAndParse(dir, PROFILE_FILES.profile, ProfileSchema),
826
- readAndParse(dir, PROFILE_FILES.tooling, ToolingSchema),
827
- readAndParse(dir, PROFILE_FILES.structure, StructureSchema),
828
- readAndParse(dir, PROFILE_FILES.metadata, MetadataSchema),
829
- readAndParse(dir, PROFILE_FILES.checksum, ChecksumSchema)
830
- ]);
831
- return { profile, tooling, structure, metadata, checksum };
1504
+ const rawFiles = {
1505
+ profile: await readRawFile(dir, PROFILE_FILES.profile),
1506
+ tooling: await readRawFile(dir, PROFILE_FILES.tooling),
1507
+ structure: await readRawFile(dir, PROFILE_FILES.structure),
1508
+ metadata: await readRawFile(dir, PROFILE_FILES.metadata),
1509
+ checksum: await readRawFile(dir, PROFILE_FILES.checksum)
1510
+ };
1511
+ const detectedVersion = typeof rawFiles.profile.replicaxVersion === "string" ? rawFiles.profile.replicaxVersion : "2.0.0";
1512
+ const { raw } = migrateRawBundle(rawFiles, detectedVersion);
1513
+ const profile = parseFile(PROFILE_FILES.profile, ProfileSchema, raw.profile);
1514
+ const tooling = parseFile(PROFILE_FILES.tooling, ToolingSchema, raw.tooling);
1515
+ const structure = parseFile(PROFILE_FILES.structure, StructureSchema, raw.structure);
1516
+ const metadata = parseFile(PROFILE_FILES.metadata, MetadataSchema, raw.metadata);
1517
+ const checksum = parseFile(PROFILE_FILES.checksum, ChecksumSchema, raw.checksum);
1518
+ const manifestPath = path6.join(dir, PROFILE_FILES.manifest);
1519
+ const manifest = await fs5.pathExists(manifestPath) ? parseFile(
1520
+ PROFILE_FILES.manifest,
1521
+ ManifestSchema,
1522
+ await readRawFile(dir, PROFILE_FILES.manifest)
1523
+ ) : buildManifest(tooling, checksum);
1524
+ return { profile, tooling, structure, metadata, checksum, manifest };
832
1525
  }
833
1526
 
834
1527
  // src/commands/report.ts
1528
+ function statusLine(ok, label, note) {
1529
+ const mark = ok ? pc.green("\u2713") : pc.red("\u2717");
1530
+ const text = ok ? label : pc.dim(label);
1531
+ return note ? `${mark} ${text} ${pc.dim(note)}` : `${mark} ${text}`;
1532
+ }
835
1533
  function toolingByCategory(tooling) {
836
1534
  const counts = /* @__PURE__ */ new Map();
837
1535
  for (const file of tooling.files) {
@@ -840,7 +1538,7 @@ function toolingByCategory(tooling) {
840
1538
  if (tooling.packageJson) {
841
1539
  counts.set("package", (counts.get("package") ?? 0) + 1);
842
1540
  }
843
- return [...counts.entries()].map(([id, n]) => [CATEGORY_BY_ID.get(id)?.label ?? id, n]).sort((a, b) => a[0].localeCompare(b[0]));
1541
+ return [...counts.entries()].map(([id, n]) => [categoryLabel(id), n]).sort((a, b) => a[0].localeCompare(b[0]));
844
1542
  }
845
1543
  function printScanSummary(bundle) {
846
1544
  const { metadata, tooling, structure } = bundle;
@@ -850,6 +1548,7 @@ function printScanSummary(bundle) {
850
1548
  logger.hint(`framework ${metadata.framework}`);
851
1549
  logger.hint(`packageManager ${metadata.packageManager}`);
852
1550
  logger.hint(`nodeVersion ${metadata.nodeVersion}`);
1551
+ printDetections(metadata.detections ?? []);
853
1552
  logger.newline();
854
1553
  logger.info(pc.bold(`Tooling (${tooling.files.length + (tooling.packageJson ? 1 : 0)} files)`));
855
1554
  for (const [label, count] of toolingByCategory(tooling)) {
@@ -858,6 +1557,15 @@ function printScanSummary(bundle) {
858
1557
  logger.newline();
859
1558
  logger.info(pc.bold(`Structure (${structure.directories.length} directories)`));
860
1559
  }
1560
+ function printDetections(detections) {
1561
+ if (detections.length === 0) return;
1562
+ logger.newline();
1563
+ logger.info(pc.bold(`Detected (${detections.length})`));
1564
+ for (const d of detections) {
1565
+ const pct = d.confidence < 1 ? pc.dim(` (${Math.round(d.confidence * 100)}%)`) : "";
1566
+ logger.hint(`${pc.green("\u2713")} ${d.name}${pct}`);
1567
+ }
1568
+ }
861
1569
  function reportSkippedSecrets(skipped) {
862
1570
  if (skipped.length === 0) return;
863
1571
  logger.warn(`Excluded ${skipped.length} protected file(s) from the profile:`);
@@ -906,7 +1614,7 @@ async function initCommand(options) {
906
1614
  spinner.succeed(
907
1615
  `Scanned ${scan.tooling.files.length} config file(s) and ${scan.structure.directories.length} director(ies)`
908
1616
  );
909
- const name = options.name ?? path6.basename(path6.resolve(root)) ?? "project";
1617
+ const name = options.name ?? path7.basename(path7.resolve(root)) ?? "project";
910
1618
  const bundle = buildBundle({
911
1619
  name,
912
1620
  tooling: scan.tooling,
@@ -931,21 +1639,21 @@ async function initCommand(options) {
931
1639
  logger.hint("Create a project from it with: replicax create <project-name>");
932
1640
  }
933
1641
  async function maybeWriteIgnoreFile(root) {
934
- const file = path6.join(root, IGNORE_FILE);
935
- if (await fs5.pathExists(file)) return;
1642
+ const file = path7.join(root, IGNORE_FILE);
1643
+ if (await fs6.pathExists(file)) return;
936
1644
  const create = process.stdin.isTTY ? await confirm({
937
1645
  message: `Create a starter ${IGNORE_FILE} to control what gets exported?`,
938
1646
  default: true
939
1647
  }) : false;
940
1648
  if (create) {
941
- await fs5.writeFile(file, DEFAULT_IGNORE_FILE_CONTENTS, "utf8");
1649
+ await fs6.writeFile(file, DEFAULT_IGNORE_FILE_CONTENTS, "utf8");
942
1650
  logger.success(`Wrote ${IGNORE_FILE}`);
943
1651
  }
944
1652
  }
945
1653
 
946
1654
  // src/commands/init-skill.ts
947
- import path7 from "path";
948
- import fs6 from "fs-extra";
1655
+ import path8 from "path";
1656
+ import fs7 from "fs-extra";
949
1657
  import ora2 from "ora";
950
1658
 
951
1659
  // src/config/ai-targets.ts
@@ -953,27 +1661,33 @@ var SKILL_TARGETS = [
953
1661
  {
954
1662
  id: "claude",
955
1663
  label: "Claude Code",
956
- entryPath: (slug2) => `.claude/skills/${slug2}/SKILL.md`,
1664
+ entryPath: (slug) => `.claude/skills/${slug}/SKILL.md`,
957
1665
  note: "Claude Code loads skills from .claude/skills/<name>/SKILL.md"
958
1666
  },
959
1667
  {
960
1668
  id: "codex",
961
1669
  label: "OpenAI Codex CLI",
962
- entryPath: (slug2) => `.codex/skills/${slug2}/SKILL.md`,
1670
+ entryPath: (slug) => `.codex/skills/${slug}/SKILL.md`,
963
1671
  note: "Codex CLI loads project skills from .codex/skills/<name>/SKILL.md"
964
1672
  },
965
1673
  {
966
1674
  id: "antigravity",
967
1675
  label: "Google Antigravity",
968
- entryPath: (slug2) => `.agents/skills/${slug2}.md`,
1676
+ entryPath: (slug) => `.agents/skills/${slug}.md`,
969
1677
  note: "Antigravity discovers skills under .agents/skills/"
970
1678
  }
971
1679
  ];
972
1680
  var SKILL_TARGET_BY_ID = new Map(SKILL_TARGETS.map((t) => [t.id, t]));
973
1681
  var SKILL_TARGET_IDS = SKILL_TARGETS.map((t) => t.id);
974
1682
 
1683
+ // src/utils/slug.ts
1684
+ function slugify(input, fallback = "project") {
1685
+ const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1686
+ return slug || fallback;
1687
+ }
1688
+
975
1689
  // src/core/skill-generator.ts
976
- var FRAMEWORK_LABELS = {
1690
+ var FRAMEWORK_LABELS2 = {
977
1691
  next: "Next.js",
978
1692
  nuxt: "Nuxt",
979
1693
  remix: "Remix",
@@ -1004,10 +1718,6 @@ var PRIMARY_SCRIPTS = [
1004
1718
  "format:check",
1005
1719
  "typecheck"
1006
1720
  ];
1007
- function slugify(input) {
1008
- const slug2 = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1009
- return slug2 || "project";
1010
- }
1011
1721
  function installCommand(pm) {
1012
1722
  switch (pm) {
1013
1723
  case "yarn":
@@ -1041,7 +1751,7 @@ function orderedScripts(scripts) {
1041
1751
  function toolingByCategoryLabel(tooling) {
1042
1752
  const groups = /* @__PURE__ */ new Map();
1043
1753
  for (const file of tooling.files) {
1044
- const label = CATEGORY_BY_ID.get(file.category)?.label ?? file.category;
1754
+ const label = categoryLabel(file.category);
1045
1755
  const list = groups.get(label) ?? [];
1046
1756
  list.push(file.path);
1047
1757
  groups.set(label, list);
@@ -1110,15 +1820,15 @@ function conventionLines(args, scripts) {
1110
1820
  }
1111
1821
  function buildSkill(args) {
1112
1822
  const { name, metadata, tooling, structure, pkg } = args;
1113
- const slug2 = slugify(name);
1823
+ const slug = slugify(name);
1114
1824
  const pm = metadata.packageManager;
1115
- const framework = FRAMEWORK_LABELS[metadata.framework] ?? metadata.framework;
1825
+ const framework = FRAMEWORK_LABELS2[metadata.framework] ?? metadata.framework;
1116
1826
  const language = metadata.language === "typescript" ? "TypeScript" : "JavaScript";
1117
1827
  const scripts = pkg?.scripts ?? {};
1118
1828
  const description = `${name} project: ${framework}/${language} setup, build/test commands, and tooling conventions. Use this skill when working in or scaffolding this codebase.`;
1119
1829
  const lines = [];
1120
1830
  lines.push("---");
1121
- lines.push(`name: ${slug2}`);
1831
+ lines.push(`name: ${slug}`);
1122
1832
  lines.push(`description: ${yamlString(description)}`);
1123
1833
  lines.push("---");
1124
1834
  lines.push("");
@@ -1176,10 +1886,13 @@ function buildSkill(args) {
1176
1886
  lines.push(`- ${line}`);
1177
1887
  }
1178
1888
  lines.push("");
1179
- return { slug: slug2, content: lines.join("\n") };
1889
+ return { slug, content: lines.join("\n") };
1180
1890
  }
1181
1891
 
1182
1892
  // src/core/ai/cli.ts
1893
+ import { spawn as spawn2 } from "child_process";
1894
+
1895
+ // src/core/process.ts
1183
1896
  import { spawn } from "child_process";
1184
1897
  async function commandExists(bin) {
1185
1898
  const onWindows = process.platform === "win32";
@@ -1196,9 +1909,45 @@ async function commandExists(bin) {
1196
1909
  child.on("close", (code) => resolve(code === 0));
1197
1910
  });
1198
1911
  }
1912
+ async function getCommandOutput(bin, args = [], options = {}) {
1913
+ const { timeoutMs = 5e3, shell = false } = options;
1914
+ return new Promise((resolve) => {
1915
+ let settled = false;
1916
+ const finish = (result) => {
1917
+ if (settled) return;
1918
+ settled = true;
1919
+ resolve(result);
1920
+ };
1921
+ let child;
1922
+ try {
1923
+ child = spawn(bin, args, { shell, windowsHide: true });
1924
+ } catch {
1925
+ finish({ ok: false, stdout: "", stderr: "", code: null });
1926
+ return;
1927
+ }
1928
+ let stdout = "";
1929
+ let stderr = "";
1930
+ const timer = setTimeout(() => {
1931
+ child.kill();
1932
+ finish({ ok: false, stdout, stderr, code: null });
1933
+ }, timeoutMs);
1934
+ child.stdout?.on("data", (d) => stdout += d.toString());
1935
+ child.stderr?.on("data", (d) => stderr += d.toString());
1936
+ child.on("error", () => {
1937
+ clearTimeout(timer);
1938
+ finish({ ok: false, stdout, stderr, code: null });
1939
+ });
1940
+ child.on("close", (code) => {
1941
+ clearTimeout(timer);
1942
+ finish({ ok: code === 0, stdout, stderr, code });
1943
+ });
1944
+ });
1945
+ }
1946
+
1947
+ // src/core/ai/cli.ts
1199
1948
  async function runWithStdin(bin, args, input, timeoutMs = 12e4) {
1200
1949
  return new Promise((resolve, reject) => {
1201
- const child = spawn(bin, args, { shell: true, windowsHide: true });
1950
+ const child = spawn2(bin, args, { shell: true, windowsHide: true });
1202
1951
  let stdout = "";
1203
1952
  let stderr = "";
1204
1953
  const timer = setTimeout(() => {
@@ -1342,6 +2091,12 @@ async function resolveProvider(preference, modelOverride) {
1342
2091
  function buildSkillPrompt(args) {
1343
2092
  const scripts = Object.entries(args.scripts).map(([name, cmd]) => ` ${name}: ${cmd}`).join("\n");
1344
2093
  const tooling = args.toolingPaths.map((p) => ` ${p}`).join("\n");
2094
+ const template = args.rootSkill?.trim();
2095
+ const templateRule = template ? "\n- A USER SKILL TEMPLATE (the project's root SKILL.md) is provided below. Use it as the BASE for the entry file: preserve its headings, structure, tone, and any explicit instructions, and refine/fill it in using the PROJECT ANALYSIS. Do not drop content the author put there; do not contradict it." : "";
2096
+ const templateSection = template ? `
2097
+ USER SKILL TEMPLATE (project root SKILL.md \u2014 use this as the base; preserve and refine, keep "name: ${args.slug}" in the frontmatter):
2098
+ ${template}
2099
+ ` : "";
1345
2100
  return `You are an expert developer-tooling assistant. Generate a high-quality "skill" for an AI coding assistant: a document (plus optional supporting files) that teaches the assistant how to work productively in a specific software project.
1346
2101
 
1347
2102
  STRICT RULES:
@@ -1355,11 +2110,11 @@ REQUIREMENTS:
1355
2110
  - Include exactly one entry file whose path is "${args.entryFile}". It MUST start with YAML frontmatter containing "name: ${args.slug}" and a concise single-line "description", then clear markdown covering: tech stack, setup/install, common commands, tooling, project structure, and conventions.
1356
2111
  - You MAY add a few supporting files under "references/" (e.g. "references/commands.md") when genuinely useful. Keep the bundle small and focused.
1357
2112
  - All paths must be relative, use forward slashes, and must NOT contain ".." or be absolute.
1358
- - This skill targets ${args.target.label} and will be installed at ${args.entryPath}.
2113
+ - This skill targets ${args.target.label} and will be installed at ${args.entryPath}.${templateRule}
1359
2114
 
1360
2115
  PROJECT ANALYSIS (ground truth \u2014 refine and expand this, do not contradict it):
1361
2116
  ${args.analysis}
1362
-
2117
+ ${templateSection}
1363
2118
  CAPTURED CONFIG FILES:
1364
2119
  ${tooling || " (none)"}
1365
2120
 
@@ -1425,6 +2180,8 @@ async function initSkillCommand(options) {
1425
2180
  spinner.succeed(
1426
2181
  `Scanned ${scan.tooling.files.length} config file(s) and ${scan.structure.directories.length} director(ies)`
1427
2182
  );
2183
+ const rootSkillPath = path8.join(root, ROOT_SKILL_FILE);
2184
+ const rootSkill = await fs7.pathExists(rootSkillPath) ? await fs7.readFile(rootSkillPath, "utf8") : void 0;
1428
2185
  const name = options.name ?? scan.structure.root;
1429
2186
  const seed = buildSkill({
1430
2187
  name,
@@ -1452,6 +2209,7 @@ async function initSkillCommand(options) {
1452
2209
  const provider = await resolveProvider(options.provider, options.model);
1453
2210
  if (provider) {
1454
2211
  logger.info(`Generating with ${provider.via} (sending project setup only)\u2026`);
2212
+ if (rootSkill?.trim()) logger.info(`Using ${ROOT_SKILL_FILE} as the skill template.`);
1455
2213
  const aiSpinner = ora2({ text: "Authoring skill\u2026", isEnabled: !options.verbose }).start();
1456
2214
  try {
1457
2215
  const prompt = buildSkillPrompt({
@@ -1461,7 +2219,8 @@ async function initSkillCommand(options) {
1461
2219
  target,
1462
2220
  analysis: seed.content,
1463
2221
  toolingPaths: scan.tooling.files.map((f) => f.path),
1464
- scripts: scan.pkg?.scripts ?? {}
2222
+ scripts: scan.pkg?.scripts ?? {},
2223
+ rootSkill
1465
2224
  });
1466
2225
  const raw = await provider.run(prompt);
1467
2226
  const parsed = parseSkillBundle(raw);
@@ -1493,11 +2252,11 @@ async function initSkillCommand(options) {
1493
2252
  if (!safe) {
1494
2253
  throw new ReplicaxError(`Refusing to write unsafe skill path: ${f.path}`);
1495
2254
  }
1496
- return { rel: safe, abs: path7.join(root, ...safe.split("/")), content: f.content };
2255
+ return { rel: safe, abs: path8.join(root, ...safe.split("/")), content: f.content };
1497
2256
  });
1498
2257
  const conflicts = [];
1499
2258
  for (const file of planned) {
1500
- if (await fs6.pathExists(file.abs)) conflicts.push(file.rel);
2259
+ if (await fs7.pathExists(file.abs)) conflicts.push(file.rel);
1501
2260
  }
1502
2261
  if (conflicts.length > 0 && !options.force) {
1503
2262
  throw new ReplicaxError(
@@ -1506,26 +2265,26 @@ async function initSkillCommand(options) {
1506
2265
  );
1507
2266
  }
1508
2267
  for (const file of planned) {
1509
- await fs6.ensureDir(path7.dirname(file.abs));
1510
- await fs6.writeFile(file.abs, file.content, "utf8");
2268
+ await fs7.ensureDir(path8.dirname(file.abs));
2269
+ await fs7.writeFile(file.abs, file.content, "utf8");
1511
2270
  logger.detail(`wrote: ${file.rel}`);
1512
2271
  }
1513
2272
  logger.newline();
1514
2273
  logger.success(`Skill "${seed.slug}" written (${planned.length} file(s), via ${via})`);
1515
2274
  logger.hint(
1516
- `Location: ${relPosix(root, path7.join(root, ...(bundleRoot || entryFile).split("/")))}`
2275
+ `Location: ${relPosix(root, path8.join(root, ...(bundleRoot || entryFile).split("/")))}`
1517
2276
  );
1518
2277
  logger.hint(target.note);
1519
2278
  }
1520
2279
 
1521
2280
  // src/commands/extract.ts
1522
- import path9 from "path";
2281
+ import path10 from "path";
1523
2282
  import ora3 from "ora";
1524
2283
 
1525
2284
  // src/core/github.ts
1526
2285
  import os from "os";
1527
- import path8 from "path";
1528
- import fs7 from "fs-extra";
2286
+ import path9 from "path";
2287
+ import fs8 from "fs-extra";
1529
2288
  import { extract as tarExtract } from "tar";
1530
2289
  function parseGitHubRef(input) {
1531
2290
  const raw = input.trim();
@@ -1576,34 +2335,34 @@ function tokenFromEnv() {
1576
2335
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1577
2336
  return token?.trim() || void 0;
1578
2337
  }
1579
- function httpError(status, slug2, hasToken) {
2338
+ function httpError(status, slug, hasToken) {
1580
2339
  if (status === 404) {
1581
- return new ReplicaxError(`Repository not found: ${slug2}.`, [
2340
+ return new ReplicaxError(`Repository not found: ${slug}.`, [
1582
2341
  "Check the owner/repo spelling and the branch/tag/commit name.",
1583
2342
  hasToken ? "If it is private, make sure your token can access it." : "If it is private, set GITHUB_TOKEN (or GH_TOKEN) with repo access."
1584
2343
  ]);
1585
2344
  }
1586
2345
  if (status === 401) {
1587
- return new ReplicaxError(`GitHub rejected the credentials for ${slug2}.`, [
2346
+ return new ReplicaxError(`GitHub rejected the credentials for ${slug}.`, [
1588
2347
  "Check that GITHUB_TOKEN (or GH_TOKEN) is valid and not expired."
1589
2348
  ]);
1590
2349
  }
1591
2350
  if (status === 403 || status === 429) {
1592
- return new ReplicaxError(`GitHub rate limit hit while fetching ${slug2}.`, [
2351
+ return new ReplicaxError(`GitHub rate limit hit while fetching ${slug}.`, [
1593
2352
  hasToken ? "Wait a moment and try again." : "Set GITHUB_TOKEN (or GH_TOKEN) to raise the limit, then retry."
1594
2353
  ]);
1595
2354
  }
1596
- return new ReplicaxError(`GitHub returned HTTP ${status} for ${slug2}.`);
2355
+ return new ReplicaxError(`GitHub returned HTTP ${status} for ${slug}.`);
1597
2356
  }
1598
2357
  async function firstSubdir(dir) {
1599
- const entries = await fs7.readdir(dir, { withFileTypes: true });
2358
+ const entries = await fs8.readdir(dir, { withFileTypes: true });
1600
2359
  for (const entry of entries) {
1601
- if (entry.isDirectory()) return path8.join(dir, entry.name);
2360
+ if (entry.isDirectory()) return path9.join(dir, entry.name);
1602
2361
  }
1603
2362
  return null;
1604
2363
  }
1605
2364
  async function downloadRepo(ref) {
1606
- const slug2 = `${ref.owner}/${ref.repo}`;
2365
+ const slug = `${ref.owner}/${ref.repo}`;
1607
2366
  const url = `https://api.github.com/repos/${ref.owner}/${ref.repo}/tarball` + (ref.ref ? `/${encodeURIComponent(ref.ref)}` : "");
1608
2367
  const token = tokenFromEnv();
1609
2368
  const headers = {
@@ -1620,19 +2379,19 @@ async function downloadRepo(ref) {
1620
2379
  ]);
1621
2380
  }
1622
2381
  if (!res.ok) {
1623
- throw httpError(res.status, slug2, Boolean(token));
2382
+ throw httpError(res.status, slug, Boolean(token));
1624
2383
  }
1625
- const tmpRoot = await fs7.mkdtemp(path8.join(os.tmpdir(), "replicax-extract-"));
1626
- const cleanup = () => fs7.remove(tmpRoot);
2384
+ const tmpRoot = await fs8.mkdtemp(path9.join(os.tmpdir(), "replicax-extract-"));
2385
+ const cleanup = () => fs8.remove(tmpRoot);
1627
2386
  try {
1628
- const tarPath = path8.join(tmpRoot, "repo.tar.gz");
1629
- await fs7.writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
1630
- const extractDir = path8.join(tmpRoot, "src");
1631
- await fs7.ensureDir(extractDir);
2387
+ const tarPath = path9.join(tmpRoot, "repo.tar.gz");
2388
+ await fs8.writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
2389
+ const extractDir = path9.join(tmpRoot, "src");
2390
+ await fs8.ensureDir(extractDir);
1632
2391
  await tarExtract({ file: tarPath, cwd: extractDir, strip: 0 });
1633
2392
  const repoRoot = await firstSubdir(extractDir);
1634
2393
  if (!repoRoot) {
1635
- throw new ReplicaxError(`The downloaded archive for ${slug2} was empty.`);
2394
+ throw new ReplicaxError(`The downloaded archive for ${slug} was empty.`);
1636
2395
  }
1637
2396
  return { dir: repoRoot, cleanup };
1638
2397
  } catch (err) {
@@ -1681,7 +2440,7 @@ async function extractCommand(repo, options) {
1681
2440
  logger.info("Dry run \u2014 no files were written.");
1682
2441
  return;
1683
2442
  }
1684
- const outRoot = options.out ? path9.resolve(options.out) : process.cwd();
2443
+ const outRoot = options.out ? path10.resolve(options.out) : process.cwd();
1685
2444
  const dir = profileDir(outRoot);
1686
2445
  if (await profileExists(dir)) {
1687
2446
  logger.warn(
@@ -1698,8 +2457,8 @@ async function extractCommand(repo, options) {
1698
2457
  }
1699
2458
 
1700
2459
  // src/commands/create.ts
1701
- import path11 from "path";
1702
- import fs9 from "fs-extra";
2460
+ import path12 from "path";
2461
+ import fs10 from "fs-extra";
1703
2462
 
1704
2463
  // src/core/conflict-resolver.ts
1705
2464
  import { select } from "@inquirer/prompts";
@@ -1741,8 +2500,8 @@ var ConflictResolver = class {
1741
2500
  };
1742
2501
 
1743
2502
  // src/core/project-generator.ts
1744
- import path10 from "path";
1745
- import fs8 from "fs-extra";
2503
+ import path11 from "path";
2504
+ import fs9 from "fs-extra";
1746
2505
  async function generateProject(options) {
1747
2506
  const { bundle, targetDir, projectName, dryRun, conflict } = options;
1748
2507
  const result = {
@@ -1752,16 +2511,16 @@ async function generateProject(options) {
1752
2511
  filesSkipped: 0,
1753
2512
  unsafeSkipped: []
1754
2513
  };
1755
- if (!dryRun) await fs8.ensureDir(targetDir);
2514
+ if (!dryRun) await fs9.ensureDir(targetDir);
1756
2515
  for (const dir of bundle.structure.directories) {
1757
2516
  const safe = safeJoinable(dir);
1758
2517
  if (!safe) {
1759
2518
  result.unsafeSkipped.push(dir);
1760
2519
  continue;
1761
2520
  }
1762
- const full = path10.join(targetDir, safe);
1763
- const existed = await fs8.pathExists(full);
1764
- if (!dryRun) await fs8.ensureDir(full);
2521
+ const full = path11.join(targetDir, safe);
2522
+ const existed = await fs9.pathExists(full);
2523
+ if (!dryRun) await fs9.ensureDir(full);
1765
2524
  if (!existed) result.dirsCreated += 1;
1766
2525
  result.entries.push({ kind: "dir", path: safe, action: existed ? "skip" : "create" });
1767
2526
  }
@@ -1785,8 +2544,8 @@ async function writeFile(relPath, content, options, result) {
1785
2544
  logger.warn(`Refusing to write unsafe path from profile: ${relPath}`);
1786
2545
  return;
1787
2546
  }
1788
- const full = path10.join(options.targetDir, safe);
1789
- const exists = await fs8.pathExists(full);
2547
+ const full = path11.join(options.targetDir, safe);
2548
+ const exists = await fs9.pathExists(full);
1790
2549
  let action2 = exists ? "overwrite" : "create";
1791
2550
  if (exists) {
1792
2551
  const decision = await options.conflict.resolve(safe);
@@ -1799,8 +2558,8 @@ async function writeFile(relPath, content, options, result) {
1799
2558
  action2 = "overwrite";
1800
2559
  }
1801
2560
  if (!options.dryRun) {
1802
- await fs8.ensureDir(path10.dirname(full));
1803
- await fs8.writeFile(full, content, "utf8");
2561
+ await fs9.ensureDir(path11.dirname(full));
2562
+ await fs9.writeFile(full, content, "utf8");
1804
2563
  }
1805
2564
  result.filesWritten += 1;
1806
2565
  result.entries.push({ kind: "file", path: safe, action: action2 });
@@ -1808,7 +2567,7 @@ async function writeFile(relPath, content, options, result) {
1808
2567
  }
1809
2568
 
1810
2569
  // src/core/installer.ts
1811
- import { spawn as spawn2 } from "child_process";
2570
+ import { spawn as spawn3 } from "child_process";
1812
2571
  var COMMANDS = {
1813
2572
  npm: ["npm", "install"],
1814
2573
  pnpm: ["pnpm", "install"],
@@ -1819,7 +2578,7 @@ function installDependencies(cwd, manager) {
1819
2578
  if (manager === "unknown") return Promise.resolve(false);
1820
2579
  const [command, ...args] = COMMANDS[manager];
1821
2580
  return new Promise((resolve) => {
1822
- const child = spawn2(command, args, {
2581
+ const child = spawn3(command, args, {
1823
2582
  cwd,
1824
2583
  stdio: "inherit",
1825
2584
  // npm/pnpm/yarn are .cmd shims on Windows; a shell resolves them.
@@ -1849,9 +2608,9 @@ async function createCommand(projectName, options) {
1849
2608
  logger.warn(`Profile integrity check found ${mismatches.length} issue(s); continuing anyway.`);
1850
2609
  logger.hint("Run `replicax validate` for details.");
1851
2610
  }
1852
- const targetDir = path11.resolve(process.cwd(), projectName);
1853
- const leafName = path11.basename(targetDir);
1854
- if (path11.resolve(process.cwd()) === targetDir) {
2611
+ const targetDir = path12.resolve(process.cwd(), projectName);
2612
+ const leafName = path12.basename(targetDir);
2613
+ if (path12.resolve(process.cwd()) === targetDir) {
1855
2614
  throw new ReplicaxError("Refusing to scaffold into the current directory.", [
1856
2615
  "Pass a new project name, e.g. `replicax create my-app`."
1857
2616
  ]);
@@ -1900,8 +2659,8 @@ async function maybeInstall(manager, targetDir, options, hasPackageJson) {
1900
2659
  logger.hint("No package manager detected; run your install command manually.");
1901
2660
  return;
1902
2661
  }
1903
- const pkgPath = path11.join(targetDir, "package.json");
1904
- const pkg = await fs9.readJson(pkgPath).catch(() => null);
2662
+ const pkgPath = path12.join(targetDir, "package.json");
2663
+ const pkg = await fs10.readJson(pkgPath).catch(() => null);
1905
2664
  if (!pkg?.devDependencies || Object.keys(pkg.devDependencies).length === 0) {
1906
2665
  logger.hint("No dependencies to install.");
1907
2666
  return;
@@ -1917,27 +2676,27 @@ async function maybeInstall(manager, targetDir, options, hasPackageJson) {
1917
2676
  import ora4 from "ora";
1918
2677
 
1919
2678
  // src/core/diff.ts
1920
- function diffChecksums(prev, next) {
2679
+ function diffStringMaps(prev, next, options = {}) {
2680
+ const ignore2 = options.ignoreKeys;
1921
2681
  const added = [];
1922
2682
  const removed = [];
1923
2683
  const changed = [];
1924
- let packageJsonChanged = false;
1925
- const keys = /* @__PURE__ */ new Set([...Object.keys(prev.files), ...Object.keys(next.files)]);
2684
+ const keys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
1926
2685
  for (const key of keys) {
1927
- const before = prev.files[key];
1928
- const after = next.files[key];
1929
- if (key === PACKAGE_JSON_KEY) {
1930
- if (before !== after) packageJsonChanged = true;
1931
- continue;
1932
- }
2686
+ if (ignore2?.has(key)) continue;
2687
+ const before = prev[key];
2688
+ const after = next[key];
1933
2689
  if (before === void 0) added.push(key);
1934
2690
  else if (after === void 0) removed.push(key);
1935
2691
  else if (before !== after) changed.push(key);
1936
2692
  }
1937
- return {
1938
- files: { added: added.sort(), removed: removed.sort(), changed: changed.sort() },
1939
- packageJsonChanged
1940
- };
2693
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
2694
+ }
2695
+ var PACKAGE_JSON_KEYS = /* @__PURE__ */ new Set([PACKAGE_JSON_KEY]);
2696
+ function diffChecksums(prev, next) {
2697
+ const files = diffStringMaps(prev.files, next.files, { ignoreKeys: PACKAGE_JSON_KEYS });
2698
+ const packageJsonChanged = prev.files[PACKAGE_JSON_KEY] !== next.files[PACKAGE_JSON_KEY];
2699
+ return { files, packageJsonChanged };
1941
2700
  }
1942
2701
  function diffStructure(prev, next) {
1943
2702
  const before = new Set(prev.directories);
@@ -2038,7 +2797,16 @@ function printList(title, items, marker) {
2038
2797
 
2039
2798
  // src/commands/inspect.ts
2040
2799
  import Table from "cli-table3";
2041
- var SECTIONS = ["profile", "tooling", "structure", "metadata"];
2800
+
2801
+ // src/utils/format.ts
2802
+ function formatBytes(bytes) {
2803
+ if (bytes < 1024) return `${bytes} B`;
2804
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2805
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2806
+ }
2807
+
2808
+ // src/commands/inspect.ts
2809
+ var SECTIONS = ["profile", "tooling", "structure", "metadata", "detections"];
2042
2810
  async function inspectCommand(options) {
2043
2811
  const dir = options.profile ? await resolveProfileDir(options.profile) : profileDir(process.cwd());
2044
2812
  if (!await profileExists(dir)) {
@@ -2052,15 +2820,38 @@ async function inspectCommand(options) {
2052
2820
  const bundle = await loadBundle(dir);
2053
2821
  const section = options.section;
2054
2822
  if (options.json) {
2055
- const payload = section ? { [section]: bundle[section] } : bundle;
2056
- logger.out(JSON.stringify(payload, null, 2));
2823
+ logger.out(JSON.stringify(jsonPayload(bundle, section), null, 2));
2057
2824
  return;
2058
2825
  }
2059
2826
  if (!section || section === "profile") printProfile(bundle);
2060
2827
  if (!section || section === "metadata") printMetadata(bundle);
2828
+ if (!section || section === "detections") printDetectionsSection(bundle);
2061
2829
  if (!section || section === "tooling") printTooling(bundle);
2062
2830
  if (!section || section === "structure") printStructure(bundle);
2063
2831
  }
2832
+ function jsonPayload(bundle, section) {
2833
+ if (!section) return bundle;
2834
+ if (section === "detections") return { detections: bundle.metadata.detections ?? [] };
2835
+ return { [section]: bundle[section] };
2836
+ }
2837
+ function printDetectionsSection(bundle) {
2838
+ const detections = bundle.metadata.detections ?? [];
2839
+ logger.out(pc.bold(`Detections (${detections.length})`));
2840
+ if (detections.length === 0) {
2841
+ logger.out(" (none)");
2842
+ logger.out("");
2843
+ return;
2844
+ }
2845
+ const table = new Table({
2846
+ head: ["Category", "Tool", "Confidence", "Evidence"],
2847
+ style: { head: ["cyan"], border: ["dim"] }
2848
+ });
2849
+ for (const d of detections) {
2850
+ table.push([d.category, d.name, `${Math.round(d.confidence * 100)}%`, d.evidence.join(", ")]);
2851
+ }
2852
+ logger.out(table.toString());
2853
+ logger.out("");
2854
+ }
2064
2855
  function printProfile(bundle) {
2065
2856
  const p = bundle.profile;
2066
2857
  logger.out(pc.bold("Profile"));
@@ -2094,12 +2885,7 @@ function printTooling(bundle) {
2094
2885
  table.push(["Package Management & Monorepos", "package.json", "json", "template"]);
2095
2886
  }
2096
2887
  for (const file of [...tooling.files].sort((a, b) => a.path.localeCompare(b.path))) {
2097
- table.push([
2098
- CATEGORY_BY_ID.get(file.category)?.label ?? file.category,
2099
- file.path,
2100
- file.variant,
2101
- formatBytes(file.bytes)
2102
- ]);
2888
+ table.push([categoryLabel(file.category), file.path, file.variant, formatBytes(file.bytes)]);
2103
2889
  }
2104
2890
  logger.out(table.toString());
2105
2891
  logger.out("");
@@ -2110,11 +2896,6 @@ function printStructure(bundle) {
2110
2896
  logger.out(renderTree(structure.directories, structure.root));
2111
2897
  logger.out("");
2112
2898
  }
2113
- function formatBytes(bytes) {
2114
- if (bytes < 1024) return `${bytes} B`;
2115
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2116
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2117
- }
2118
2899
 
2119
2900
  // src/commands/validate.ts
2120
2901
  async function validateCommand(options) {
@@ -2152,20 +2933,20 @@ async function validateCommand(options) {
2152
2933
  }
2153
2934
 
2154
2935
  // src/commands/export.ts
2155
- import path13 from "path";
2156
- import fs11 from "fs-extra";
2936
+ import path14 from "path";
2937
+ import fs12 from "fs-extra";
2157
2938
  import ora5 from "ora";
2158
2939
 
2159
2940
  // src/core/archive.ts
2160
2941
  import os2 from "os";
2161
- import path12 from "path";
2162
- import fs10 from "fs-extra";
2942
+ import path13 from "path";
2943
+ import fs11 from "fs-extra";
2163
2944
  import { create as tarCreate, extract as tarExtract2 } from "tar";
2164
2945
  async function exportProfile(profileDirectory, outPath) {
2165
- const resolvedOut = path12.resolve(outPath);
2166
- await fs10.ensureDir(path12.dirname(resolvedOut));
2167
- const parent = path12.dirname(profileDirectory);
2168
- const base = path12.basename(profileDirectory);
2946
+ const resolvedOut = path13.resolve(outPath);
2947
+ await fs11.ensureDir(path13.dirname(resolvedOut));
2948
+ const parent = path13.dirname(profileDirectory);
2949
+ const base = path13.basename(profileDirectory);
2169
2950
  await tarCreate(
2170
2951
  {
2171
2952
  gzip: true,
@@ -2178,21 +2959,21 @@ async function exportProfile(profileDirectory, outPath) {
2178
2959
  );
2179
2960
  }
2180
2961
  async function extractToTemp(archivePath) {
2181
- const resolved = path12.resolve(archivePath);
2182
- if (!await fs10.pathExists(resolved)) {
2962
+ const resolved = path13.resolve(archivePath);
2963
+ if (!await fs11.pathExists(resolved)) {
2183
2964
  throw new Error(`Archive not found: ${archivePath}`);
2184
2965
  }
2185
- const tmp = await fs10.mkdtemp(path12.join(os2.tmpdir(), "replicax-import-"));
2966
+ const tmp = await fs11.mkdtemp(path13.join(os2.tmpdir(), "replicax-import-"));
2186
2967
  await tarExtract2({ file: resolved, cwd: tmp, strip: 0 });
2187
2968
  return tmp;
2188
2969
  }
2189
2970
  async function findProfileRoot(dir) {
2190
- const hasProfile = async (d) => fs10.pathExists(path12.join(d, PROFILE_FILES.profile));
2971
+ const hasProfile = async (d) => fs11.pathExists(path13.join(d, PROFILE_FILES.profile));
2191
2972
  if (await hasProfile(dir)) return dir;
2192
- const entries = await fs10.readdir(dir, { withFileTypes: true });
2973
+ const entries = await fs11.readdir(dir, { withFileTypes: true });
2193
2974
  for (const entry of entries) {
2194
2975
  if (entry.isDirectory()) {
2195
- const candidate = path12.join(dir, entry.name);
2976
+ const candidate = path13.join(dir, entry.name);
2196
2977
  if (await hasProfile(candidate)) return candidate;
2197
2978
  }
2198
2979
  }
@@ -2200,33 +2981,27 @@ async function findProfileRoot(dir) {
2200
2981
  }
2201
2982
 
2202
2983
  // src/commands/export.ts
2203
- function slug(name) {
2204
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "profile";
2205
- }
2206
2984
  async function exportCommand(options) {
2207
2985
  const dir = options.profile ? await resolveProfileDir(options.profile) : profileDir(process.cwd());
2208
2986
  if (!await profileExists(dir)) {
2209
2987
  throw new ReplicaxError("No ReplicaX profile found to export.", ["Run `replicax init` first."]);
2210
2988
  }
2211
2989
  const bundle = await loadBundle(dir);
2212
- const outPath = path13.resolve(options.out ?? `${slug(bundle.profile.name)}.replicax.tar.gz`);
2990
+ const outPath = path14.resolve(
2991
+ options.out ?? `${slugify(bundle.profile.name, "profile")}.replicax.tar.gz`
2992
+ );
2213
2993
  const spinner = ora5({ text: "Packaging profile\u2026" }).start();
2214
2994
  await exportProfile(dir, outPath);
2215
2995
  spinner.stop();
2216
- const { size } = await fs11.stat(outPath);
2996
+ const { size } = await fs12.stat(outPath);
2217
2997
  logger.success(
2218
- `Exported "${bundle.profile.name}" \u2192 ${path13.relative(process.cwd(), outPath)} (${formatBytes2(size)})`
2998
+ `Exported "${bundle.profile.name}" \u2192 ${path14.relative(process.cwd(), outPath)} (${formatBytes(size)})`
2219
2999
  );
2220
3000
  logger.hint("Share it, then `replicax import <file>` elsewhere.");
2221
3001
  }
2222
- function formatBytes2(bytes) {
2223
- if (bytes < 1024) return `${bytes} B`;
2224
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2225
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2226
- }
2227
3002
 
2228
3003
  // src/commands/import.ts
2229
- import fs12 from "fs-extra";
3004
+ import fs13 from "fs-extra";
2230
3005
  import ora6 from "ora";
2231
3006
  import { confirm as confirm2 } from "@inquirer/prompts";
2232
3007
  async function importCommand(archivePath, options) {
@@ -2254,7 +3029,7 @@ async function importCommand(archivePath, options) {
2254
3029
  "Re-run with --force to overwrite it."
2255
3030
  ]);
2256
3031
  }
2257
- await fs12.remove(dest);
3032
+ await fs13.remove(dest);
2258
3033
  }
2259
3034
  await saveBundle(dest, bundle);
2260
3035
  logger.newline();
@@ -2263,8 +3038,409 @@ async function importCommand(archivePath, options) {
2263
3038
  );
2264
3039
  logger.hint("Create a project with: replicax create <project-name>");
2265
3040
  } finally {
2266
- await fs12.remove(tmp).catch(() => void 0);
3041
+ await fs13.remove(tmp).catch(() => void 0);
3042
+ }
3043
+ }
3044
+
3045
+ // src/config/environment-tools.ts
3046
+ var ENVIRONMENT_TOOLS = [
3047
+ { id: "node", name: "Node.js", bin: "node", versionArgs: ["--version"], kind: "runtime" },
3048
+ { id: "git", name: "Git", bin: "git", versionArgs: ["--version"], kind: "vcs" },
3049
+ { id: "npm", name: "npm", bin: "npm", versionArgs: ["--version"], kind: "package-manager" },
3050
+ { id: "pnpm", name: "pnpm", bin: "pnpm", versionArgs: ["--version"], kind: "package-manager" },
3051
+ { id: "yarn", name: "Yarn", bin: "yarn", versionArgs: ["--version"], kind: "package-manager" },
3052
+ { id: "bun", name: "Bun", bin: "bun", versionArgs: ["--version"], kind: "package-manager" },
3053
+ { id: "docker", name: "Docker", bin: "docker", versionArgs: ["--version"], kind: "container" },
3054
+ {
3055
+ id: "vscode",
3056
+ name: "VS Code",
3057
+ bin: "code",
3058
+ versionArgs: ["--version"],
3059
+ kind: "editor",
3060
+ // `code --version` prints version on the first line, then commit + arch.
3061
+ parseVersion: (raw) => raw.split(/\r?\n/)[0]?.trim() || void 0
3062
+ },
3063
+ { id: "cursor", name: "Cursor", bin: "cursor", versionArgs: ["--version"], kind: "editor" },
3064
+ {
3065
+ id: "claude-code",
3066
+ name: "Claude Code",
3067
+ bin: "claude",
3068
+ versionArgs: ["--version"],
3069
+ kind: "editor"
3070
+ },
3071
+ { id: "windsurf", name: "Windsurf", bin: "windsurf", versionArgs: ["--version"], kind: "editor" }
3072
+ ];
3073
+
3074
+ // src/core/environment.ts
3075
+ function parseVersionDefault(raw) {
3076
+ const trimmed = raw.trim();
3077
+ const semver = trimmed.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
3078
+ if (semver) return semver[0];
3079
+ const loose = trimmed.match(/\d+\.\d+/);
3080
+ return loose ? loose[0] : void 0;
3081
+ }
3082
+ var defaultProbe = async (tool) => {
3083
+ const out = await getCommandOutput(tool.bin, tool.versionArgs, { shell: true });
3084
+ if (out.ok) {
3085
+ const raw = out.stdout.trim() || out.stderr.trim();
3086
+ const parse = tool.parseVersion ?? parseVersionDefault;
3087
+ return { found: true, version: parse(raw) };
3088
+ }
3089
+ return { found: await commandExists(tool.bin) };
3090
+ };
3091
+ async function runEnvironmentChecks(tools = ENVIRONMENT_TOOLS, probe = defaultProbe) {
3092
+ return Promise.all(
3093
+ tools.map(async (tool) => {
3094
+ const result = await probe(tool);
3095
+ return {
3096
+ id: tool.id,
3097
+ name: tool.name,
3098
+ kind: tool.kind,
3099
+ found: result.found,
3100
+ version: result.version
3101
+ };
3102
+ })
3103
+ );
3104
+ }
3105
+
3106
+ // src/commands/doctor.ts
3107
+ async function doctorCommand(options) {
3108
+ const checks = await runEnvironmentChecks();
3109
+ if (options.json) {
3110
+ logger.out(JSON.stringify({ checks }, null, 2));
3111
+ return;
3112
+ }
3113
+ logger.out(pc.bold("Developer environment"));
3114
+ logger.out("");
3115
+ for (const check of checks) {
3116
+ const note = check.found ? check.version : "not found";
3117
+ logger.out(statusLine(check.found, check.name, note));
3118
+ }
3119
+ const found = checks.filter((c) => c.found).length;
3120
+ logger.out("");
3121
+ logger.out(pc.dim(`${found}/${checks.length} tools found`));
3122
+ }
3123
+
3124
+ // src/commands/compare.ts
3125
+ import path15 from "path";
3126
+ import fs14 from "fs-extra";
3127
+
3128
+ // src/core/compare.ts
3129
+ function detectionsOf(bundle) {
3130
+ return bundle.metadata.detections ?? [];
3131
+ }
3132
+ var toolingComparator = {
3133
+ id: "tooling",
3134
+ title: "Tooling",
3135
+ compare(a, b) {
3136
+ const aById = new Map(detectionsOf(a).map((d) => [d.id, d]));
3137
+ const bById = new Map(detectionsOf(b).map((d) => [d.id, d]));
3138
+ const added = [];
3139
+ const removed = [];
3140
+ const changed = [];
3141
+ for (const [id, d] of bById) if (!aById.has(id)) added.push(d.name);
3142
+ for (const [id, d] of aById) if (!bById.has(id)) removed.push(d.name);
3143
+ for (const [id, d] of aById) {
3144
+ const other = bById.get(id);
3145
+ if (other && other.confidence !== d.confidence) changed.push(d.name);
3146
+ }
3147
+ return sortSection({ id: this.id, title: this.title, added, removed, changed });
3148
+ }
3149
+ };
3150
+ var PACKAGE_JSON_KEYS2 = /* @__PURE__ */ new Set([PACKAGE_JSON_KEY]);
3151
+ var configFilesComparator = {
3152
+ id: "config-files",
3153
+ title: "Configuration files",
3154
+ compare(a, b) {
3155
+ const diff = diffStringMaps(a.checksum.files, b.checksum.files, {
3156
+ ignoreKeys: PACKAGE_JSON_KEYS2
3157
+ });
3158
+ return { id: this.id, title: this.title, ...diff };
3159
+ }
3160
+ };
3161
+ var packageJsonComparator = {
3162
+ id: "package-json",
3163
+ title: "package.json",
3164
+ compare(a, b) {
3165
+ const flatten = (bundle) => {
3166
+ const pkg = bundle.tooling.packageJson;
3167
+ const out = {};
3168
+ for (const [name, cmd] of Object.entries(pkg?.scripts ?? {})) out[`script:${name}`] = cmd;
3169
+ for (const [name, ver] of Object.entries(pkg?.devDependencies ?? {})) {
3170
+ out[`devDependency:${name}`] = ver;
3171
+ }
3172
+ return out;
3173
+ };
3174
+ const diff = diffStringMaps(flatten(a), flatten(b));
3175
+ return { id: this.id, title: this.title, ...diff };
3176
+ }
3177
+ };
3178
+ var structureComparator = {
3179
+ id: "structure",
3180
+ title: "Structure",
3181
+ compare(a, b) {
3182
+ const before = new Set(a.structure.directories);
3183
+ const after = new Set(b.structure.directories);
3184
+ const added = b.structure.directories.filter((d) => !before.has(d));
3185
+ const removed = a.structure.directories.filter((d) => !after.has(d));
3186
+ return sortSection({ id: this.id, title: this.title, added, removed, changed: [] });
3187
+ }
3188
+ };
3189
+ var metadataComparator = {
3190
+ id: "metadata",
3191
+ title: "Metadata",
3192
+ compare(a, b) {
3193
+ const fields = [
3194
+ "language",
3195
+ "framework",
3196
+ "packageManager",
3197
+ "nodeVersion"
3198
+ ];
3199
+ const changed = [];
3200
+ for (const field of fields) {
3201
+ const from = String(a.metadata[field] ?? "");
3202
+ const to = String(b.metadata[field] ?? "");
3203
+ if (from !== to) changed.push(`${field}: ${from} \u2192 ${to}`);
3204
+ }
3205
+ return { id: this.id, title: this.title, added: [], removed: [], changed };
3206
+ }
3207
+ };
3208
+ var COMPARATORS = [
3209
+ toolingComparator,
3210
+ configFilesComparator,
3211
+ packageJsonComparator,
3212
+ structureComparator,
3213
+ metadataComparator
3214
+ ];
3215
+ function sortSection(section) {
3216
+ return {
3217
+ ...section,
3218
+ added: [...section.added].sort(),
3219
+ removed: [...section.removed].sort(),
3220
+ changed: [...section.changed].sort()
3221
+ };
3222
+ }
3223
+ function compareBundles(a, b) {
3224
+ return { sections: COMPARATORS.map((c) => c.compare(a, b)) };
3225
+ }
3226
+ function sectionHasChanges(section) {
3227
+ return section.added.length > 0 || section.removed.length > 0 || section.changed.length > 0;
3228
+ }
3229
+ function comparisonHasChanges(comparison) {
3230
+ return comparison.sections.some(sectionHasChanges);
3231
+ }
3232
+
3233
+ // src/commands/compare.ts
3234
+ async function resolveBundle(input) {
3235
+ const resolved = path15.resolve(input);
3236
+ if (!await fs14.pathExists(resolved)) {
3237
+ throw new ReplicaxError(`Path not found: ${input}`);
3238
+ }
3239
+ try {
3240
+ const dir = await resolveProfileDir(input);
3241
+ const bundle2 = await loadBundle(dir);
3242
+ return { bundle: bundle2, label: `${bundle2.profile.name} (profile)` };
3243
+ } catch {
3244
+ }
3245
+ const stat = await fs14.stat(resolved);
3246
+ if (!stat.isDirectory()) {
3247
+ throw new ReplicaxError(`Cannot compare "${input}": not a profile or a project directory.`, [
3248
+ "Pass a project folder or a directory containing a .replicax profile."
3249
+ ]);
3250
+ }
3251
+ const scan = await scanProject(resolved);
3252
+ const bundle = buildBundle({
3253
+ name: path15.basename(resolved) || "project",
3254
+ tooling: scan.tooling,
3255
+ structure: scan.structure,
3256
+ metadata: scan.metadata
3257
+ });
3258
+ return { bundle, label: `${path15.basename(resolved)} (scanned)` };
3259
+ }
3260
+ async function compareCommand(source, target, options) {
3261
+ const [a, b] = await Promise.all([resolveBundle(source), resolveBundle(target)]);
3262
+ const comparison = compareBundles(a.bundle, b.bundle);
3263
+ if (options.json) {
3264
+ logger.out(JSON.stringify({ source: a.label, target: b.label, ...comparison }, null, 2));
3265
+ return;
3266
+ }
3267
+ logger.out(pc.bold(`Comparing ${a.label} \u2192 ${b.label}`));
3268
+ logger.out("");
3269
+ if (!comparisonHasChanges(comparison)) {
3270
+ logger.out("No differences.");
3271
+ return;
3272
+ }
3273
+ printGroup("Added", collect(comparison, "added"), pc.green("+"));
3274
+ printGroup("Removed", collect(comparison, "removed"), pc.red("-"));
3275
+ printGroup("Changed", collect(comparison, "changed"), pc.yellow("~"));
3276
+ }
3277
+ function collect(comparison, bucket) {
3278
+ const out = [];
3279
+ for (const section of comparison.sections) {
3280
+ for (const item of section[bucket]) {
3281
+ out.push(`${item} ${pc.dim(`(${section.title})`)}`);
3282
+ }
2267
3283
  }
3284
+ return out;
3285
+ }
3286
+ function printGroup(label, items, marker) {
3287
+ if (items.length === 0) return;
3288
+ logger.out(pc.bold(`${label}:`));
3289
+ for (const item of items) logger.out(` ${marker} ${item}`);
3290
+ logger.out("");
3291
+ }
3292
+
3293
+ // src/commands/audit.ts
3294
+ import path16 from "path";
3295
+
3296
+ // src/core/audit/rules.ts
3297
+ function detected(ctx, ids) {
3298
+ const present = new Set(ctx.detections.map((d) => d.id));
3299
+ return ids.some((id) => present.has(id));
3300
+ }
3301
+ var AUDIT_RULES = [
3302
+ {
3303
+ id: "linting",
3304
+ title: "Linting",
3305
+ weight: 15,
3306
+ category: "quality",
3307
+ passes: (c) => detected(c, ["eslint", "biome"]),
3308
+ recommendation: "Add ESLint to catch problems with static analysis."
3309
+ },
3310
+ {
3311
+ id: "formatting",
3312
+ title: "Formatting",
3313
+ weight: 10,
3314
+ category: "quality",
3315
+ passes: (c) => detected(c, ["prettier", "biome"]),
3316
+ recommendation: "Add Prettier to keep formatting consistent."
3317
+ },
3318
+ {
3319
+ id: "testing",
3320
+ title: "Testing",
3321
+ weight: 20,
3322
+ category: "quality",
3323
+ passes: (c) => detected(c, ["vitest", "jest", "playwright", "cypress"]),
3324
+ recommendation: "Add a test runner such as Vitest or Jest."
3325
+ },
3326
+ {
3327
+ id: "git-hooks",
3328
+ title: "Git hooks",
3329
+ weight: 10,
3330
+ category: "quality",
3331
+ passes: (c) => detected(c, ["husky", "lefthook"]),
3332
+ recommendation: "Add Husky to run checks before each commit."
3333
+ },
3334
+ {
3335
+ id: "ci",
3336
+ title: "CI/CD",
3337
+ weight: 20,
3338
+ category: "delivery",
3339
+ passes: (c) => detected(c, ["github-actions", "gitlab-ci", "circleci", "jenkins", "azure-pipelines"]),
3340
+ recommendation: "Add a CI pipeline (e.g. GitHub Actions) to run checks on every push."
3341
+ },
3342
+ {
3343
+ id: "containerization",
3344
+ title: "Containerization",
3345
+ weight: 10,
3346
+ category: "delivery",
3347
+ passes: (c) => detected(c, ["docker", "docker-compose"]),
3348
+ recommendation: "Add a Dockerfile to containerize the application."
3349
+ },
3350
+ {
3351
+ id: "typescript",
3352
+ title: "TypeScript",
3353
+ weight: 10,
3354
+ category: "quality",
3355
+ passes: (c) => detected(c, ["typescript"]),
3356
+ recommendation: "Adopt TypeScript for type safety."
3357
+ },
3358
+ {
3359
+ id: "commit-linting",
3360
+ title: "Commit linting",
3361
+ weight: 3,
3362
+ category: "quality",
3363
+ passes: (c) => detected(c, ["commitlint"]),
3364
+ recommendation: "Add Commitlint to standardize commit messages."
3365
+ },
3366
+ {
3367
+ id: "staged-linting",
3368
+ title: "Staged-file linting",
3369
+ weight: 2,
3370
+ category: "quality",
3371
+ passes: (c) => detected(c, ["lint-staged"]),
3372
+ recommendation: "Add lint-staged to lint only changed files."
3373
+ }
3374
+ ];
3375
+
3376
+ // src/core/audit/engine.ts
3377
+ function runAudit(ctx, rules = AUDIT_RULES) {
3378
+ const evaluated = rules.map((rule) => ({
3379
+ id: rule.id,
3380
+ title: rule.title,
3381
+ category: rule.category,
3382
+ weight: rule.weight,
3383
+ passed: rule.passes(ctx),
3384
+ recommendation: rule.recommendation
3385
+ }));
3386
+ const totalWeight = evaluated.reduce((sum, r) => sum + r.weight, 0);
3387
+ const passedWeight = evaluated.filter((r) => r.passed).reduce((sum, r) => sum + r.weight, 0);
3388
+ const score = totalWeight === 0 ? 100 : Math.round(passedWeight / totalWeight * 100);
3389
+ const failed = evaluated.filter((r) => !r.passed);
3390
+ return {
3391
+ score,
3392
+ maxScore: 100,
3393
+ rules: evaluated,
3394
+ missing: failed.map((r) => r.title),
3395
+ recommendations: failed.map((r) => r.recommendation)
3396
+ };
3397
+ }
3398
+
3399
+ // src/commands/audit.ts
3400
+ async function buildContext(options) {
3401
+ if (options.profile) {
3402
+ const dir = await resolveProfileDir(options.profile);
3403
+ const bundle = await loadBundle(dir);
3404
+ return {
3405
+ ctx: {
3406
+ detections: bundle.metadata.detections ?? [],
3407
+ metadata: bundle.metadata,
3408
+ tooling: bundle.tooling
3409
+ },
3410
+ source: `profile "${bundle.profile.name}"`
3411
+ };
3412
+ }
3413
+ const root = path16.resolve(options.path ?? process.cwd());
3414
+ const scan = await scanProject(root);
3415
+ return {
3416
+ ctx: { detections: scan.detections, metadata: scan.metadata, tooling: scan.tooling },
3417
+ source: path16.basename(root) || "project"
3418
+ };
3419
+ }
3420
+ async function auditCommand(options) {
3421
+ const { ctx, source } = await buildContext(options);
3422
+ const result = runAudit(ctx);
3423
+ if (options.json) {
3424
+ logger.out(JSON.stringify(result, null, 2));
3425
+ return;
3426
+ }
3427
+ logger.out(pc.bold(`Project Score: ${result.score}/${result.maxScore}`));
3428
+ logger.out(pc.dim(`Audited ${source}`));
3429
+ logger.out("");
3430
+ for (const rule of result.rules) {
3431
+ logger.out(statusLine(rule.passed, rule.title));
3432
+ }
3433
+ if (result.missing.length === 0) {
3434
+ logger.out("");
3435
+ logger.out(pc.green("All checks passed."));
3436
+ return;
3437
+ }
3438
+ logger.out("");
3439
+ logger.out(pc.bold("Missing:"));
3440
+ for (const item of result.missing) logger.out(` - ${item}`);
3441
+ logger.out("");
3442
+ logger.out(pc.bold("Recommendations:"));
3443
+ for (const rec of result.recommendations) logger.out(` - ${rec}`);
2268
3444
  }
2269
3445
 
2270
3446
  // src/index.ts
@@ -2313,6 +3489,9 @@ program.command("inspect").description("Display captured configuration and struc
2313
3489
  program.command("validate").description("Check profile schema and integrity").option("--profile <path>", "Validate a profile at a custom path").action(action(validateCommand));
2314
3490
  program.command("export").description("Export the profile as a portable .tar.gz archive").option("--out <file>", "Output archive path").option("--profile <path>", "Export a profile from a custom path").action(action(exportCommand));
2315
3491
  program.command("import").argument("<archive>", "Path to a .tar.gz profile archive").description("Import a portable profile archive into .replicax/").option("--force", "Overwrite an existing profile").action(action(importCommand));
3492
+ program.command("doctor").description("Check which developer tools are installed locally").option("--json", "Output as JSON").action(action(doctorCommand));
3493
+ program.command("compare").argument("<source>", "A profile path or project directory").argument("<target>", "A profile path or project directory").description("Compare two profiles (or projects): tooling, config, structure, metadata").option("--json", "Output as JSON").action(action(compareCommand));
3494
+ program.command("audit").description("Score a project setup against best practices and recommend improvements").option("--path <dir>", "Directory to audit (default: current dir)").option("--profile <path>", "Audit a stored profile instead of scanning").option("--json", "Output as JSON").action(action(auditCommand));
2316
3495
  if (process.argv.slice(2).length === 0) {
2317
3496
  program.outputHelp();
2318
3497
  process.exit(0);