@sous-io/sous 0.1.0 → 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 (206) hide show
  1. package/README.md +121 -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 +73 -9
  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/bin/xcv +0 -5
  166. package/shared-prompts/_partials/resume-task.md +0 -51
  167. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  168. package/shared-prompts/_partials/update-task-file.md +0 -52
  169. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  189. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  190. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  191. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  192. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  193. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  194. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  195. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  196. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  197. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  198. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  199. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  200. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  201. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  202. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  203. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  204. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  206. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -1,16 +1,58 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { color } from "@oclif/color";
3
3
 
4
- const DEFAULT_VAR_NAME_PADDING = 20;
5
-
6
4
  const HEADER_LINES = [
7
5
  " ▄█████ ▄████▄ ██ ██ ▄█████ ",
8
6
  " ▀▀▀▄▄▄ ██ ██ ██ ██ ▀▀▀▄▄▄ ",
9
7
  " █████▀ ▀████▀ ▀████▀ █████▀ ",
10
8
  ];
11
9
 
10
+ // --- The Color Palette ---------------------------------------------------------------------------
12
11
 
12
+ /**
13
+ * Every color sous writes comes from this one place, so a reader learns the
14
+ * vocabulary once: a label is cyan, the value beside it is bright white, an
15
+ * aside about that value is grey, a warning is bright yellow with its sharpest
16
+ * words in orange, an explanation is bright teal, and an error is bright red.
17
+ *
18
+ * The orange is given as a hex value on purpose. A terminal that can show it
19
+ * does; one limited to sixteen colors has chalk fold it down to bright yellow,
20
+ * which keeps a highlighted word inside the warning's own color rather than
21
+ * turning it into something that reads as a second kind of message.
22
+ */
23
+ export const palette = {
24
+ /** The name in a key and value pair. */
25
+ label: (text: string): string => color.cyan(text),
26
+ /** The value beside a label. */
27
+ value: (text: string): string => color.whiteBright(text),
28
+ /** A trailing aside: a location, a provenance, anything secondary. */
29
+ muted: (text: string): string => color.gray(text),
30
+ /** The body of a warning. */
31
+ warning: (text: string): string => color.yellowBright(text),
32
+ /** The words inside a warning that carry the actual risk. */
33
+ highlight: (text: string): string => color.hex("#ff8800")(text),
34
+ /** An explanatory line that is neither a warning nor an error. */
35
+ note: (text: string): string => color.cyanBright(text),
36
+ /** The body of an error. */
37
+ error: (text: string): string => color.redBright(text),
38
+ };
13
39
 
40
+ /**
41
+ * The prefix every error message carries, so an error is still findable by
42
+ * eye or by grep when the output has no color at all (a pipe, a log file,
43
+ * `CI=true`).
44
+ */
45
+ export const ERROR_PREFIX = "Error: ";
46
+
47
+ /**
48
+ * Adds the error prefix to a message that does not already begin with one, so
49
+ * a message that says "Error:" itself is never made to say it twice.
50
+ *
51
+ * @param text - The first line of an error message.
52
+ */
53
+ export function withErrorPrefix(text: string): string {
54
+ return /^error:\s/i.test(text.trimStart()) ? text : `${ERROR_PREFIX}${text}`;
55
+ }
14
56
 
15
57
 
16
58
  // --- Core Output Functions -----------------------------------------------------------------------
