@sous-io/sous 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -1,5 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { ConfigError } from "./errors.js";
4
+ import {
5
+ listRecipeConfigLayers,
6
+ type RecipeConfigLayer,
7
+ } from "./repos/recipe-config-layers.js";
3
8
 
4
9
  /**
5
10
  * The directory name sous looks for when walking up from the working directory.
@@ -7,15 +12,33 @@ import path from "node:path";
7
12
  export const SOUS_DIR_NAME = ".sous";
8
13
 
9
14
  /**
10
- * Config file names searched inside a `.sous/` directory, in priority order.
11
- * The first one that exists wins.
15
+ * Primary config file names searched inside a `.sous/` directory. Exactly ONE of
16
+ * these may exist per `.sous/`; more than one is a hard error (no silent
17
+ * first-match-wins).
12
18
  */
13
19
  export const CONFIG_FILE_NAMES = [
14
20
  "sous.config.js",
15
21
  "sous.config.mjs",
16
22
  "sous.config.json",
23
+ "sous.config.jsonc",
24
+ "sous.config.yaml",
17
25
  ] as const;
18
26
 
27
+ /**
28
+ * The drop-in config layer directory inside `.sous/`. Every
29
+ * `conf.d/*.{js,mjs,json,jsonc,yaml}` file (non-recursive) is loaded after the
30
+ * primary config and deep-merged in bytewise filename order.
31
+ */
32
+ export const CONFD_DIR_NAME = "conf.d";
33
+
34
+ /**
35
+ * File extensions recognised as config layers inside `conf.d/`. A `.jsonc`
36
+ * layer is JSON with comments: line comments, block comments and trailing
37
+ * commas are all allowed in it, which is why the layers sous manages for a
38
+ * project are written that way.
39
+ */
40
+ export const LAYER_EXTENSIONS = [".js", ".mjs", ".json", ".jsonc", ".yaml"] as const;
41
+
19
42
  /**
20
43
  * The name of the optional shared-defaults env file inside `.sous/`. This file
21
44
  * is meant to be committed: it holds values a whole team shares, never secrets.
@@ -27,7 +50,7 @@ export const ENV_LOCAL_NAME = ".env.local";
27
50
 
28
51
  /** A located sous configuration. */
29
52
  export type DiscoveredConfig = {
30
- /** Absolute path to the config file itself. */
53
+ /** Absolute path to the primary config file itself. */
31
54
  configPath: string;
32
55
  /**
33
56
  * Absolute path to the `.sous/` directory holding the config, when the config
@@ -35,6 +58,30 @@ export type DiscoveredConfig = {
35
58
  * file's parent directory, whatever it is called.
36
59
  */
37
60
  sousDir: string;
61
+ /** Absolute path to the `conf.d/` drop-in directory (may not exist). */
62
+ confDir: string;
63
+ /**
64
+ * Ordered absolute paths of every config layer: the primary config first,
65
+ * then any config layers subscribed recipes contribute, then the `conf.d/`
66
+ * layers in bytewise filename order. Recipes sit in the middle so a recipe can
67
+ * supply defaults and the project always wins over them.
68
+ */
69
+ layerPaths: string[];
70
+ /** The subset of `layerPaths` that came from subscribed recipes. */
71
+ recipeLayerPaths: string[];
72
+ /**
73
+ * The recipe layers themselves, already read and already filtered down to the
74
+ * keys a recipe is allowed to set. Sous reads these instead of handing their
75
+ * paths to the config kernel, so a recipe cannot set a key that would change
76
+ * what sous trusts or what sous runs. See `repos/recipe-config-layers.ts`.
77
+ */
78
+ recipeLayers: RecipeConfigLayer[];
79
+ /**
80
+ * Complete, plain-language sentences about anything a recipe contributed that
81
+ * sous declined to load. Printed by the command, since discovery runs before
82
+ * there is anywhere good to print.
83
+ */
84
+ recipeLayerWarnings: string[];
38
85
  /** How the config was located. */
39
86
  source: "flag" | "walk-up";
40
87
  };
