@effected/cli-kit 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CliColor.js CHANGED
@@ -41,6 +41,10 @@ var CliColor = class CliColor {
41
41
  * result to a `const` or the decision is re-read every time it is
42
42
  * provided.
43
43
  *
44
+ * `helpFlags` sets how subcommand help renders the flags a parent declared (`inheritedFlags`) and the
45
+ * program-wide built-ins (`globalFlags`): `"full"` (core's default), `"summary"` or `"none"`. It is passed to
46
+ * core's `defaultFormatter` beside the colour decision, so choosing a mode costs no colour.
47
+ *
44
48
  * The `never` in its output does not mean it installs nothing.
45
49
  * `CliOutput.Formatter` is a `Context.Reference`, whose key type is
46
50
  * `never`, so this layer sets the formatter reference rather than
@@ -50,8 +54,11 @@ var CliColor = class CliColor {
50
54
  *
51
55
  * @public
52
56
  */
53
- static formatterLayer = (overrides = {}) => Layer.unwrap(Effect.map(CliColor.enabled, (colors) => CliOutput.layer({
54
- ...CliOutput.defaultFormatter({ colors }),
57
+ static formatterLayer = (overrides = {}, helpFlags) => Layer.unwrap(Effect.map(CliColor.enabled, (colors) => CliOutput.layer({
58
+ ...CliOutput.defaultFormatter({
59
+ colors,
60
+ ...helpFlags
61
+ }),
55
62
  ...overrides
56
63
  })));
57
64
  };
package/CliExit.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { Fmt } from "./Fmt.js";
1
2
  import { isExitCode } from "./internal/isExitCode.js";
2
- import { Context, Effect, Layer, MutableRef } from "effect";
3
+ import { Cause, Console, Context, Effect, Layer, MutableRef, Predicate } from "effect";
3
4
 
4
5
  // src/CliExit.ts
5
6
  /**
@@ -59,6 +60,58 @@ var CliExit = class CliExit extends Context.Service()("@effected/cli-kit/CliExit
59
60
  const exit = yield* CliExit;
60
61
  if (code > MutableRef.get(exit.code)) MutableRef.set(exit.code, code);
61
62
  });
63
+ /**
64
+ * Write one line to stderr, record an exit code, and succeed: the shape of a
65
+ * hook-style command that must say why it exits non-zero without failing.
66
+ *
67
+ * @remarks
68
+ * This does **not** stop the program. It succeeds, so the caller must still
69
+ * return (or otherwise end its own flow) after calling it; finalizers and log
70
+ * sinks run as they would on any successful run, and `CliRuntime.main` turns
71
+ * the recorded code into the exit.
72
+ *
73
+ * The code is validated first, exactly as {@link CliExit.set} validates it, and
74
+ * an invalid code dies as the same defect before anything is written. The
75
+ * line goes through `Console.error`, never stdout, and is written as given:
76
+ * sanitize anything you do not control with `Fmt.sanitize` first. The
77
+ * highest code recorded during the run wins.
78
+ */
79
+ static report = (code, line) => Effect.gen(function* () {
80
+ yield* CliExit.set(code);
81
+ yield* Console.error(line);
82
+ });
83
+ /**
84
+ * Report a failure cause as one stderr line, record the exit code the caller
85
+ * chooses for it, and succeed.
86
+ *
87
+ * @remarks
88
+ * The cause is squashed and the line is `<code> <tag>: <message>`, or
89
+ * `<code> <message>` when the error has no string `_tag`. The message comes
90
+ * from `Fmt.errorText` and is sanitized with `Fmt.sanitize` and its line breaks
91
+ * become a space, so a hostile error can neither throw, write an escape nor
92
+ * forge a second line. Reading `_tag` is guarded
93
+ * too: a getter that throws means no tag.
94
+ *
95
+ * An interrupt-only cause is not a failure to report: nothing is written or
96
+ * recorded and the effect re-interrupts. `codeOf` receives the squashed error
97
+ * and must return an integer in `0..255`; anything else dies as
98
+ * {@link CliExit.set} does, which is wiring.
99
+ *
100
+ * Like {@link CliExit.report} it does not stop the program: the caller still
101
+ * has to return.
102
+ */
103
+ static reportCause = (cause, codeOf) => Cause.hasInterruptsOnly(cause) ? Effect.interrupt : Effect.suspend(() => {
104
+ const error = Cause.squash(cause);
105
+ const code = codeOf(error);
106
+ let tag;
107
+ try {
108
+ const read = Predicate.hasProperty(error, "_tag") ? error._tag : void 0;
109
+ tag = Predicate.isString(read) ? read : void 0;
110
+ } catch {}
111
+ const oneLine = (text) => Fmt.sanitize(text).replace(/\r?\n|\r/gu, " ");
112
+ const message = oneLine(Fmt.errorText(error));
113
+ return CliExit.report(code, tag === void 0 ? `${code} ${message}` : `${code} ${oneLine(tag)}: ${message}`);
114
+ });
62
115
  };
63
116
 
64
117
  export { CliExit };
package/CliRuntime.js CHANGED
@@ -220,7 +220,7 @@ var CliRuntime = class CliRuntime {
220
220
  const env = options.env === void 0 ? void 0 : CliEnv.layer(options.env);
221
221
  const envLog = options.env?.log;
222
222
  const logger = options.logger ?? (env === void 0 || envLog === void 0 ? CliLogger.layer() : CliLog.layer(envLog).pipe(Layer.provide(env), Layer.provide(Layer.provideMerge(options.platform.pipe(Layer.provide(platformLogLayer(envLog, options.env?.audienceEnvVar))), envBuildLogLayer(envLog, options.env?.audienceEnvVar))), Layer.catchCause(() => CliLogger.layer(envLog.logger))));
223
- const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter), Layer.effectDiscard(Effect.gen(function* () {
223
+ const inside = env === void 0 ? Layer.empty : Layer.mergeAll(CliColor.formatterLayer(options.env?.formatter, options.env?.helpFlags), Layer.effectDiscard(Effect.gen(function* () {
224
224
  const { spans, invalid } = yield* readSpans(options.env?.spans, options.env?.spansEnvVar);
225
225
  if (invalid !== void 0) yield* Effect.logWarning(invalid).pipe(Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([makeCliLogger(envLog?.logger)])));
226
226
  yield* refreshFailureTarget(void 0, {
@@ -231,7 +231,7 @@ var CliRuntime = class CliRuntime {
231
231
  }))).pipe(Layer.provideMerge(env));
232
232
  const carried = options.distribution === void 0 ? program : withCarrier(program, options.distribution);
233
233
  const run = Effect.gen(function* () {
234
- yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(carried) : carried;
234
+ yield* options.helpOnUsageError === "stderr" ? routeHelpOnUsageError(carried, options.env?.formatter, options.env?.helpFlags) : carried;
235
235
  const exit = yield* CliExit;
236
236
  const code = MutableRef.get(exit.code);
237
237
  if (!isExitCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`CliRuntime.main: CliExit code must be an integer 0..255, received ${code}`));
package/CliTest.js CHANGED
@@ -1,8 +1,35 @@
1
1
  import { Fmt } from "./Fmt.js";
2
- import { Duration, Effect, Fiber, FileSystem, Option, Path, Stream, SubscriptionRef } from "effect";
2
+ import { Duration, Effect, Fiber, FileSystem, Option, Path, Schema, Stream, SubscriptionRef } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
 
5
5
  // src/CliTest.ts
6
+ /**
7
+ * A run ended otherwise than {@link RunOptions.expectExit} required.
8
+ *
9
+ * @remarks
10
+ * Audience: the person reading a failed test. The message holds what the child did and its stderr, so a suite that
11
+ * only cares about the happy path needs no hand-rolled helper to see why it did not get it.
12
+ *
13
+ * @public
14
+ */
15
+ var CliTestFailure = class extends Schema.TaggedError()("CliTestFailure", {
16
+ expected: Schema.Number,
17
+ exitCode: Schema.NullOr(Schema.Number),
18
+ signal: Schema.NullOr(Schema.String),
19
+ stderr: Schema.String
20
+ }) {
21
+ /**
22
+ * What was expected, what happened, and the child's stderr.
23
+ *
24
+ * @remarks
25
+ * Assigning to it is ignored: a runner or logger that rewrites `error.message` must not make this error throw,
26
+ * which a getter-only property does in strict mode.
27
+ */
28
+ get message() {
29
+ return `expected exit ${this.expected}, but the bin ${this.signal === null ? `exited with code ${this.exitCode}` : `was ended by ${this.signal}`}\nstderr:\n${this.stderr}`;
30
+ }
31
+ set message(_value) {}
32
+ };
6
33
  /** The complete lines of `captured` from `offset`, and the rest too once it has ended; with the offset after them. */
7
34
  const linesFrom = (offset, captured) => {
8
35
  const lines = [];
@@ -108,7 +135,7 @@ var CliTest = class {
108
135
  };
109
136
  });
110
137
  /**
111
- * Run `execPath bin ...args`; a non-zero exit is returned, never failed.
138
+ * Run `execPath bin ...args`; a non-zero exit is returned, never failed, unless `expectExit` is set.
112
139
  *
113
140
  * @remarks
114
141
  * `stdin` is never left as an inherited open pipe: when omitted or `""`
@@ -126,11 +153,18 @@ var CliTest = class {
126
153
  text(handle.stderr),
127
154
  endingOf(handle.exitCode)
128
155
  ], { concurrency: "unbounded" });
129
- return {
156
+ const result = {
130
157
  ...ending,
131
158
  stdout,
132
159
  stderr
133
160
  };
161
+ if (options.expectExit !== void 0 && result.exitCode !== options.expectExit) return yield* new CliTestFailure({
162
+ expected: options.expectExit,
163
+ exitCode: result.exitCode,
164
+ signal: result.signal,
165
+ stderr
166
+ });
167
+ return result;
134
168
  }));
135
169
  };
136
170
  /**
@@ -203,4 +237,4 @@ var CliTest = class {
203
237
  };
204
238
  };
205
239
 
206
- export { CliTest };
240
+ export { CliTest, CliTestFailure };
@@ -13,10 +13,9 @@ import { formatIssueWithHints } from "./internal/format.js";
13
13
  * value is the diagnostic: printing only the message tells a user their config
14
14
  * is invalid without saying which value is wrong or how it is shaped.
15
15
  *
16
- * `@effected/config-file` is an optional peer, and this module only
17
- * `import type`s it, so the import is erased at build time. A consumer without
18
- * the package installed can import this module without the resolver being asked
19
- * for it.
16
+ * The parameter is {@link ConfigIssueSource}, a structural type, so this module
17
+ * names nothing from `@effected/config-file` and the package is not a peer. A
18
+ * real `ConfigValidationError` is assignable to it.
20
19
  *
21
20
  * @example
22
21
  * ```ts
@@ -45,7 +44,7 @@ var ConfigIssueRenderer = class {
45
44
  * hands you and because `issue` is typed `Schema.Defect`, which every call
46
45
  * site would otherwise have to reach into. Inside
47
46
  * `Effect.catchTag("ConfigValidationError", …)` the error is already this
48
- * type.
47
+ * shape.
49
48
  *
50
49
  * An unknown key the schema has a near match for at the same path ends in a
51
50
  * hint, `unknown key at theme.colour (did you mean "color"?)`, drawn from the
package/Fmt.js CHANGED
@@ -1,40 +1,9 @@
1
1
  import { displayWidth, graphemes, isEscapeOnly, stripAnsi } from "./internal/displayWidth.js";
2
2
  import { Duration, Option } from "effect";
3
3
  import { ErrorText } from "@effected/kit";
4
+ import { EditDistance } from "@effected/source";
4
5
 
5
6
  // src/Fmt.ts
6
- /**
7
- * The Levenshtein distance between `a` and `b` when it is at most `limit`, and `limit + 1` otherwise.
8
- *
9
- * Only the diagonal band of the matrix within `limit` of it is computed (a cell outside it is past `limit` already),
10
- * over two rows reused in turn, and a row whose every cell is past `limit` ends the walk, since no later row can come
11
- * back under it: the cost is the length of `a` times `2 * limit + 1`.
12
- */
13
- const boundedDistance = (a, b, limit) => {
14
- const over = limit + 1;
15
- if (Math.abs(a.length - b.length) > limit) return over;
16
- const n = b.length;
17
- const at = (row, j) => row[j] ?? over;
18
- let previous = Array.from({ length: n + 1 }, (_, j) => j <= limit ? j : over);
19
- let current = new Array(n + 1).fill(over);
20
- for (let i = 1; i <= a.length; i++) {
21
- const lo = Math.max(1, i - limit);
22
- const hi = Math.min(n, i + limit);
23
- const edge = lo === 1 && i <= limit ? i : over;
24
- current[lo - 1] = edge;
25
- let rowMin = edge;
26
- for (let j = lo; j <= hi; j++) {
27
- const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
28
- const cell = Math.min(at(previous, j) + 1, at(current, j - 1) + 1, at(previous, j - 1) + cost, over);
29
- current[j] = cell;
30
- if (cell < rowMin) rowMin = cell;
31
- }
32
- if (hi < n) current[hi + 1] = over;
33
- if (rowMin > limit) return over;
34
- [previous, current] = [current, previous];
35
- }
36
- return Math.min(at(previous, n), over);
37
- };
38
7
  const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
39
8
  /**
40
9
  * Text made safe to lay out and print: complete ANSI and OSC sequences removed, every remaining control character
@@ -319,16 +288,7 @@ var Fmt = class {
319
288
  static suggest = (input, candidates, options) => {
320
289
  const max = options?.maxDistance ?? 2;
321
290
  if (!Number.isInteger(max) || max < 0) throw new RangeError(`Fmt.suggest: maxDistance must be a non-negative integer, received ${max}`);
322
- let best = max + 1;
323
- let found = [];
324
- for (const candidate of new Set(candidates)) {
325
- const distance = boundedDistance(input, candidate, Math.min(max, best));
326
- if (distance < best) {
327
- best = distance;
328
- found = [candidate];
329
- } else if (distance === best) found.push(candidate);
330
- }
331
- return best <= max ? found : [];
291
+ return EditDistance.suggest(input, candidates, { maxDistance: max });
332
292
  };
333
293
  };
334
294
 
package/README.md CHANGED
@@ -47,8 +47,6 @@ Optional peers:
47
47
  npm install ink react @types/react
48
48
  ```
49
49
 
50
- - **`@effected/config-file`**, only for `ConfigIssueRenderer`. It is imported as a type, so nothing at runtime reaches for it.
51
-
52
50
  All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED`. Import from an ES module.
53
51
 
54
52
  ## Quick start
package/TestTerminal.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Effect, Layer, Option, Queue, Terminal } from "effect";
2
2
 
3
3
  // src/TestTerminal.ts
4
+ const FORBIDDEN = "TestTerminal: this path must not read the terminal (forbidInput is set)";
4
5
  /**
5
6
  * A scripted `Terminal` for testing prompts and anything that reads the terminal.
6
7
  *
@@ -19,7 +20,12 @@ var TestTerminal = class {
19
20
  /**
20
21
  * Build a test terminal.
21
22
  *
22
- * @param options - the reported size; 80 by 24 by default
23
+ * @remarks
24
+ * With `forbidInput`, `readInput` and `readLine` die with a message saying this path must not read the terminal,
25
+ * where the default double hands back an empty queue (a stray read hangs) and quits `readLine`. `size`, `columns`,
26
+ * `rows`, `display` and `resize` behave as ever, so a "this path never prompts" platform keeps a real terminal size.
27
+ *
28
+ * @param options - the reported size, 80 by 24 by default; `forbidInput` to die on any input read
23
29
  */
24
30
  static make = (options) => Effect.gen(function* () {
25
31
  const queue = yield* Queue.unbounded();
@@ -47,11 +53,11 @@ var TestTerminal = class {
47
53
  sizeSubscribers.delete(subscriber);
48
54
  Queue.endUnsafe(subscriber);
49
55
  })),
50
- readInput: Effect.sync(() => {
56
+ readInput: options?.forbidInput === true ? Effect.die(/* @__PURE__ */ new Error(FORBIDDEN)) : Effect.sync(() => {
51
57
  subscriptions++;
52
58
  return queue;
53
59
  }),
54
- readLine: Effect.suspend(() => {
60
+ readLine: options?.forbidInput === true ? Effect.die(/* @__PURE__ */ new Error(FORBIDDEN)) : Effect.suspend(() => {
55
61
  lines++;
56
62
  return Effect.fail(new Terminal.QuitError({}));
57
63
  }),
package/Token.js CHANGED
@@ -34,7 +34,9 @@ var Token = class {
34
34
  * @remarks
35
35
  * An explicit style resolves to itself. A token name resolves to its override when `overrides` has one, else
36
36
  * its default; a name that is not a token (including an `Object.prototype` member) resolves to the empty
37
- * style. This is the same resolution `CliTheme.paint` applies, which is what `StreamTheme.style` reports.
37
+ * style. It is theme-blind: it knows only the default palette and the `overrides` you pass, never a mounted
38
+ * theme, so a component under `CliUi` that wants the screen's theme uses `useStyle`. This is the same resolution
39
+ * `CliTheme.paint` applies, which is what `StreamTheme.style` reports.
38
40
  *
39
41
  * @param token - a token name or an explicit style
40
42
  * @param overrides - styles that replace the default of a token, as `CliThemeOptions.tokens` does
package/index.d.ts CHANGED
@@ -2,7 +2,6 @@ import { Array as Array$1, Cause, Context, Duration, Effect, FileSystem, Layer,
2
2
  import { Audience, AudienceKind, ColorLevel, CurrentRuntimeEnv, RuntimeEnv, TerminalEnv } from "@effected/env";
3
3
  import { CliError, CliOutput, Command, Flag, Param, Prompt } from "effect/cli";
4
4
  import { Distribution } from "@effected/kit";
5
- import { ConfigValidationError } from "@effected/config-file";
6
5
  declare const Cancelled_base: Schema.Class<Cancelled, Schema.TaggedStruct<"Cancelled", {
7
6
  readonly reason: Schema.Literals<readonly ["escape", "interrupt"]>;
8
7
  }>, import("effect/Cause").YieldableError>;
@@ -183,6 +182,20 @@ export declare class CliAudience {
183
182
  readonly renderErrors?: boolean | undefined;
184
183
  }) => Effect.Effect<void, Exclude<E | CliError.UserError, Terminal.QuitError> | CliError.CliError, Exclude<R, Audience> | Audience | Command.Environment>;
185
184
  }
185
+ /**
186
+ * How subcommand help renders flags the command does not own, as core's `CliOutput.defaultFormatter` takes them.
187
+ *
188
+ * @remarks
189
+ * `inheritedFlags` covers flags a parent command shares with its subcommands (for example `CliAudience.flags()`);
190
+ * `globalFlags` covers the built-ins such as `--help` and `--version`. Each is `"full"` (core's default, every flag
191
+ * listed), `"summary"` (a one-line pointer) or `"none"`.
192
+ *
193
+ * @public
194
+ */
195
+ interface CliHelpFlags {
196
+ readonly inheritedFlags?: "full" | "summary" | "none" | undefined;
197
+ readonly globalFlags?: "full" | "summary" | "none" | undefined;
198
+ }
186
199
  /**
187
200
  * Whether a CLI's output should carry ANSI colour, decided once and shared by
188
201
  * everything that renders — help text, error output, and any rendered result.
@@ -221,6 +234,10 @@ export declare class CliColor {
221
234
  * result to a `const` or the decision is re-read every time it is
222
235
  * provided.
223
236
  *
237
+ * `helpFlags` sets how subcommand help renders the flags a parent declared (`inheritedFlags`) and the
238
+ * program-wide built-ins (`globalFlags`): `"full"` (core's default), `"summary"` or `"none"`. It is passed to
239
+ * core's `defaultFormatter` beside the colour decision, so choosing a mode costs no colour.
240
+ *
224
241
  * The `never` in its output does not mean it installs nothing.
225
242
  * `CliOutput.Formatter` is a `Context.Reference`, whose key type is
226
243
  * `never`, so this layer sets the formatter reference rather than
@@ -230,7 +247,7 @@ export declare class CliColor {
230
247
  *
231
248
  * @public
232
249
  */
233
- static readonly formatterLayer: (overrides?: Partial<CliOutput.Formatter>) => Layer.Layer<never, never, Stdio.Stdio>;
250
+ static readonly formatterLayer: (overrides?: Partial<CliOutput.Formatter>, helpFlags?: CliHelpFlags) => Layer.Layer<never, never, Stdio.Stdio>;
234
251
  }
235
252
  /**
236
253
  * The symbols a theme draws with.
@@ -368,7 +385,9 @@ export declare class Token {
368
385
  * @remarks
369
386
  * An explicit style resolves to itself. A token name resolves to its override when `overrides` has one, else
370
387
  * its default; a name that is not a token (including an `Object.prototype` member) resolves to the empty
371
- * style. This is the same resolution `CliTheme.paint` applies, which is what `StreamTheme.style` reports.
388
+ * style. It is theme-blind: it knows only the default palette and the `overrides` you pass, never a mounted
389
+ * theme, so a component under `CliUi` that wants the screen's theme uses `useStyle`. This is the same resolution
390
+ * `CliTheme.paint` applies, which is what `StreamTheme.style` reports.
372
391
  *
373
392
  * @param token - a token name or an explicit style
374
393
  * @param overrides - styles that replace the default of a token, as `CliThemeOptions.tokens` does
@@ -2048,6 +2067,17 @@ interface CliEnvOptions {
2048
2067
  * sets; this is the way to keep a method of your own. Methods you omit keep the coloured defaults.
2049
2068
  */
2050
2069
  readonly formatter?: Partial<CliOutput.Formatter> | undefined;
2070
+ /**
2071
+ * How subcommand help renders flags it does not own, passed to core's `CliOutput.defaultFormatter` beside the
2072
+ * colour decision. Only `CliRuntime.main` reads this.
2073
+ *
2074
+ * @remarks
2075
+ * `inheritedFlags` covers flags a parent command shares with its subcommands (for example `CliAudience.flags()`);
2076
+ * `globalFlags` covers the built-ins such as `--help` and `--version`. Each is `"full"` (core's default, every flag
2077
+ * listed), `"summary"` (a one-line pointer) or `"none"`. Setting a mode here keeps the kit's colour decision, which
2078
+ * supplying your own `formatHelpDoc` through {@link CliEnvOptions.formatter} would lose.
2079
+ */
2080
+ readonly helpFlags?: CliHelpFlags | undefined;
2051
2081
  /**
2052
2082
  * How an absolute path is shown: a function, or `{ relativeTo: root }` to show a path under `root` relative to it.
2053
2083
  * The identity by default.
@@ -2259,6 +2289,44 @@ export declare class CliExit extends CliExit_base {
2259
2289
  * cell, and the failure's own exit code (or the `exitCode` fallback) wins.
2260
2290
  */
2261
2291
  static readonly set: (code: number) => Effect.Effect<void, never, CliExit>;
2292
+ /**
2293
+ * Write one line to stderr, record an exit code, and succeed: the shape of a
2294
+ * hook-style command that must say why it exits non-zero without failing.
2295
+ *
2296
+ * @remarks
2297
+ * This does **not** stop the program. It succeeds, so the caller must still
2298
+ * return (or otherwise end its own flow) after calling it; finalizers and log
2299
+ * sinks run as they would on any successful run, and `CliRuntime.main` turns
2300
+ * the recorded code into the exit.
2301
+ *
2302
+ * The code is validated first, exactly as {@link CliExit.set} validates it, and
2303
+ * an invalid code dies as the same defect before anything is written. The
2304
+ * line goes through `Console.error`, never stdout, and is written as given:
2305
+ * sanitize anything you do not control with `Fmt.sanitize` first. The
2306
+ * highest code recorded during the run wins.
2307
+ */
2308
+ static readonly report: (code: number, line: string) => Effect.Effect<void, never, CliExit>;
2309
+ /**
2310
+ * Report a failure cause as one stderr line, record the exit code the caller
2311
+ * chooses for it, and succeed.
2312
+ *
2313
+ * @remarks
2314
+ * The cause is squashed and the line is `<code> <tag>: <message>`, or
2315
+ * `<code> <message>` when the error has no string `_tag`. The message comes
2316
+ * from `Fmt.errorText` and is sanitized with `Fmt.sanitize` and its line breaks
2317
+ * become a space, so a hostile error can neither throw, write an escape nor
2318
+ * forge a second line. Reading `_tag` is guarded
2319
+ * too: a getter that throws means no tag.
2320
+ *
2321
+ * An interrupt-only cause is not a failure to report: nothing is written or
2322
+ * recorded and the effect re-interrupts. `codeOf` receives the squashed error
2323
+ * and must return an integer in `0..255`; anything else dies as
2324
+ * {@link CliExit.set} does, which is wiring.
2325
+ *
2326
+ * Like {@link CliExit.report} it does not stop the program: the caller still
2327
+ * has to return.
2328
+ */
2329
+ static readonly reportCause: <E>(cause: Cause.Cause<E>, codeOf: (error: unknown) => number) => Effect.Effect<void, never, CliExit>;
2262
2330
  }
2263
2331
  /**
2264
2332
  * The protocol an error class implements to say how a failure is shown: a method under this key that returns the
@@ -2942,6 +3010,11 @@ interface MainOptions<RP, EP> extends ReportFailuresOptions {
2942
3010
  * `formatVersion`); a Formatter the platform provides is shadowed by
2943
3011
  * `main`'s, so pass it through `env.formatter` as well.
2944
3012
  *
3013
+ * The installed Formatter paints by stdout's terminal check. When `env` is set and stderr's colour decision
3014
+ * differs from stdout's, a usage error's help is rendered with `formatHelpDoc`, `formatError` and `formatErrors` rebuilt from
3015
+ * core's default formatter under stderr's decision (and `env.helpFlags`), so those methods follow stderr
3016
+ * unless given through `env.formatter`, which always wins. Every other method is left as installed.
3017
+ *
2945
3018
  * Two cases keep help on stdout even under `"stderr"`. A `CliOutput`
2946
3019
  * Presenter provided inside `program` replaces the routing. And with
2947
3020
  * `Command.runWith`'s `renderErrors: false` core presents the help as
@@ -3179,6 +3252,18 @@ export declare class CliRuntime {
3179
3252
  static reported<E extends Error>(error: E, exitCode?: number): E;
3180
3253
  static reported(error: unknown, exitCode?: number): Error;
3181
3254
  }
3255
+ /**
3256
+ * What {@link ConfigIssueRenderer.render} reads: anything that may carry an `issue` tree.
3257
+ *
3258
+ * @remarks
3259
+ * A `@effected/config-file` `ConfigValidationError` is assignable to it, as is any
3260
+ * other value with an optional `issue` property.
3261
+ *
3262
+ * @public
3263
+ */
3264
+ interface ConfigIssueSource {
3265
+ readonly issue?: unknown;
3266
+ }
3182
3267
  /**
3183
3268
  * Turns a `@effected/config-file` `ConfigValidationError` into one line per rejected value.
3184
3269
  *
@@ -3191,10 +3276,9 @@ export declare class CliRuntime {
3191
3276
  * value is the diagnostic: printing only the message tells a user their config
3192
3277
  * is invalid without saying which value is wrong or how it is shaped.
3193
3278
  *
3194
- * `@effected/config-file` is an optional peer, and this module only
3195
- * `import type`s it, so the import is erased at build time. A consumer without
3196
- * the package installed can import this module without the resolver being asked
3197
- * for it.
3279
+ * The parameter is {@link ConfigIssueSource}, a structural type, so this module
3280
+ * names nothing from `@effected/config-file` and the package is not a peer. A
3281
+ * real `ConfigValidationError` is assignable to it.
3198
3282
  *
3199
3283
  * @example
3200
3284
  * ```ts
@@ -3223,7 +3307,7 @@ export declare class ConfigIssueRenderer {
3223
3307
  * hands you and because `issue` is typed `Schema.Defect`, which every call
3224
3308
  * site would otherwise have to reach into. Inside
3225
3309
  * `Effect.catchTag("ConfigValidationError", …)` the error is already this
3226
- * type.
3310
+ * shape.
3227
3311
  *
3228
3312
  * An unknown key the schema has a near match for at the same path ends in a
3229
3313
  * hint, `unknown key at theme.colour (did you mean "color"?)`, drawn from the
@@ -3235,7 +3319,7 @@ export declare class ConfigIssueRenderer {
3235
3319
  * a guard before it is read, so a renderer on an error path never becomes the
3236
3320
  * reason a program dies.
3237
3321
  */
3238
- static readonly render: (error: ConfigValidationError) => ReadonlyArray<string>;
3322
+ static readonly render: (error: ConfigIssueSource) => ReadonlyArray<string>;
3239
3323
  }
3240
3324
  /**
3241
3325
  * Options for {@link Fmt.truncate}.
@@ -3947,5 +4031,5 @@ export declare class SchemaIssueRenderer {
3947
4031
  */
3948
4032
  static readonly render: (issue: unknown) => ReadonlyArray<string>;
3949
4033
  }
3950
- export type { AnnotationLevel, AnnotationOptions, AudienceFlagInput, Block, BlockOf, CliAudienceFlagsOptions, CliDocSource, CliEnvOptions, CliEnvServices, CliEnvTestOptions, CliEnvTestServices, CliExitShape, CliFailureOptions, CliFormatFlagOptions, CliFormatName, CliFormatOutput, CliFormatPrintOptions, CliLinksLinkerOptions, CliLinksOptions, CliLinksShape, CliLogFile, CliLogFileOptions, CliLogOptions, CliLogStatusOptions, CliLoggerOptions, CliMessageOptions, CliPromptFallbackOptions, CliPromptTarget, CliThemeOptions, CliThemeShape, CliThemeTestOptions, Column, CoreStatusName, Counter, CountsOptions, CountsRow, CountsTableOptions, DocPrintOptions, Document, EditorLinks, FailureDetails, GithubAnnotationProperties, GlyphSelectOptions, GlyphSet, Inline, InlineInput, InlineOf, LinkOptions, LinkTarget, ListOptions, MainOptions, NamedColor, OverflowOptions, PadOptions, PercentOptions, RenderContext, RenderContextOfOptions, RenderContextOptions, ReportFailuresOptions, RequiresAudienceFlags, StatusDef, StatusRef, StreamTheme, Style, SuggestOptions, TableOptions, TokenName, TreeInput, TreeNode, TruncateOptions };
4034
+ export type { AnnotationLevel, AnnotationOptions, AudienceFlagInput, Block, BlockOf, CliAudienceFlagsOptions, CliDocSource, CliEnvOptions, CliEnvServices, CliEnvTestOptions, CliEnvTestServices, CliExitShape, CliFailureOptions, CliFormatFlagOptions, CliFormatName, CliFormatOutput, CliFormatPrintOptions, CliHelpFlags, CliLinksLinkerOptions, CliLinksOptions, CliLinksShape, CliLogFile, CliLogFileOptions, CliLogOptions, CliLogStatusOptions, CliLoggerOptions, CliMessageOptions, CliPromptFallbackOptions, CliPromptTarget, CliThemeOptions, CliThemeShape, CliThemeTestOptions, Column, ConfigIssueSource, CoreStatusName, Counter, CountsOptions, CountsRow, CountsTableOptions, DocPrintOptions, Document, EditorLinks, FailureDetails, GithubAnnotationProperties, GlyphSelectOptions, GlyphSet, Inline, InlineInput, InlineOf, LinkOptions, LinkTarget, ListOptions, MainOptions, NamedColor, OverflowOptions, PadOptions, PercentOptions, RenderContext, RenderContextOfOptions, RenderContextOptions, ReportFailuresOptions, RequiresAudienceFlags, StatusDef, StatusRef, StreamTheme, Style, SuggestOptions, TableOptions, TokenName, TreeInput, TreeNode, TruncateOptions };
3951
4035
  //# sourceMappingURL=index.d.ts.map
@@ -1,8 +1,29 @@
1
- import { Console, Effect } from "effect";
1
+ import { Console, Effect, Option } from "effect";
2
+ import { TerminalEnv } from "@effected/env";
2
3
  import { CliOutput } from "effect/cli";
3
4
 
4
5
  // src/internal/HelpRouting.ts
5
6
  /**
7
+ * The installed formatter with its colour-bearing methods (`formatHelpDoc`, `formatError`, `formatErrors`) rebuilt
8
+ * from core's default under `colors`, then `overrides` applied so they still win. Every other method of `current`,
9
+ * `formatVersion` included, is kept.
10
+ *
11
+ * @internal
12
+ */
13
+ const stderrFormatter = (current, colors, overrides, helpFlags) => {
14
+ const { formatHelpDoc, formatError, formatErrors } = CliOutput.defaultFormatter({
15
+ colors,
16
+ ...helpFlags
17
+ });
18
+ return {
19
+ ...current,
20
+ formatHelpDoc,
21
+ formatError,
22
+ formatErrors,
23
+ ...overrides
24
+ };
25
+ };
26
+ /**
6
27
  * Core's presenter, with an invalid invocation's help written to stderr beside
7
28
  * its errors instead of stdout.
8
29
  *
@@ -14,11 +35,18 @@ import { CliOutput } from "effect/cli";
14
35
  * moves. An explicit `--help`, a bare group invocation and `--version` are
15
36
  * other events, presented by core unchanged on stdout.
16
37
  */
17
- const usageErrorsToStderr = { present: (event) => event._tag !== "InvalidInvocation" ? CliOutput.defaultPresenter.present(event) : Effect.gen(function* () {
38
+ const usageErrorsToStderr = (overrides, helpFlags) => ({ present: (event) => event._tag !== "InvalidInvocation" ? CliOutput.defaultPresenter.present(event) : Effect.gen(function* () {
18
39
  const sink = yield* Console.Console;
19
40
  const toStderr = Object.assign(Object.create(sink), { log: (...args) => sink.error(...args) });
20
- yield* CliOutput.defaultPresenter.present(event).pipe(Effect.provideService(Console.Console, toStderr));
21
- }) };
41
+ const terminal = yield* Effect.serviceOption(TerminalEnv);
42
+ const current = yield* CliOutput.Formatter;
43
+ const colors = Option.map(terminal, (t) => ({
44
+ stdout: t.stdout.color !== "none",
45
+ stderr: t.stderr.color !== "none"
46
+ }));
47
+ const formatter = Option.isNone(colors) || colors.value.stderr === colors.value.stdout ? current : stderrFormatter(current, colors.value.stderr, overrides, helpFlags);
48
+ yield* CliOutput.defaultPresenter.present(event).pipe(Effect.provideService(Console.Console, toStderr), Effect.provideService(CliOutput.Formatter, formatter));
49
+ }) });
22
50
  /**
23
51
  * Run `program` so a help document printed together with parse errors goes to
24
52
  * stderr, beside the errors, instead of stdout.
@@ -30,6 +58,6 @@ const usageErrorsToStderr = { present: (event) => event._tag !== "InvalidInvocat
30
58
  *
31
59
  * @internal
32
60
  */
33
- const routeHelpOnUsageError = (program) => Effect.provideService(program, CliOutput.Presenter, usageErrorsToStderr);
61
+ const routeHelpOnUsageError = (program, overrides, helpFlags) => Effect.provideService(program, CliOutput.Presenter, usageErrorsToStderr(overrides, helpFlags));
34
62
 
35
- export { routeHelpOnUsageError };
63
+ export { routeHelpOnUsageError, stderrFormatter };
@@ -25,9 +25,7 @@ const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue)
25
25
  *
26
26
  * @remarks
27
27
  * Shared by `SchemaIssueRenderer` and `ConfigIssueRenderer` and imported by
28
- * nothing else. It lives here rather than in either module so that
29
- * `ConfigIssueRenderer` — the only export that references the optional
30
- * `@effected/config-file` peer — can stay a module no other module imports.
28
+ * nothing else.
31
29
  *
32
30
  * **No walker of our own.** `makeFormatterStandardSchemaV1` already flattens the
33
31
  * tree to `{ message, path }` entries, and `defaultLeafHook` covers every leaf
@@ -47,7 +45,7 @@ const formatIssue = (issue) => issueEntries(issue).map((entry) => entry.path.len
47
45
  * @remarks
48
46
  * Used by `ConfigIssueRenderer` only. The declared keys come from the issue tree itself: core's `UnexpectedKey` leaf
49
47
  * carries the AST of the struct being decoded, whose property signatures are the keys the schema accepts at that
50
- * path, so no schema is passed in and no `@effected/config-file` value is needed. The hint is `Fmt.suggest`'s list,
48
+ * path, so no schema is passed in. The hint is `Fmt.suggest`'s list,
51
49
  * each key sanitised, and a line with no near match is exactly the line {@link formatIssue} draws.
52
50
  *
53
51
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/cli-kit",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "private": false,
5
5
  "description": "The presentation boundary of an effect/cli program: audience-aware output, a document IR and renderers, editor links, failure reports and logging, plus opt-in Ink screens, widgets and a live view",
6
6
  "keywords": [
@@ -59,22 +59,19 @@
59
59
  "dependencies": {
60
60
  "@effected/commands": "^0.20.0",
61
61
  "@effected/github-commands": "^0.9.0",
62
- "@effected/kit": "^0.12.0"
62
+ "@effected/kit": "^0.13.0",
63
+ "@effected/source": "^0.4.0"
63
64
  },
64
65
  "peerDependencies": {
65
- "@effected/config-file": "^0.24.0",
66
- "@effected/env": "^0.10.0",
67
- "@effected/glob": "^0.18.0",
68
- "@effected/walker": "^0.25.0",
66
+ "@effected/env": "^0.11.0",
67
+ "@effected/glob": "^0.19.0",
68
+ "@effected/walker": "^0.25.1",
69
69
  "@types/react": "^19.3.0",
70
70
  "effect": "~4.1.0",
71
71
  "ink": "^8.0.0",
72
72
  "react": "^19.3.0"
73
73
  },
74
74
  "peerDependenciesMeta": {
75
- "@effected/config-file": {
76
- "optional": true
77
- },
78
75
  "@types/react": {
79
76
  "optional": true
80
77
  },
package/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Duration, Effect, FileSystem, Layer, Path, PlatformError, Scope, Stream, Terminal } from "effect";
1
+ import { Duration, Effect, FileSystem, Layer, Path, PlatformError, Schema, Scope, Stream, Terminal } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  /**
4
4
  * A hermetic temp directory minted by {@link CliTest.sandbox}, removed when its
@@ -45,6 +45,37 @@ interface RunOptions {
45
45
  * child that ignores the signal would otherwise keep the scope from closing forever, and leave the process behind.
46
46
  */
47
47
  readonly forceKillAfter?: Duration.Input | undefined;
48
+ /**
49
+ * The exit code the run must end with. Any other ending, a signal included, fails with a {@link CliTestFailure}
50
+ * carrying the code, the signal and the child's stderr in its message. Omitted, a non-zero exit is data.
51
+ */
52
+ readonly expectExit?: number | undefined;
53
+ }
54
+ declare const CliTestFailure_base: Schema.Class<CliTestFailure, Schema.TaggedStruct<"CliTestFailure", {
55
+ readonly expected: Schema.Number;
56
+ readonly exitCode: Schema.NullOr<Schema.Number>;
57
+ readonly signal: Schema.NullOr<Schema.String>;
58
+ readonly stderr: Schema.String;
59
+ }>, import("effect/Cause").YieldableError>;
60
+ /**
61
+ * A run ended otherwise than {@link RunOptions.expectExit} required.
62
+ *
63
+ * @remarks
64
+ * Audience: the person reading a failed test. The message holds what the child did and its stderr, so a suite that
65
+ * only cares about the happy path needs no hand-rolled helper to see why it did not get it.
66
+ *
67
+ * @public
68
+ */
69
+ export declare class CliTestFailure extends CliTestFailure_base {
70
+ /**
71
+ * What was expected, what happened, and the child's stderr.
72
+ *
73
+ * @remarks
74
+ * Assigning to it is ignored: a runner or logger that rewrites `error.message` must not make this error throw,
75
+ * which a getter-only property does in strict mode.
76
+ */
77
+ get message(): string;
78
+ set message(_value: string);
48
79
  }
49
80
  /**
50
81
  * What a spawned bin did, as data: a non-zero exit is a result, not a failure.
@@ -132,7 +163,7 @@ export declare class CliTest {
132
163
  readonly path: string;
133
164
  }) => Effect.Effect<Sandbox, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Scope.Scope>;
134
165
  /**
135
- * Run `execPath bin ...args`; a non-zero exit is returned, never failed.
166
+ * Run `execPath bin ...args`; a non-zero exit is returned, never failed, unless `expectExit` is set.
136
167
  *
137
168
  * @remarks
138
169
  * `stdin` is never left as an inherited open pipe: when omitted or `""`
@@ -141,7 +172,7 @@ export declare class CliTest {
141
172
  * default `"pipe"` stdio, which stays open until something writes to and
142
173
  * ends it.
143
174
  */
144
- static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
175
+ static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError | CliTestFailure, ChildProcessSpawner.ChildProcessSpawner>;
145
176
  /**
146
177
  * Start `execPath bin ...args` and return at once with a handle on it: its output as lines while it runs, a signal,
147
178
  * and its exit, for a long-running command (a `--follow`, a watch mode, a server) that {@link CliTest.run} would wait
@@ -235,11 +266,17 @@ export declare class TestTerminal {
235
266
  /**
236
267
  * Build a test terminal.
237
268
  *
238
- * @param options - the reported size; 80 by 24 by default
269
+ * @remarks
270
+ * With `forbidInput`, `readInput` and `readLine` die with a message saying this path must not read the terminal,
271
+ * where the default double hands back an empty queue (a stray read hangs) and quits `readLine`. `size`, `columns`,
272
+ * `rows`, `display` and `resize` behave as ever, so a "this path never prompts" platform keeps a real terminal size.
273
+ *
274
+ * @param options - the reported size, 80 by 24 by default; `forbidInput` to die on any input read
239
275
  */
240
276
  static readonly make: (options?: {
241
277
  readonly columns?: number | undefined;
242
278
  readonly rows?: number | undefined;
279
+ readonly forbidInput?: boolean | undefined;
243
280
  }) => Effect.Effect<TestTerminalHandle>;
244
281
  }
245
282
  export type { KeyInput, RunOptions, RunResult, Sandbox, SpawnedBin, TestTerminalHandle };
package/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CliTest } from "./CliTest.js";
1
+ import { CliTest, CliTestFailure } from "./CliTest.js";
2
2
  import { TestTerminal } from "./TestTerminal.js";
3
3
 
4
- export { CliTest, TestTerminal };
4
+ export { CliTest, CliTestFailure, TestTerminal };
package/ui/CliUi.js CHANGED
@@ -354,9 +354,11 @@ var CliUi = class CliUi {
354
354
  * naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
355
355
  * pass each as an `otherwise`, and a non-interactive run returns exactly them.
356
356
  *
357
- * Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
358
- * the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
359
- * `CliRuntime.main`).
357
+ * It has two forms. With a real `otherwise` (any value but `undefined`) the error type is `Cancelled` alone, since a
358
+ * non-interactive run answers and cannot fail. Without one, or with `{ otherwise: undefined }`, which counts as not
359
+ * given, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since the type
360
+ * cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
361
+ * `CliRuntime.main`). `A` is read from the screen, never from `otherwise`.
360
362
  *
361
363
  * As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
362
364
  * the frame.
package/ui/UiTheme.js CHANGED
@@ -49,6 +49,25 @@ const useTheme = () => useScreen().theme;
49
49
  */
50
50
  const useGlyphs = () => useScreen().glyphs;
51
51
  /**
52
+ * The Ink `Text` props for `token`, resolved through the mounted screen's theme.
53
+ *
54
+ * @remarks
55
+ * The route a component takes to honour the theme: it sees a consumer's token overrides and `CliUiTest.styled`'s
56
+ * marker palette, and gives no styling props at colour `none`. `inkProps(Token.resolve(token))` resolves against the
57
+ * default palette and ignores the mounted theme, so under `CliUiTest.styled` it renders a raw colour where this
58
+ * renders the token.
59
+ *
60
+ * A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
61
+ *
62
+ * @param token - a theme token, or an explicit style
63
+ *
64
+ * @public
65
+ */
66
+ const useStyle = (token) => {
67
+ const theme = useTheme();
68
+ return inkProps(theme.style(token), theme.color);
69
+ };
70
+ /**
52
71
  * Text painted with a theme token or style, through the mounted screen's theme.
53
72
  *
54
73
  * @remarks
@@ -144,4 +163,4 @@ const useTerminalSize = () => {
144
163
  };
145
164
  };
146
165
 
147
- export { Styled, inkProps, useGlyphs, useTerminalSize, useTheme };
166
+ export { Styled, inkProps, useGlyphs, useStyle, useTerminalSize, useTheme };
package/ui.d.ts CHANGED
@@ -660,9 +660,11 @@ export declare class CliUi {
660
660
  * naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
661
661
  * pass each as an `otherwise`, and a non-interactive run returns exactly them.
662
662
  *
663
- * Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
664
- * the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
665
- * `CliRuntime.main`).
663
+ * It has two forms. With a real `otherwise` (any value but `undefined`) the error type is `Cancelled` alone, since a
664
+ * non-interactive run answers and cannot fail. Without one, or with `{ otherwise: undefined }`, which counts as not
665
+ * given, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since the type
666
+ * cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
667
+ * `CliRuntime.main`). `A` is read from the screen, never from `otherwise`.
666
668
  *
667
669
  * As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
668
670
  * the frame.
@@ -670,7 +672,12 @@ export declare class CliUi {
670
672
  * @param screen - the screen to show
671
673
  * @param options - the non-interactive default, and whether to erase the last frame
672
674
  */
673
- static readonly prompt: <A>(screen: Screen<A>, options?: CliUiPromptOptions<A>) => Effect.Effect<A, Cli.Cancelled | Cli.NotInteractive, Cli.CliTheme>;
675
+ static readonly prompt: {
676
+ <A>(screen: Screen<A>, options: CliUiPromptOptions<A> & {
677
+ readonly otherwise: Exclude<A, undefined>;
678
+ }): Effect.Effect<A, Cli.Cancelled, Cli.CliTheme>;
679
+ <A>(screen: Screen<A>, options?: CliUiPromptOptions<A>): Effect.Effect<A, Cli.Cancelled | Cli.NotInteractive, Cli.CliTheme>;
680
+ };
674
681
  /**
675
682
  * A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that shows a screen when the run is
676
683
  * interactive: `CliPrompt.fallback` for screens.
@@ -1942,6 +1949,22 @@ export declare const useTheme: () => Cli.StreamTheme;
1942
1949
  * @public
1943
1950
  */
1944
1951
  export declare const useGlyphs: () => Cli.GlyphSet;
1952
+ /**
1953
+ * The Ink `Text` props for `token`, resolved through the mounted screen's theme.
1954
+ *
1955
+ * @remarks
1956
+ * The route a component takes to honour the theme: it sees a consumer's token overrides and `CliUiTest.styled`'s
1957
+ * marker palette, and gives no styling props at colour `none`. `inkProps(Token.resolve(token))` resolves against the
1958
+ * default palette and ignores the mounted theme, so under `CliUiTest.styled` it renders a raw colour where this
1959
+ * renders the token.
1960
+ *
1961
+ * A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
1962
+ *
1963
+ * @param token - a theme token, or an explicit style
1964
+ *
1965
+ * @public
1966
+ */
1967
+ export declare const useStyle: (token: Cli.TokenName | Cli.Style) => InkTextProps;
1945
1968
  /**
1946
1969
  * Text painted with a theme token or style, through the mounted screen's theme.
1947
1970
  *
package/ui.js CHANGED
@@ -11,7 +11,7 @@ import { Toggle } from "./ui/Toggle.js";
11
11
  import { UiKey } from "./ui/UiKey.js";
12
12
  import { UiProvider } from "./ui/UiProvider.js";
13
13
  import { UiStreams } from "./ui/UiStreams.js";
14
- import { Styled, inkProps, useGlyphs, useTerminalSize, useTheme } from "./ui/UiTheme.js";
14
+ import { Styled, inkProps, useGlyphs, useStyle, useTerminalSize, useTheme } from "./ui/UiTheme.js";
15
15
  import { Viewport } from "./ui/Viewport.js";
16
16
 
17
- export { CliUi, Confirm, DocView, KeyHelp, KeyTable, MultiSelect, Select, Styled, Tabs, TextInput, Toggle, UiKey, UiProvider, UiStreams, Viewport, inkProps, useGlyphs, useKeys, useTerminalSize, useTheme };
17
+ export { CliUi, Confirm, DocView, KeyHelp, KeyTable, MultiSelect, Select, Styled, Tabs, TextInput, Toggle, UiKey, UiProvider, UiStreams, Viewport, inkProps, useGlyphs, useKeys, useStyle, useTerminalSize, useTheme };