@@ -84,13 +126,27 @@ export function indent(text: string, count = 2, char = " "): string {
84
126
  // --- Header & Footer -----------------------------------------------------------------------------
85
127
 
86
128
  /**
87
- * Writes the CLI header to the console.
129
+ * Writes the CLI header using the given line writer.
130
+ *
131
+ * Factored out so commands whose stdout must stay machine-readable (the
132
+ * `sous config *` commands, which emit JSON) can route the decorative banner to
133
+ * stderr instead — see ConfigCommand.emitHeader.
134
+ *
135
+ * @param write - Receives one already-formatted line at a time (no trailing newline).
136
+ */
137
+ export function headerTo(write: (line: string) => void): void {
138
+ write(" ");
139
+ write(" ");
140
+ write(color.cyan(HEADER_LINES.join("\n")));
141
+ write(" Agent Configuration Manager ");
142
+ write(" ");
143
+ }
144
+
145
+ /**
146
+ * Writes the CLI header to stdout.
88
147
  */
89
148
  export function header(): void {
90
- blankLines();
91
- log(color.cyan(HEADER_LINES.join("\n")));
92
- log(" Agent Configuration Manager ");
93
- blankLine();
149
+ headerTo(log);
94
150
  }
95
151
 
96
152
  /**
@@ -116,6 +172,29 @@ export function heading(text: string, prefixSymbol = "▶"): void {
116
172
  log(color.yellowBright(final));
117
173
  }
118
174
 
175
+ /**
176
+ * Writes a section heading followed by a blank line, which is how a command
177
+ * opens a section of its output.
178
+ *
179
+ * `heading` on its own leaves the first line of content pressed right up under
180
+ * the heading, which reads as one crowded block. Every command that lays out
181
+ * sections of prose, tables or variable lists uses this instead, so the spacing
182
+ * is decided in one place rather than by a `blankLine()` call remembered at
183
+ * each site.
184
+ *
185
+ * @param text - The heading text; a colon is appended unless it ends in a period.
186
+ * @param prefixSymbol - The marker drawn before the text.
187
+ *
188
+ * @example
189
+ * section("Adding a repository");
190
+ * // ▶ Adding a repository:
191
+ * // (blank line)
192
+ */
193
+ export function section(text: string, prefixSymbol = "▶"): void {
194
+ heading(text, prefixSymbol);
195
+ blankLine();
196
+ }
197
+
119
198
  /**
120
199
  * Writes a subheading to the console.
121
200
  *
@@ -171,39 +250,167 @@ export function showCount(count: number, entity = "items", headingText = "", ver
171
250
  log(line);
172
251
  }
173
252
 
253
+ // --- The One Key and Value Display ---------------------------------------------------------------
254
+
255
+ /**
256
+ * How far a key and value block is indented. Every one of them sits at the same
257
+ * depth, whether it is a command's opening block, a result, or the facts about
258
+ * a variable.
259
+ */
260
+ export const VARIABLE_INDENT = 4;
261
+
262
+ /** One key and value pair, as the display functions take it. */
263
+ export interface VariableEntry {
264
+ /**
265
+ * The name, written on the left. An empty label means "the same label as the
266
+ * line above, continued": the label column and its colon are left blank, so a
267
+ * fact that needs several lines still lines up under itself.
268
+ */
269
+ label: string;
270
+ /** The value, written after the colon. Anything printable. */
271
+ value: unknown;
272
+ /**
273
+ * A secondary fact about the value: where it came from, where it lives. It
274
+ * follows the value in muted grey rather than in parentheses, so the value
275
+ * itself stays the thing the eye lands on.
276
+ */
277
+ detail?: string;
278
+ }
279
+
280
+ /** How a key and value block is laid out. */
281
+ export interface ShowVariablesOptions {
282
+ /** How far the block is indented. Four by default. */
283
+ indent?: number;
284
+ /**
285
+ * The column the colons line up at, counted from the start of the label.
286
+ * Worked out from the longest label when it is not given, which is what
287
+ * `showVariables` does for a whole block.
288
+ */
289
+ labelWidth?: number;
290
+ /** The column to wrap values at. Defaults to the wrap width. */
291
+ width?: number;
292
+ /** Where the lines go. The console by default. */
293
+ write?: (line: string) => void;
294
+ }
295
+
296
+ /**
297
+ * Lays one key and value pair out: the label in the label color, padded so
298
+ * every colon in the block lines up, then the value in the value color, then
299
+ * any detail in muted grey. A value too long for the line wraps and hangs under
300
+ * the value column rather than under the label.
301
+ *
302
+ * @param entry - The label, the value and any trailing detail.
303
+ * @param options - Indentation, label column and wrap column.
304
+ * @returns The rendered lines.
305
+ */
306
+ export function formatVariable(
307
+ entry: VariableEntry,
308
+ options: ShowVariablesOptions = {},
309
+ ): string[] {
310
+ const pad = " ".repeat(Math.max(0, options.indent ?? VARIABLE_INDENT));
311
+ const labelWidth = Math.max(options.labelWidth ?? entry.label.length, entry.label.length);
312
+ const gutter = labelWidth + 2; // the padded label, then ": "
313
+ const width = options.width ?? wrapColumns();
314
+ const valueWidth = Math.max(20, width - pad.length - gutter);
315
+
316
+ const valueText = entry.value === undefined || entry.value === null ? "" : String(entry.value);
317
+ const valueLines = wrapText(valueText, valueWidth, { hangingIndent: 0 });
318
+ const detailLines =
319
+ entry.detail === undefined || entry.detail === ""
320
+ ? []
321
+ : wrapText(entry.detail, valueWidth, { hangingIndent: 0 });
322
+
323
+ // The detail joins the value's last line when both fit, and starts a line of
324
+ // its own when they do not, so a long location is never cut in half.
325
+ const cells: string[] = valueLines.map(line => palette.value(line));
326
+ if (detailLines.length > 0) {
327
+ const last = valueLines[valueLines.length - 1] ?? "";
328
+ const [first, ...rest] = detailLines;
329
+ if (last !== "" && displayWidth(last) + 1 + displayWidth(first ?? "") <= valueWidth) {
330
+ cells[cells.length - 1] = `${palette.value(last)} ${palette.muted(first ?? "")}`;
331
+ } else {
332
+ cells.push(palette.muted(first ?? ""));
333
+ }
334
+ for (const line of rest) cells.push(palette.muted(line));
335
+ }
336
+
337
+ if (cells.length === 0) cells.push("");
338
+
339
+ return cells.map((cell, index) =>
340
+ index === 0 && entry.label !== ""
341
+ ? `${pad}${palette.label(entry.label.padEnd(labelWidth))}: ${cell}`
342
+ : `${pad}${" ".repeat(gutter)}${cell}`,
343
+ );
344
+ }
345
+
174
346
  /**
175
- * Displays a variable name and its value to the console.
347
+ * Writes one key and value pair to the console.
348
+ *
349
+ * @param label - The name, written on the left.
350
+ * @param value - The value, written after the colon.
351
+ * @param options - Indentation, label column, wrap column and where to write.
176
352
  *
177
353
  * @example
178
- * showVar("Config", "./my-config.js");
179
- * // Config : ./my-config.js
354
+ * showVariable("Config", "./my-config.js");
355
+ * // Config: ./my-config.js
180
356
  */
181
- export function showVar(name: string, value: any, padding = DEFAULT_VAR_NAME_PADDING): void {
182
- const str = `${color.cyan(name.padEnd(padding))}: ${value}`;
183
- log(indent(str));
357
+ export function showVariable(
358
+ label: string,
359
+ value: unknown,
360
+ options: ShowVariablesOptions & { detail?: string } = {},
361
+ ): void {
362
+ const write = options.write ?? log;
363
+ const entry: VariableEntry = {
364
+ label,
365
+ value,
366
+ ...(options.detail === undefined ? {} : { detail: options.detail }),
367
+ };
368
+ for (const line of formatVariable(entry, options)) write(line);
184
369
  }
185
370
 
186
371
  /**
187
- * Displays a list of variables to the console, with aligned colons.
372
+ * Writes a list of key and value pairs to the console, with the colons lined
373
+ * up. Every key and value display in sous goes through this function or through
374
+ * `showVariable`, so there is exactly one of them to change.
375
+ *
376
+ * @param entries - The pairs, either as a plain object or as a list carrying details.
377
+ * @param options - Indentation, wrap column and where to write.
188
378
  *
189
379
  * @example
190
- * showVars({ Config: "./my-config.js", Strict: "false" });
191
- * // Config : ./my-config.js
192
- * // Strict : false
193
- */
194
- export function showVars(vars: Record<string, any>): void {
195
- const padding = findLongestKeyLength(vars) + 1;
196
- for (const [name, value] of Object.entries(vars)) {
197
- showVar(name, value, padding);
198
- }
380
+ * showVariables({ Config: "./my-config.js", Strict: "false" });
381
+ * // Config: ./my-config.js
382
+ * // Strict: false
383
+ */
384
+ export function showVariables(
385
+ entries: Record<string, unknown> | VariableEntry[],
386
+ options: ShowVariablesOptions = {},
387
+ ): void {
388
+ const list: VariableEntry[] = Array.isArray(entries)
389
+ ? entries
390
+ : Object.entries(entries).map(([label, value]) => ({ label, value }));
391
+ if (list.length === 0) return;
392
+
393
+ const labelWidth =
394
+ options.labelWidth ?? Math.max(...list.map(entry => entry.label.length));
395
+ for (const entry of list) showVariable(entry.label, entry.value, {
396
+ ...options,
397
+ labelWidth,
398
+ ...(entry.detail === undefined ? {} : { detail: entry.detail }),
399
+ });
199
400
  }
200
401
 
201
402
  /**
202
403
  * Displays a heading labelled "Command Variables" followed by a variable list.
404
+ *
405
+ * The "Dry Run" entry is a special case: it is noise when dry-run mode is off, so it
406
+ * is only shown when its value is `true`. Every other entry is always shown.
203
407
  */
204
408
  export function showCommandVars(vars: Record<string, any>): void {
409
+ const filtered = Object.fromEntries(
410
+ Object.entries(vars).filter(([name, value]) => name !== "Dry Run" || value === true),
411
+ );
205
412
  subheading("Command Variables", "$");
206
- showVars(vars);
413
+ showVariables(filtered);
207
414
  }
208
415
 
209
416
  /**
@@ -236,17 +443,23 @@ export function deleteStatus(recordsDeleted: number, totalRecordsToDelete: numbe
236
443
 
237
444
  /**
238
445
  * Writes an error message to the console in red.
446
+ *
447
+ * @param text - The message to display.
448
+ * @param write - Line sink (default stdout via `log`). Commands whose stdout must
449
+ * stay machine-readable (the `sous config *` JSON commands) pass a stderr writer
450
+ * so error text never corrupts a piped stdout stream.
239
451
  */
240
- export function displayError(text: string): void {
241
- const lines = text.split("\n");
242
- log("");
243
- for (const line of lines) {
244
- if (line.trim() !== "") {
245
- log(indent(color.redBright(line.trim())));
452
+ export function displayError(text: string, write: (line: string) => void = log): void {
453
+ const lines = text.split("\n").filter(line => line.trim() !== "");
454
+ write("");
455
+ lines.forEach((line, index) => {
456
+ const body = index === 0 ? withErrorPrefix(line.trim()) : line.trim();
457
+ for (const wrapped of wrapText(body, wrapColumns() - 2)) {
458
+ write(indent(palette.error(wrapped)));
246
459
  }
247
- }
248
- log("");
249
- log("");
460
+ });
461
+ write("");
462
+ write("");
250
463
  }
251
464
 
252
465
  /**
@@ -257,43 +470,74 @@ export function displayError(text: string): void {
257
470
  * structure (a checked-paths list, a code sample, numbered steps). `displayError`
258
471
  * trims every line and drops blanks, which flattens that structure.
259
472
  *
473
+ * @param text - The pre-formatted, multi-line message to display.
474
+ * @param write - Line sink (default stdout via `log`). Commands whose stdout must
475
+ * stay machine-readable (the `sous config *` JSON commands) pass a stderr writer
476
+ * so error text never corrupts a piped stdout stream.
477
+ *
260
478
  * @example
261
479
  * displayErrorBlock("No config found.\n\n Checked:\n /a/.sous/");
262
480
  */
263
- export function displayErrorBlock(text: string): void {
264
- log("");
265
- for (const line of text.split("\n")) {
266
- log(line === "" ? " " : indent(color.redBright(line)));
267
- }
268
- log("");
269
- log("");
481
+ export function displayErrorBlock(text: string, write: (line: string) => void = log): void {
482
+ const lines = text.split("\n");
483
+ // The prefix goes on the first line that has any text on it, which is the
484
+ // line a reader (or a grep) looks at.
485
+ const first = lines.findIndex(line => line.trim() !== "");
486
+
487
+ write("");
488
+ lines.forEach((line, index) => {
489
+ if (line === "") {
490
+ write(" ");
491
+ return;
492
+ }
493
+ const body = index === first ? withErrorPrefix(line) : line;
494
+ for (const wrapped of wrapText(body, wrapColumns() - 2)) {
495
+ write(indent(palette.error(wrapped)));
496
+ }
497
+ });
498
+ write("");
499
+ write("");
270
500
  }
271
501
 
272
502
  /**
273
- * Writes a notice indicating that the operation is running in dry-run mode.
503
+ * Writes a notice indicating that the operation is running in dry-run mode. It
504
+ * is an explanation of what is about to not happen, so it carries the note
505
+ * color rather than the warning one.
274
506
  *
275
507
  * @example
276
508
  * dryRunNotice("File will not be written.");
277
509
  * // [Dry Run] File will not be written.
278
510
  */
279
511
  export function dryRunNotice(text: string): void {
280
- log(" " + color.cyan("[Dry Run] " + text));
512
+ note(`[Dry Run] ${text}`, { indent: 4 });
281
513
  }
282
514
 
283
515
  /**
284
- * Writes a warning message to the console with a yellow banner.
516
+ * Writes a warning message to the console with a yellow banner. The body is
517
+ * bright yellow; words written in capitals inside it are taken as the ones
518
+ * carrying the risk and are drawn in orange, so a reader skimming the block
519
+ * still takes in the part that matters.
520
+ *
521
+ * @param text - The message to display.
522
+ * @param write - Line sink (default stdout via `log`). Commands whose stdout must
523
+ * stay machine-readable (the `sous config *` JSON commands) pass a stderr writer
524
+ * so warning text never corrupts a piped stdout stream.
285
525
  */
286
- export function warning(text: string): void {
287
- blankLines();
288
- log(color.bgYellowBright(color.black(" WARNING: ")));
526
+ export function warning(text: string, write: (line: string) => void = log): void {
527
+ write("");
528
+ write("");
529
+ write(color.bgYellowBright(color.black(" WARNING: ")));
289
530
 
290
- const lines = text.split("\n");
291
- for (const line of lines) {
292
- if (line.trim() !== "") {
293
- log(highlightUpperCaseWords(indent(line.trim())));
531
+ for (const line of text.split("\n")) {
532
+ if (line.trim() === "") {
533
+ write(" ");
534
+ continue;
535
+ }
536
+ for (const wrapped of wrapText(line.trim(), wrapColumns() - 2)) {
537
+ write(indent(palette.warning(highlightUpperCaseWords(wrapped))));
294
538
  }
295
539
  }
296
- blankLine();
540
+ write("");
297
541
  }
298
542
 
299
543
  // --- Miscellaneous Helpers -----------------------------------------------------------------------
@@ -303,7 +547,7 @@ export function warning(text: string): void {
303
547
  */
304
548
  function highlightUpperCaseWords(
305
549
  str: string,
306
- highlightFn: (word: string) => string = color.yellowBright,
550
+ highlightFn: (word: string) => string = palette.highlight,
307
551
  ): string {
308
552
  return str.replace(/\b[A-Z][A-Z_'"()\[\]{}<>|&*!@#%^\\-]+\b/g, match => highlightFn(match));
309
553
  }
@@ -330,9 +574,250 @@ export function sortObjectKeys<T extends Record<string, any>>(obj: T): T {
330
574
  }
331
575
  }
332
576
 
577
+ // --- ANSI-Safe Measurement -----------------------------------------------------------------------
578
+
579
+ /**
580
+ * Matches the SGR (Select Graphic Rendition) escape sequences the coloring
581
+ * helpers emit, which is every escape sequence sous writes into a string.
582
+ */
583
+ const SGR_PATTERN = /\u001B\[[0-9;]*m/g;
584
+
585
+ /**
586
+ * Removes the color escape sequences from a string, leaving the characters a
587
+ * reader actually sees.
588
+ *
589
+ * @param text - The text to strip.
590
+ * @returns The same text, without any color escape sequence.
591
+ */
592
+ export function stripAnsi(text: string): string {
593
+ return String(text ?? "").replace(SGR_PATTERN, "");
594
+ }
595
+
596
+ /**
597
+ * How many terminal columns a string occupies once its color codes are taken
598
+ * out. Every alignment decision measures with this, because a colored cell is
599
+ * longer than it looks.
600
+ *
601
+ * East Asian wide characters and emoji are out of scope: this counts one column
602
+ * per code unit, so a cell holding them aligns a little short. Everything sous
603
+ * lays out in a table is an identifier, a path, a URL or English prose, so the
604
+ * simple measure holds; widening it later means changing this one function.
605
+ *
606
+ * @param text - The text to measure.
607
+ * @returns The visible width, in columns.
608
+ */
609
+ export function displayWidth(text: string): number {
610
+ return stripAnsi(text).length;
611
+ }
612
+
613
+ // --- Width-Aware Wrapping ------------------------------------------------------------------------
614
+
615
+ /**
616
+ * The width sous wraps prose to when the output is not a terminal, or when the
617
+ * terminal never said how wide it is.
618
+ */
619
+ export const DEFAULT_WRAP_COLUMNS = 100;
620
+
621
+ /**
622
+ * How many columns the output has to work with: the real terminal width when
623
+ * there is a terminal, and the default width otherwise, so a piped or recorded
624
+ * run always wraps the same way.
625
+ *
626
+ * @param stream - The stream to measure. Defaults to stdout.
627
+ */
628
+ export function terminalColumns(stream: { columns?: number } = process.stdout): number {
629
+ const columns = stream.columns;
630
+ return typeof columns === "number" && columns > 0 ? columns : DEFAULT_WRAP_COLUMNS;
631
+ }
632
+
633
+ /**
634
+ * How far short of the right edge text stops, so a wrapped paragraph never
635
+ * touches the last column of the terminal.
636
+ */
637
+ export const RIGHT_MARGIN = 2;
638
+
639
+ /**
640
+ * The width every paragraph sous prints is wrapped to: the columns available,
641
+ * less the right margin.
642
+ *
643
+ * @param stream - The stream to measure. Defaults to stdout.
644
+ */
645
+ export function wrapColumns(stream: { columns?: number } = process.stdout): number {
646
+ return Math.max(20, terminalColumns(stream) - RIGHT_MARGIN);
647
+ }
648
+
649
+ /** How a paragraph is wrapped. */
650
+ export interface WrapOptions {
651
+ /**
652
+ * How far a continuation line hangs past the first line of its paragraph.
653
+ * Two by default, which is what makes a wrapped sentence read as one
654
+ * paragraph rather than as several. Pass zero where the caller lays out its
655
+ * own hanging indent, as the labeled blocks do.
656
+ */
657
+ hangingIndent?: number;
658
+ }
659
+
660
+ /**
661
+ * Wraps a paragraph to a width, breaking on spaces and never inside a word. A
662
+ * word longer than the width (a URL, a path) is left whole on a line of its
663
+ * own, because half a URL is worse than a long line.
664
+ *
665
+ * Newlines already in the text are honored: each line is wrapped on its own,
666
+ * and each keeps whatever indentation it was written with, so an already laid
667
+ * out block survives being passed through. Continuation lines hang two spaces
668
+ * past the line they continue, unless the caller asks for something else.
669
+ *
670
+ * Width is measured with the color codes taken out, so a colored word is
671
+ * counted by what a reader sees rather than by what the escape sequences add.
672
+ *
673
+ * @param text - The prose to wrap.
674
+ * @param width - The column to wrap at. Defaults to the terminal width, less the right margin.
675
+ * @param options - How far continuation lines hang.
676
+ * @returns One string per rendered line, without trailing spaces.
677
+ *
678
+ * @example
679
+ * wrapText("one two three", 7, { hangingIndent: 0 });
680
+ * // -> ["one two", "three"]
681
+ */
682
+ export function wrapText(
683
+ text: string,
684
+ width: number = wrapColumns(),
685
+ options: WrapOptions = {},
686
+ ): string[] {
687
+ const limit = Math.max(1, Math.floor(width));
688
+ const hanging = Math.max(0, Math.floor(options.hangingIndent ?? 2));
689
+ const lines: string[] = [];
690
+
691
+ for (const paragraph of String(text ?? "").split("\n")) {
692
+ // The line's own indentation is kept, and continuation lines are measured
693
+ // and drawn from inside it, so a block that arrives already laid out is
694
+ // still laid out when it leaves.
695
+ const leading = /^[ \t]*/.exec(paragraph)?.[0] ?? "";
696
+ const body = paragraph.slice(leading.length);
697
+ const words = body.split(/\s+/).filter(word => word.length > 0);
698
+ if (words.length === 0) {
699
+ lines.push("");
700
+ continue;
701
+ }
702
+
703
+ const continuation = leading + " ".repeat(hanging);
704
+ let prefix = leading;
705
+ let current = "";
706
+
707
+ for (const word of words) {
708
+ if (current.length === 0) {
709
+ current = word;
710
+ continue;
711
+ }
712
+ const projected = displayWidth(prefix) + displayWidth(current) + 1 + displayWidth(word);
713
+ if (projected <= limit) {
714
+ current = `${current} ${word}`;
715
+ continue;
716
+ }
717
+ lines.push(`${prefix}${current}`);
718
+ prefix = continuation;
719
+ current = word;
720
+ }
721
+
722
+ lines.push(`${prefix}${current}`);
723
+ }
724
+
725
+ return lines;
726
+ }
727
+
728
+ /** How a paragraph is laid out for printing. */
729
+ export interface ParagraphOptions extends WrapOptions {
730
+ /** How far the whole paragraph is indented. Two by default. */
731
+ indent?: number;
732
+ /** The column to wrap at, indentation included. Defaults to the wrap width. */
733
+ width?: number;
734
+ /** A color to paint every line with. */
735
+ color?: (text: string) => string;
736
+ }
737
+
738
+ /**
739
+ * Lays a paragraph out for printing: wrapped to the width left after its own
740
+ * indentation, indented, and colored.
741
+ *
742
+ * @param text - The prose to lay out.
743
+ * @param options - Indentation, wrap column and color.
744
+ * @returns The rendered lines.
745
+ */
746
+ export function formatParagraph(text: string, options: ParagraphOptions = {}): string[] {
747
+ const pad = Math.max(0, options.indent ?? 2);
748
+ const width = options.width ?? wrapColumns();
749
+ const wrapOptions: WrapOptions =
750
+ options.hangingIndent === undefined ? {} : { hangingIndent: options.hangingIndent };
751
+ const paint = options.color ?? ((line: string) => line);
752
+
753
+ return wrapText(text, Math.max(20, width - pad), wrapOptions).map(line =>
754
+ line === "" ? " " : " ".repeat(pad) + paint(line),
755
+ );
756
+ }
757
+
758
+ /**
759
+ * Writes a wrapped, indented paragraph to the console. Every long sentence the
760
+ * CLI prints goes through this, so nothing is ever left to the terminal's own
761
+ * idea of where a line should break.
762
+ *
763
+ * @param text - The prose to print.
764
+ * @param options - Indentation, wrap column and color.
765
+ */
766
+ export function paragraph(text: string, options: ParagraphOptions = {}): void {
767
+ for (const line of formatParagraph(text, options)) log(line);
768
+ }
769
+
333
770
  /**
334
- * Finds the length of the longest key in an object.
771
+ * Writes an explanatory line: something that is neither a warning nor an error,
772
+ * but a note about what just happened or what is about to. It is bright teal,
773
+ * which is the one color reserved for explanation.
774
+ *
775
+ * @param text - The note to print.
776
+ * @param options - Indentation and wrap column.
777
+ */
778
+ export function note(text: string, options: Omit<ParagraphOptions, "color"> = {}): void {
779
+ paragraph(text, { ...options, color: palette.note });
780
+ }
781
+
782
+ // --- Key Bindings --------------------------------------------------------------------------------
783
+
784
+ /**
785
+ * The one-line legend a prompt draws under itself, in the style the stock
786
+ * `@inquirer/select` prompt uses: the key in bold, what it does dimmed beside
787
+ * it, pairs separated by a dimmed bullet.
788
+ *
789
+ * @param keys - Key and action pairs, in the order they are shown.
790
+ * @returns The legend line.
791
+ *
792
+ * @example
793
+ * keysHelpTip([["↑↓", "navigate"], ["⏎", "select"]]);
794
+ * // -> "↑↓ navigate • ⏎ select"
795
+ */
796
+ export function keysHelpTip(keys: Array<[key: string, action: string]>): string {
797
+ return keys
798
+ .map(([key, action]) => `${color.bold(key)} ${color.dim(action)}`)
799
+ .join(color.dim(" • "));
800
+ }
801
+
802
+ /**
803
+ * The character every bulleted line in the CLI is drawn with, so a list of
804
+ * points always looks like a list of points.
805
+ */
806
+ export const BULLET = "•";
807
+
808
+ /** How many blank lines a question keeps under itself while it is waiting. */
809
+ export const PROMPT_BOTTOM_PADDING = 2;
810
+
811
+ /**
812
+ * The block a prompt draws under its input line: whatever it has to say
813
+ * (a validation message, the key legend), then two blank lines, so a question
814
+ * never sits on the terminal's very last row with its legend scrolled away.
815
+ *
816
+ * @param content - The line under the prompt, or an empty string for none.
817
+ * @returns The bottom block, ready to hand back from a prompt's renderer.
335
818
  */
336
- function findLongestKeyLength(vars: Record<string, any>): number {
337
- return Math.max(...Object.keys(vars).map(key => key.length));
819
+ export function promptBottom(content: string): string {
820
+ const lines = content === "" ? [] : [content];
821
+ for (let index = 0; index < PROMPT_BOTTOM_PADDING; index++) lines.push(" ");
822
+ return lines.join("\n");
338
823
  }