@@ -58,16 +105,152 @@ export function candidateDirs(startDir: string): string[] {
58
105
  }
59
106
 
60
107
  /**
61
- * Returns the first config file that exists inside `sousDir`, or null.
108
+ * Returns the primary config file inside `sousDir`, or null when none exists.
109
+ *
110
+ * @throws ConfigError when MORE THAN ONE primary candidate exists — sous never
111
+ * silently picks one of several `sous.config.*` files.
62
112
  */
63
113
  export function findConfigInSousDir(sousDir: string): string | null {
114
+ const found: string[] = [];
64
115
  for (const name of CONFIG_FILE_NAMES) {
65
116
  const candidate = path.join(sousDir, name);
66
117
  if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
67
- return candidate;
118
+ found.push(candidate);
68
119
  }
69
120
  }
70
- return null;
121
+
122
+ if (found.length > 1) {
123
+ throw new ConfigError(
124
+ `Multiple primary sous config files found in ${sousDir}:\n` +
125
+ found.map((f) => ` ${f}`).join("\n") +
126
+ `\n A .sous/ directory may hold exactly one of: ${CONFIG_FILE_NAMES.join(", ")}.\n` +
127
+ ` Keep one primary config and move the rest into ${CONFD_DIR_NAME}/ (with unique names) or delete them.`
128
+ );
129
+ }
130
+
131
+ return found[0] ?? null;
132
+ }
133
+
134
+ /**
135
+ * Compares two strings bytewise (plain `<` on the string), locale-independent
136
+ * so layer order is identical on every machine. Note this is NOT numeric:
137
+ * `10-` sorts before `2-` (zero-pad layer prefixes if that matters).
138
+ */
139
+ function bytewiseCompare(a: string, b: string): number {
140
+ return a < b ? -1 : a > b ? 1 : 0;
141
+ }
142
+
143
+ /**
144
+ * Lists the config layer files inside a `conf.d/` directory: every
145
+ * `*.{js,mjs,json,jsonc,yaml}` file directly inside it (non-recursive), sorted
146
+ * bytewise by filename. A missing directory yields an empty list.
147
+ */
148
+ export function listConfDirLayers(confDir: string): string[] {
149
+ if (!fs.existsSync(confDir) || !fs.statSync(confDir).isDirectory()) return [];
150
+
151
+ return fs
152
+ .readdirSync(confDir)
153
+ .filter((name) => {
154
+ const ext = path.extname(name).toLowerCase();
155
+ if (!(LAYER_EXTENSIONS as readonly string[]).includes(ext)) return false;
156
+ const full = path.join(confDir, name);
157
+ return fs.statSync(full).isFile();
158
+ })
159
+ .sort(bytewiseCompare)
160
+ .map((name) => path.join(confDir, name));
161
+ }
162
+
163
+ /**
164
+ * Asserts that every loaded config file (primary + conf.d layers) has a unique
165
+ * baseName; the filename minus its FINAL extension. Two layers named
166
+ * `500-repos.json` and `500-repos.jsonc` would otherwise merge in an order that
167
+ * depends on their extensions, which is never what the author meant. It is also
168
+ * what stops a managed layer from existing under both its old `.json` name and
169
+ * its `.jsonc` one.
170
+ *
171
+ * @throws ConfigError naming both conflicting files.
172
+ */
173
+ export function assertUniqueLayerBaseNames(layerPaths: string[]): void {
174
+ const seen = new Map<string, string>();
175
+ for (const layerPath of layerPaths) {
176
+ const base = path.basename(layerPath, path.extname(layerPath));
177
+ const existing = seen.get(base);
178
+ if (existing !== undefined) {
179
+ throw new ConfigError(
180
+ `Duplicate config layer baseName '${base}':\n` +
181
+ ` ${existing}\n` +
182
+ ` ${layerPath}\n` +
183
+ ` Every loaded config file (the primary config and all ${CONFD_DIR_NAME}/ layers) must have a\n` +
184
+ ` unique filename once its final extension is removed. Rename one of them.`
185
+ );
186
+ }
187
+ seen.set(base, layerPath);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Builds a full DiscoveredConfig from a located primary config: computes the
193
+ * conf.d directory, enumerates its layers and the layers subscribed recipes
194
+ * contribute, and runs the duplicate-baseName check.
195
+ *
196
+ * Recipe layers load after the primary config and before the `conf.d/` layers,
197
+ * so a recipe supplies defaults and the project always wins over them. They are
198
+ * left out of the duplicate-baseName check deliberately: that check exists so a
199
+ * person never has to guess which of two files they wrote merges last, and a
200
+ * recipe's file names are not theirs to rename.
201
+ *
202
+ * @param confDirOverride - Absolute path to use as the conf.d directory instead
203
+ * of `<sousDir>/conf.d`. Set from the `--sous-confd` flag or `SOUS_CONFD` env
204
+ * var; discovery then builds its layers from the overridden directory.
205
+ */
206
+ function buildDiscoveredConfig(
207
+ configPath: string,
208
+ sousDir: string,
209
+ source: DiscoveredConfig["source"],
210
+ confDirOverride?: string
211
+ ): DiscoveredConfig {
212
+ const confDir = confDirOverride ?? path.join(sousDir, CONFD_DIR_NAME);
213
+ const projectLayers = [configPath, ...listConfDirLayers(confDir)];
214
+ assertUniqueLayerBaseNames(projectLayers);
215
+
216
+ const recipes = listRecipeConfigLayers(sousDir);
217
+ const layerPaths = [
218
+ configPath,
219
+ ...recipes.paths,
220
+ ...projectLayers.slice(1),
221
+ ];
222
+
223
+ return {
224
+ configPath,
225
+ sousDir,
226
+ confDir,
227
+ layerPaths,
228
+ recipeLayerPaths: recipes.paths,
229
+ recipeLayers: recipes.layers,
230
+ recipeLayerWarnings: recipes.warnings,
231
+ source,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Re-runs the conf.d enumeration, the recipe layer enumeration and the
237
+ * duplicate-baseName check for an existing discovery.
238
+ *
239
+ * Two callers need it. Watch mode calls it because layer files can appear or
240
+ * disappear while watching. `BaseCommand.init()` calls it once after the
241
+ * `.sous/` env files are loaded, because `SOUS_HOME` is file-settable and it
242
+ * decides where the store holding the recipe layers is.
243
+ *
244
+ * The existing `confDir` is preserved (not recomputed from `sousDir`), so a
245
+ * `SOUS_CONFD` / `--sous-confd` override survives across watch reloads.
246
+ */
247
+ export function refreshDiscoveredConfig(discovered: DiscoveredConfig): DiscoveredConfig {
248
+ return buildDiscoveredConfig(
249
+ discovered.configPath,
250
+ discovered.sousDir,
251
+ discovered.source,
252
+ discovered.confDir
253
+ );
71
254
  }
72
255
 
73
256
  /**
@@ -76,15 +259,22 @@ export function findConfigInSousDir(sousDir: string): string | null {
76
259
  * without a config file does not stop the walk.
77
260
  *
78
261
  * @param startDir - Directory to start from (normally `process.cwd()`).
262
+ * @param confDirOverride - Absolute conf.d directory to use instead of
263
+ * `<sousDir>/conf.d` (from `--sous-confd` / `SOUS_CONFD`).
79
264
  * @returns The discovered config, or null when nothing was found.
265
+ * @throws ConfigError when a `.sous/` holds several primary configs, or when
266
+ * loaded layer baseNames collide.
80
267
  */
81
- export function discoverConfig(startDir: string = process.cwd()): DiscoveredConfig | null {
268
+ export function discoverConfig(
269
+ startDir: string = process.cwd(),
270
+ confDirOverride?: string
271
+ ): DiscoveredConfig | null {
82
272
  for (const dir of candidateDirs(startDir)) {
83
273
  const sousDir = path.join(dir, SOUS_DIR_NAME);
84
274
  if (!fs.existsSync(sousDir) || !fs.statSync(sousDir).isDirectory()) continue;
85
275
 
86
276
  const configPath = findConfigInSousDir(sousDir);
87
- if (configPath) return { configPath, sousDir, source: "walk-up" };
277
+ if (configPath) return buildDiscoveredConfig(configPath, sousDir, "walk-up", confDirOverride);
88
278
  }
89
279
 
90
280
  return null;
@@ -99,40 +289,47 @@ export function discoverConfig(startDir: string = process.cwd()): DiscoveredConf
99
289
  *
100
290
  * @param configFlag - The raw `--config` value (relative paths resolve against cwd).
101
291
  * @param cwd - Base directory for relative paths.
292
+ * @param confDirOverride - Absolute conf.d directory to use instead of
293
+ * `<sousDir>/conf.d` (from `--sous-confd` / `SOUS_CONFD`).
294
+ * @param sourceLabel - How the caller supplied the value (`--config`,
295
+ * `--sous-config`, `SOUS_CONFIG`, `--sous-dir`, `SOUS_DIR`). Used only in error
296
+ * messages so a user who set `SOUS_DIR` is not told to fix `--config`.
102
297
  * @returns The resolved config.
103
298
  * @throws When the path does not exist or holds no recognised config file.
104
299
  */
105
300
  export function resolveConfigFlag(
106
301
  configFlag: string,
107
- cwd: string = process.cwd()
302
+ cwd: string = process.cwd(),
303
+ confDirOverride?: string,
304
+ sourceLabel = "--config"
108
305
  ): DiscoveredConfig {
109
306
  const resolved = path.resolve(cwd, expandHome(configFlag));
110
307
 
111
308
  if (!fs.existsSync(resolved)) {
112
- throw new Error(`--config path not found: ${resolved}`);
309
+ throw new Error(`${sourceLabel} path not found: ${resolved}`);
113
310
  }
114
311
 
115
312
  if (fs.statSync(resolved).isDirectory()) {
116
313
  const direct = findConfigInSousDir(resolved);
117
314
  if (direct) {
118
- return { configPath: direct, sousDir: resolved, source: "flag" };
315
+ return buildDiscoveredConfig(direct, resolved, "flag", confDirOverride);
119
316
  }
120
317
 
121
318
  const nested = path.join(resolved, SOUS_DIR_NAME);
122
319
  if (fs.existsSync(nested) && fs.statSync(nested).isDirectory()) {
123
320
  const nestedConfig = findConfigInSousDir(nested);
124
321
  if (nestedConfig) {
125
- return { configPath: nestedConfig, sousDir: nested, source: "flag" };
322
+ return buildDiscoveredConfig(nestedConfig, nested, "flag", confDirOverride);
126
323
  }
127
324
  }
128
325
 
129
326
  throw new Error(
130
- `--config points at a directory with no sous config file: ${resolved}\n` +
327
+ `${sourceLabel} points at a directory with no sous config file: ${resolved}\n` +
131
328
  `Looked for: ${CONFIG_FILE_NAMES.join(", ")}`
132
329
  );
133
330
  }
134
331
 
135
- return { configPath: resolved, sousDir: path.dirname(resolved), source: "flag" };
332
+ return buildDiscoveredConfig(resolved, path.dirname(resolved), "flag", confDirOverride);
136
333
  }
137
334
 
138
335
  /**
@@ -175,24 +372,20 @@ export function formatNotFoundMessage(startDir: string = process.cwd()): string
175
372
  "",
176
373
  " To fix this, either:",
177
374
  ` 1. Create ${SOUS_DIR_NAME}/${CONFIG_FILE_NAMES[0]} in your project root, or`,
178
- " 2. Pass the config explicitly: xcv <command> --config <path>",
375
+ " 2. Pass the config explicitly: sous <command> --config <path>",
179
376
  "",
180
377
  ` A minimal ${CONFIG_FILE_NAMES[0]}:`,
181
378
  "",
182
379
  " export const config = {",
183
- ' defaultProject: "myproject",',
184
- " projects: {",
185
- " myproject: {",
186
- ' name: "My Project",',
187
- " compilation: {",
188
- " targets: [",
189
- " {",
190
- ' entryPoint: "${sousDir}/AGENTS.md",',
191
- ' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
192
- " },",
193
- " ],",
380
+ ' name: "My Project",',
381
+ ' _vars: { projectRoot: "${sousDir}/.." },',
382
+ " compilation: {",
383
+ " targets: [",
384
+ " {",
385
+ ' entryPoint: "${sousDir}/AGENTS.md",',
386
+ ' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
194
387
  " },",
195
- " },",
388
+ " ],",
196
389
  " },",
197
390
  " };",
198
391
  ].join("\n");
@@ -0,0 +1,145 @@
1
+ import { styleText } from "node:util";
2
+ import { ConfigError } from "./errors.js";
3
+
4
+ /**
5
+ * Shared helpers for the `sous config *` inspection commands: dot-path lookup
6
+ * with `[n]` array indexing, colorized pretty-JSON rendering, and value
7
+ * truncation for the `--layers` provenance view.
8
+ */
9
+
10
+ /** Sentinel returned by lookupPath when a path segment does not exist. */
11
+ export const NOT_FOUND = Symbol("sous.config.path.not-found");
12
+
13
+ /**
14
+ * Splits a dot-path with optional `[n]` array indices into ordered segments.
15
+ * `compilation.targets[0].entryPoint` → `["compilation", "targets", 0, "entryPoint"]`.
16
+ * Numeric-looking bracket segments become numbers; everything else is a string key.
17
+ *
18
+ * @throws ConfigError on a malformed path (empty, unbalanced brackets, etc.).
19
+ */
20
+ export function parsePath(path: string): (string | number)[] {
21
+ const trimmed = path.trim();
22
+ if (trimmed === "") {
23
+ throw new ConfigError("Empty config path. Provide a dot-path like 'compilation.targets[0].entryPoint'.");
24
+ }
25
+
26
+ const segments: (string | number)[] = [];
27
+ // Consume, in order: bare keys, `[n]` array indices, and `.` separators
28
+ // between them. Any character that fits none of these (e.g. `a..b`, `a[]`,
29
+ // `a[x]`) leaves a gap the scanner reports as malformed.
30
+ const tokenRe = /([^.[\]]+)|\[(\d+)\]|(\.)/g;
31
+ let lastIndex = 0;
32
+ let expectKey = false; // a `.` was just consumed, so a key must follow
33
+ let match: RegExpExecArray | null;
34
+
35
+ while ((match = tokenRe.exec(trimmed)) !== null) {
36
+ if (match.index !== lastIndex) {
37
+ throw new ConfigError(`Malformed config path near '${trimmed.slice(lastIndex)}' in '${path}'.`);
38
+ }
39
+ if (match[1] !== undefined) {
40
+ segments.push(match[1]);
41
+ expectKey = false;
42
+ } else if (match[2] !== undefined) {
43
+ if (expectKey) {
44
+ throw new ConfigError(`Malformed config path near '${trimmed.slice(match.index)}' in '${path}'.`);
45
+ }
46
+ segments.push(Number(match[2]));
47
+ } else {
48
+ // A `.` separator: a key must follow it, and one may not lead the path or
49
+ // directly follow another `.` (i.e. `.a`, `a..b` are malformed).
50
+ if (expectKey || segments.length === 0) {
51
+ throw new ConfigError(`Malformed config path near '${trimmed.slice(match.index)}' in '${path}'.`);
52
+ }
53
+ expectKey = true;
54
+ }
55
+ lastIndex = tokenRe.lastIndex;
56
+ }
57
+
58
+ if (lastIndex !== trimmed.length || expectKey) {
59
+ throw new ConfigError(`Malformed config path near '${trimmed.slice(lastIndex) || "end of path"}' in '${path}'.`);
60
+ }
61
+
62
+ return segments;
63
+ }
64
+
65
+ /**
66
+ * Walks `root` following `segments`, returning the value found or NOT_FOUND when
67
+ * any segment is missing (or the path descends into a non-indexable value).
68
+ */
69
+ export function lookupPath(root: unknown, segments: (string | number)[]): unknown {
70
+ let current: unknown = root;
71
+ for (const segment of segments) {
72
+ if (current === null || current === undefined || typeof current !== "object") {
73
+ return NOT_FOUND;
74
+ }
75
+ if (typeof segment === "number") {
76
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) {
77
+ return NOT_FOUND;
78
+ }
79
+ current = current[segment];
80
+ } else {
81
+ // Use hasOwnProperty, NOT the `in` operator: `in` walks the prototype
82
+ // chain, so inherited Object.prototype members (`constructor`, `toString`,
83
+ // `hasOwnProperty`, `__proto__`, …) would falsely "resolve" and print
84
+ // garbage instead of NOT_FOUND.
85
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) {
86
+ return NOT_FOUND;
87
+ }
88
+ current = (current as Record<string, unknown>)[segment];
89
+ }
90
+ }
91
+ return current;
92
+ }
93
+
94
+ /** True for JSON scalar values (string, finite/any number, boolean, null). */
95
+ export function isScalar(value: unknown): value is string | number | boolean | null {
96
+ return (
97
+ value === null ||
98
+ typeof value === "string" ||
99
+ typeof value === "number" ||
100
+ typeof value === "boolean"
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Colorizes an already-serialized pretty-JSON string using node:util styleText:
106
+ * object keys cyan, string values green, numbers yellow, booleans/null magenta.
107
+ * Operates purely on JSON token syntax, so it never mangles structure.
108
+ *
109
+ * Callers should only invoke this when writing to a TTY; piped output must stay
110
+ * plain so it parses as JSON.
111
+ */
112
+ export function colorizeJson(json: string): string {
113
+ const tokenRe = /"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
114
+ return json.replace(tokenRe, (match, offset: number, whole: string) => {
115
+ if (match[0] === '"') {
116
+ const rest = whole.slice(offset + match.length);
117
+ // A string immediately followed by a colon is an object key.
118
+ return /^\s*:/.test(rest)
119
+ ? styleText("cyan", match)
120
+ : styleText("green", match);
121
+ }
122
+ if (match === "true" || match === "false" || match === "null") {
123
+ return styleText("magenta", match);
124
+ }
125
+ return styleText("yellow", match);
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Renders a value as pretty JSON (2-space), colorized when `useColor` is true.
131
+ */
132
+ export function renderJson(value: unknown, useColor: boolean): string {
133
+ const json = JSON.stringify(value, null, 2);
134
+ return useColor ? colorizeJson(json) : json;
135
+ }
136
+
137
+ /**
138
+ * JSON-encodes a value on a single line and truncates it to `max` characters
139
+ * (with an ellipsis) for the compact `--layers` provenance lines.
140
+ */
141
+ export function truncateJson(value: unknown, max = 80): string {
142
+ const encoded = JSON.stringify(value);
143
+ if (encoded === undefined) return "undefined";
144
+ return encoded.length > max ? `${encoded.slice(0, max - 1)}…` : encoded;
145
+ }