@effected/cli 0.10.0 → 0.12.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 (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
@@ -0,0 +1,40 @@
1
+ import { WorkflowCommand } from "@effected/github-commands";
2
+
3
+ //#region src/GithubAnnotation.ts
4
+ /**
5
+ * GitHub Actions annotations, as workflow commands.
6
+ *
7
+ * @public
8
+ */
9
+ var GithubAnnotation = class {
10
+ constructor() {}
11
+ /**
12
+ * Format an annotation as a workflow command: `::error title=T,file=F,line=1,endLine=2,col=3,endColumn=4::message`.
13
+ *
14
+ * @remarks
15
+ * The message escapes `%`, CR and LF; a property value escapes those and `:` and `,`, per GitHub's
16
+ * [workflow-command documentation](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions).
17
+ * The percent sign is escaped first, so an escape that was just written is never escaped again. An unescaped line
18
+ * break in a message would let the text after it be read as a new command, which is why the escaping is not optional.
19
+ *
20
+ * A property that is not given is left out, and the properties are written in the order `title`, `file`, `line`,
21
+ * `endLine`, `col`, `endColumn`, the same as `@effected/github-commands`' `WorkflowCommand`, which this renders
22
+ * through.
23
+ *
24
+ * @param annotation - the level and the optional file, position and title
25
+ * @param message - the annotation's text
26
+ */
27
+ static format = (annotation, message) => {
28
+ return WorkflowCommand.render(annotation.level, {
29
+ title: annotation.title,
30
+ file: annotation.file,
31
+ line: annotation.line,
32
+ endLine: annotation.endLine,
33
+ col: annotation.col,
34
+ endColumn: annotation.endColumn
35
+ }, message);
36
+ };
37
+ };
38
+
39
+ //#endregion
40
+ export { GithubAnnotation };
package/Glyphs.js ADDED
@@ -0,0 +1,83 @@
1
+ //#region src/Glyphs.ts
2
+ /**
3
+ * The two glyph sets: Unicode, and a plain-ASCII fallback for terminals that cannot draw it.
4
+ *
5
+ * @remarks
6
+ * The sets are shared, so they and their nested values are frozen.
7
+ *
8
+ * @public
9
+ */
10
+ var Glyphs = class Glyphs {
11
+ constructor() {}
12
+ /** Unicode symbols. */
13
+ static unicode = Object.freeze({
14
+ kind: "unicode",
15
+ ellipsis: "…",
16
+ spinner: Object.freeze([
17
+ "⠋",
18
+ "⠙",
19
+ "⠹",
20
+ "⠸",
21
+ "⠼",
22
+ "⠴",
23
+ "⠦",
24
+ "⠧",
25
+ "⠇",
26
+ "⠏"
27
+ ]),
28
+ bullet: "•",
29
+ arrow: "→",
30
+ pathSeparator: Object.freeze({
31
+ human: "›",
32
+ agent: " > "
33
+ }),
34
+ spinnerIntervalMs: 80,
35
+ tree: Object.freeze({
36
+ branch: "├─ ",
37
+ last: "└─ ",
38
+ pipe: "│ ",
39
+ blank: " "
40
+ })
41
+ });
42
+ /**
43
+ * Pick a glyph set without a service: the one `CliTheme` uses, as a pure function.
44
+ *
45
+ * @remarks
46
+ * `CliTheme.layer` reads `TERM` through `Config` and calls this, so the two agree. `StreamEnv` carries no
47
+ * `TERM`, and nothing else in it decides ASCII, so the caller passes `term` when it wants `auto` to mean
48
+ * something.
49
+ *
50
+ * @param options - whether to force ASCII or Unicode, and the `TERM` value `auto` reads
51
+ */
52
+ static select = (options) => {
53
+ const ascii = options?.ascii ?? "auto";
54
+ return ascii === true || ascii === "auto" && options?.term === "dumb" ? Glyphs.ascii : Glyphs.unicode;
55
+ };
56
+ /** ASCII-only symbols. */
57
+ static ascii = Object.freeze({
58
+ kind: "ascii",
59
+ ellipsis: "...",
60
+ spinner: Object.freeze([
61
+ "-",
62
+ "\\",
63
+ "|",
64
+ "/"
65
+ ]),
66
+ bullet: "*",
67
+ arrow: "->",
68
+ pathSeparator: Object.freeze({
69
+ human: ">",
70
+ agent: " > "
71
+ }),
72
+ spinnerIntervalMs: 80,
73
+ tree: Object.freeze({
74
+ branch: "|-- ",
75
+ last: "\\-- ",
76
+ pipe: "| ",
77
+ blank: " "
78
+ })
79
+ });
80
+ };
81
+
82
+ //#endregion
83
+ export { Glyphs };
@@ -0,0 +1,42 @@
1
+ import { Runtime, Schema } from "effect";
2
+
3
+ //#region src/NotInteractive.ts
4
+ /**
5
+ * A command needed to prompt, but there is no terminal to prompt on.
6
+ *
7
+ * @remarks
8
+ * Exits `64` (BSD `EX_USAGE`) through core's `Runtime.errorExitCode` marker:
9
+ * the caller invoked the command the wrong way, so the fix is to run it in a
10
+ * terminal or pass the flag that supplies the answer. Its default rendering is
11
+ * one line, `not interactive: run in a terminal or pass the flag`, which is the error's `message`, so a consumer
12
+ * `render` can print `error.message` and keep it.
13
+ *
14
+ * @public
15
+ */
16
+ var NotInteractive = class extends Schema.TaggedError()("NotInteractive", {}) {
17
+ /**
18
+ * The one line, `not interactive: run in a terminal or pass the flag`.
19
+ *
20
+ * @remarks
21
+ * A prototype getter, not a field, so it is not part of the encoded form, equality or a JSON dump. Assigning to
22
+ * it is ignored: a library that rewrites `error.message` must not make this error throw, which a getter-only
23
+ * property does in strict mode. The line is fixed.
24
+ */
25
+ get message() {
26
+ return "not interactive: run in a terminal or pass the flag";
27
+ }
28
+ set message(_value) {}
29
+ /**
30
+ * The process exit code: `64`.
31
+ *
32
+ * @remarks
33
+ * A prototype getter rather than an own field, so a JSON or logger dump of the error does not carry the
34
+ * runtime marker. It is the error's own code, so `CliRuntime`'s `usageExitCode` option does not change it.
35
+ */
36
+ get [Runtime.errorExitCode]() {
37
+ return 64;
38
+ }
39
+ };
40
+
41
+ //#endregion
42
+ export { NotInteractive };
package/README.md CHANGED
@@ -5,12 +5,12 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- The boundary layer of a command-line program built on `effect/cli`: how output reaches a human, how a failure is reported, and how a schema issue becomes a sentence someone can act on. `CliLogger` renders log records as plain lines and routes diagnostics to stderr, reading the `Console` off the fiber so it needs no platform package and the stream split is actually testable. `CliRuntime.reportFailures` catches inside your program so a failure prints through *your* logger instead of Effect's default one on stdout, then re-fails with the exit code and the no-double-report mark; `CliRuntime.main` assembles a whole program — platform layer, a fresh `CliExit`, failure reporting, and the logger — in the one order that reports every failure well. `CliExit` lets a findings command (a linter that found problems, say) succeed with a non-zero exit code, with finalizers intact on any runtime. `CliColor` decides once, per the no-color.org rule, whether output carries ANSI colour, and hands core's own `CliOutput.Formatter` the same decision. `SchemaIssueRenderer` and `ConfigIssueRenderer` turn issue trees into `unknown key at groups.g.rulesetz`. The `./testing` subpath's `CliTest` spawns a built bin hermetically for a test that wants a real subprocess.
8
+ The presentation boundary of a command-line program built on `effect/cli`: who the output is for, and how it reaches them. Plain log lines on the right stream. Colour, glyphs and links only where the terminal and the reader can use them. Documents rendered for a person, an agent or a CI log. Failures reported through your own logger with the right exit code. Prompts that know when there is nobody to ask. Interactive screens and live progress views drawn with Ink. `effect/cli` still owns argument parsing, flags, the command tree and help; this package adds no parser and no command model.
9
9
 
10
- > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
- > development against a single pinned Effect v4 prerelease. Packages graduate to
12
- > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
- > exactly the ones the kit is built and tested against, install
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
14
  > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
@@ -21,181 +21,195 @@ The boundary layer of a command-line program built on `effect/cli`: how output r
21
21
 
22
22
  ## Why @effected/cli
23
23
 
24
- Everything here shares one property: **you only discover you needed it by shipping bad output to a person.** None of it fails a type-check, a test, or a review of the code in isolation.
24
+ Everything here shares one property: **you only discover you needed it by shipping bad output to a person or a machine.** None of it fails a type-check, a test, or a review of the code in isolation.
25
25
 
26
- Effect's default logger emits `[00:33:56.619] INFO (#2): message`. That is correct for a service being scraped and wrong for a tool someone is watching — it turns a formatted table into noise — and nothing at the call site suggests it. A platform `runMain` then reports an unhandled failure through that *same* default logger, which sits outside the layers your program was provided, so a program that carefully installs a CLI logger still prints its failures in the format that logger exists to replace, on **stdout**, the one stream errors must not use. And a decode failure arrives as a structured tree when what a user needs is a sentence naming the key they got wrong; core does ship formatters for this, but they live on `SchemaIssue` rather than `SchemaError`, are named `makeFormatter*`, and are not referenced by `SchemaError.message` — two engineers searched for two rounds and concluded they did not exist.
26
+ Effect's default logger emits `[00:33:56.619] INFO (#2): message`, which is right for a service and noise for a tool someone is watching. A platform `runMain` reports an unhandled failure through that same default logger, outside the layers your program was given, so it prints on **stdout**, the one stream errors must not use. Colour codes end up in an agent's context window. A prompt fires inside a pipe and hangs. A file name containing `::error::` becomes a workflow command in GitHub Actions. And a decode failure arrives as a structured tree when a user needs a sentence naming the key they got wrong.
27
27
 
28
- This package is **not a CLI framework**. `effect/cli` owns argument parsing, flags, the command tree and help, and this package must never grow a second one.
28
+ This package makes those decisions once, at the edge of the program, from the environment it actually runs in.
29
29
 
30
30
  ## Install
31
31
 
32
32
  ```bash
33
- npm install @effected/cli effect
33
+ npm install @effected/cli @effected/env @effected/glob @effected/walker effect
34
34
  ```
35
35
 
36
36
  ```bash
37
- pnpm add @effected/cli effect
37
+ pnpm add @effected/cli @effected/env @effected/glob @effected/walker effect
38
38
  ```
39
39
 
40
- Requires Node.js >=24.11.0. `effect` v4 is a peer dependency.
40
+ Requires Node.js >=24.11.0. `effect` v4, `@effected/env` (the audience and terminal services), and `@effected/walker` with its `@effected/glob` peer (the project root for editor links) are peer dependencies. The one runtime dependency is `@effected/github-commands`. The package never imports a platform package, so it runs unchanged on Node, Bun and Deno.
41
41
 
42
- All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
42
+ Optional peers:
43
43
 
44
- `@effected/config-file` is an **optional** peer, needed only for `ConfigIssueRenderer`. It lives in its own module and is imported as a type, so nothing at runtime reaches for it.
44
+ - **`ink` and `react`**, plus **`@types/react`** for TypeScript, for the interactive screens and live views in `@effected/cli/ui`. The root never reaches them, and `./ui` loads neither until a screen first mounts. Without `@types/react`, a program compiled with `skipLibCheck` silently types every screen as `any`.
45
45
 
46
- ## Quick start
47
-
48
- ```ts
49
- import { CliLogger, CliRuntime } from "@effected/cli";
50
- import { NodeRuntime } from "@effect/platform-node";
51
- import { Console, Effect, Layer } from "effect";
46
+ ```bash
47
+ npm install ink react @types/react
48
+ ```
52
49
 
53
- declare const AppLive: Layer.Layer<never>;
50
+ - **`@effected/config-file`**, only for `ConfigIssueRenderer`. It is imported as a type, so nothing at runtime reaches for it.
54
51
 
55
- const program = Effect.gen(function* () {
56
- yield* Effect.logInfo("building 3 packages"); // a diagnostic, not the product
57
- yield* Console.log("build.json contents"); // the program's actual output
58
- yield* Effect.logError("nothing to build");
59
- });
60
-
61
- // Merged, not provided beneath: this way it also covers lines emitted during
62
- // layer construction, which is exactly where a startup failure prints.
63
- const MainLive = Layer.mergeAll(AppLive, CliLogger.layer());
52
+ 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.
64
53
 
65
- NodeRuntime.runMain(program.pipe(CliRuntime.reportFailures(), Effect.provide(MainLive)));
66
- // stdout: build.json contents
67
- // stderr: building 3 packages
68
- // stderr: nothing to build
69
- // No timestamp, no level, no fiber id — and stdout carries only what Console.log wrote.
70
- ```
54
+ ## Quick start
71
55
 
72
- Rendering a bad config into something actionable:
56
+ One wiring serves every program: share the audience flags on the root command, run it through `CliAudience.run`, and hand that to `CliRuntime.main` with an `env`.
73
57
 
74
58
  ```ts
75
- import { CliRuntime, ConfigIssueRenderer } from "@effected/cli";
59
+ import { CliAudience, CliExit, CliMessage, CliRuntime, Doc } from "@effected/cli";
60
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
76
61
  import { Effect } from "effect";
62
+ import { Command } from "effect/cli";
77
63
 
78
- configFile.load.pipe(
79
- Effect.catchTag("ConfigValidationError", (error) =>
80
- Effect.gen(function* () {
81
- yield* Effect.logError(String(error));
82
- for (const line of ConfigIssueRenderer.render(error)) yield* Effect.logError(` ${line}`);
83
-
84
- // Re-fail, or the handler SUCCEEDS and a CLI exits 0 on invalid config.
85
- // `reported` carries the exit code and the mark that stops the runtime
86
- // printing the same failure a second time.
87
- return yield* Effect.fail(CliRuntime.reported(error));
88
- }),
89
- ),
64
+ const sync = Command.make("sync", {}, () =>
65
+ Effect.gen(function* () {
66
+ yield* CliMessage.info("syncing 2 repositories");
67
+ yield* Doc.print([
68
+ Doc.table(
69
+ [{ header: "Repository" }, { header: "Files", align: "right" }],
70
+ [
71
+ ["acme/widgets", "12"],
72
+ ["acme/gadgets", "3"],
73
+ ],
74
+ ),
75
+ ]);
76
+ yield* CliMessage.warning("acme/gadgets has no default branch");
77
+ // A finding, not a crash: the handler succeeds and the run still exits 1.
78
+ yield* CliExit.set(1);
79
+ }),
80
+ );
81
+
82
+ const root = Command.make("tool").pipe(Command.withSharedFlags(CliAudience.flags()), Command.withSubcommands([sync]));
83
+
84
+ NodeRuntime.runMain(
85
+ CliRuntime.main(CliAudience.run(root, { version: "1.0.0" }), {
86
+ platform: NodeServices.layer,
87
+ env: {
88
+ audienceEnvVar: "TOOL_AUDIENCE",
89
+ stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true),
90
+ },
91
+ }),
90
92
  );
91
93
  ```
92
94
 
93
95
  ```text
94
- ConfigValidationError: Config validation failed at "/home/me/.config/app/config.toml"
95
- unknown key at variables.keep.KEEP_ME
96
- Missing key at variables.keep.file
97
- Missing key at variables.keep.value
98
- Missing key at variables.keep.resolved
96
+ $ tool sync
97
+ ℹ syncing 2 repositories
98
+ Repository Files
99
+ ------------ -----
100
+ acme/widgets 12
101
+ acme/gadgets 3
102
+ ⚠ acme/gadgets has no default branch
103
+ $ echo $?
104
+ 1
99
105
  ```
100
106
 
101
- Print-then-`reported` is for a program run **without** `CliRuntime.main` or `reportFailures`. Under either, do not print the failure yourself: `reportFailures` renders every error except a `ShowHelp`, a `CliError.UserError` whose reported mark is `false` (one `Command.runWith` already printed, or one you marked with `reported`) and the `CliExit` sentinel, so it would print twice. Fail with the error and put the multi-line rendering in the `render` option instead — see "Rendering a multi-line failure" in the [advanced guide](https://effected.spencerbeg.gs/cli/advanced#rendering-a-multi-line-failure).
107
+ At a colour terminal the glyphs are painted and the header is bold. For an agent (`--agent`, `TOOL_AUDIENCE=agent`, or an agent detected from the environment), in a pipe, or under `NO_COLOR`, the same program writes no escape sequence of any kind. Under GitHub Actions, anything the runner could read as a workflow command is neutralized. A failure anywhere renders as a short report on stderr and exits non-zero.
102
108
 
103
- ## Putting it together
109
+ ## Audiences
104
110
 
105
- A findings command — one whose non-zero exit reports a result rather than a
106
- crash — wires `CliRuntime.main`, `CliExit.set` and `CliColor.formatterLayer`
107
- around an ordinary `effect/cli` command:
111
+ The audience is decided once per run: an audience flag (`--audience <human|agent|ci>`, `--human`, `--agent`, `--ci`), then the override variable you name, then an agent detected from the environment, then CI, else a human. It decides:
108
112
 
109
- ```ts
110
- import { CliColor, CliExit, CliRuntime } from "@effected/cli";
111
- import { NodeRuntime, NodeServices } from "@effect/platform-node";
112
- import { Console, Effect, Layer } from "effect";
113
- import { Command, Flag } from "effect/cli";
113
+ | | Human | Agent | CI |
114
+ | --- | --- | --- | --- |
115
+ | Colour | When the stream has it | Never, even with `FORCE_COLOR` | When the stream has it (messages) |
116
+ | `Doc.print` | `ansi`: painted, OSC 8 links | `plain` | `githubLog` under GitHub Actions, else `plain` |
117
+ | Width | The terminal's | Unbounded | Unbounded |
118
+ | Diagnostics (`CliLog`) | Pretty lines | NDJSON | NDJSON |
119
+ | Prompts and screens | When interactive | Never | Never |
114
120
 
115
- const findProblems = (strict: boolean): ReadonlyArray<string> =>
116
- strict ? ["missing changeset", "unpinned dependency"] : ["missing changeset"];
121
+ Colour follows Node's precedence, per stream: `FORCE_COLOR` decides first and beats `NO_COLOR` (`1`–`3` on, even in a pipe; `0` off). Otherwise there is no colour without a terminal. On a terminal, a non-empty `NO_COLOR`, `NODE_DISABLE_COLORS` or `TERM=dumb` turns it off. `TERM=dumb` also switches to ASCII glyphs and makes the run non-interactive.
117
122
 
118
- const check = Command.make("check", { strict: Flag.Boolean("strict").pipe(Flag.withDefault(false)) }, (config) =>
119
- Effect.gen(function* () {
120
- const problems = findProblems(config.strict);
121
- for (const problem of problems) yield* Console.log(problem);
123
+ ## Output
122
124
 
123
- // Findings, not a crash: the handler still SUCCEEDS. CliExit.set records
124
- // the code CliRuntime.main turns into a real exit once the program ends.
125
- if (problems.length > 0) yield* CliExit.set(1);
126
- }),
127
- );
125
+ - **`CliMessage`**: `success`, `info`, `warning`, `failure` and `status(vocab, name, text)`. One themed line each, through `Console` rather than the logger, so no log level silences them. Warnings and failures go to stderr.
126
+ - **`Doc` and `Render`**: a document IR (headings, paragraphs, lists, tables, trees, counts, count tables, collapsibles, callouts, code blocks, diffs, GitHub annotations) and pure `plain`, `ansi`, `markdown` and `githubLog` renderers. `Doc.print` picks the renderer for the audience, and lays out at the terminal's width only when the stream is a terminal: piped output (`tool | grep`) never wraps. `Doc.line(content, { wrap: false })` keeps one line whole at any width, glyph and colour included. `Render.contextOf` renders outside Effect, for example markdown for a step summary.
127
+ - **`CliTheme`, `Token`, `Status`, `Glyphs`**: semantic tokens (`success`, `failure`, `warning`, `info`, `error`, `muted`, `accent`, `emphasis`), an extendable status vocabulary with glyphs and ranks, and Unicode or ASCII glyph sets. Override tokens with `env.theme`.
128
+ - **`CliLinks`**: file links that open in VS Code (`vscode://file/…`) or as `file://` URLs, as OSC 8 hyperlinks where the terminal renders them, never for an agent.
129
+ - **`Fmt`**: `sanitize`, `width`, `truncate`, `duration`, `percent` and `plural`.
128
130
 
129
- // Satisfies Command.Environment (Command.run needs it) AND feeds Stdio to
130
- // CliColor.formatterLayer, so help text, parse errors and rendered output
131
- // never disagree about whether colour is on.
132
- const Platform = CliColor.formatterLayer().pipe(Layer.provideMerge(NodeServices.layer));
131
+ Every string that enters a document or a message is sanitised: escape sequences and control characters are removed, so data cannot paint the terminal or plant a link.
133
132
 
134
- // CliRuntime.main provides a fresh CliExit, the platform layer inside failure
135
- // reporting, and the logger outermost — do NOT provide CliExit.layer here
136
- // yourself, or CliExit.set writes to a second, unread cell and `check`
137
- // silently exits 0.
138
- NodeRuntime.runMain(CliRuntime.main(Command.run(check, { version: "1.0.0" }), { platform: Platform }));
139
- ```
133
+ ## Failures and exit codes
140
134
 
141
- ```bash
142
- $ node check.js
143
- missing changeset
144
- $ echo $?
145
- 1
146
- ```
135
+ `CliRuntime.main` reports a failure as a document on stderr, through the audience's renderer: a status line for a typed failure, a tree of rejected values for a schema failure, and a defect's message with a collapsible stack of your own frames (Effect's, Node's and `node_modules` frames hidden). Give an error class a `[CliDoc]()` method to draw itself, or pass a `render` option. Its `details.lines({ status: false })` keeps the run's colour and paths behind your own prefix. The `in: outer › inner` span trail after a failure names only your own spans by default; `env.spans` (`"app"`, `"all"` or `"off"`) chooses, as `env.stackFrames` does for a defect's frames. `"app"` leaves out spans defined in files under `node_modules/@effected/` or `node_modules/effect/`, and fails open: a kit package linked into a workspace, or a bundled program, shows more, never less. A program that is itself installed under `node_modules/@effected/` passes its bin's `import.meta.url` as `env.appModule` to keep its own spans. `env.spansEnvVar` names a variable (say `TOOL_SPANS`) that sets it at run time, as `log.envVar` sets the level. An `Effect.fn` call and its definition are one entry in the trail.
147
136
 
148
- ## Features
137
+ - `CliExit.set(code)` records a findings exit code from a handler that still succeeds. Do not provide `CliExit.layer` yourself under `main`, or the code goes to a second, unread cell.
138
+ - `Cancelled` (a prompt quit) exits `130`, and `NotInteractive` (a prompt with nobody to ask) exits `64`, each as one fixed line.
139
+ - A usage error exits `64`. `helpOnUsageError: "stderr"` keeps stdout clean for a caller piping it into `jq`.
140
+ - Without `main`: `CliRuntime.reportFailures()` is the combinator to apply inside your program, and `CliRuntime.reported(error, code)` marks an error you printed yourself.
149
141
 
150
- - `CliLogger.layer(options?)` — replaces the default logger with plain lines, routing every level to stderr by default. The threshold is the `stderrFrom` option (pass `"Error"` to restore the old split), compared ordinally, so a level added upstream lands on the right stream without a change here.
151
- - `CliLogger.make(options?)` — the `Logger` itself, for composing into a logger set you already have.
152
- - `CliRuntime.reportFailures(options?)` — reports through your logger, then re-fails with an exit code and the mark that stops the runtime reporting it a second time. `render(error, details)` receives the squashed error and a `FailureDetails` (`{ cause, isDefect }`), so a defect can render differently from a typed failure. Never renders a `CliError.ShowHelp` (already printed by `Command.runWith`) — a `ShowHelp` carrying errors is remapped to `usageExitCode` (default `64`).
153
- - `CliRuntime.main(program, { platform, logger?, ... })` — assembles a whole program in the one order that reports every failure well: a fresh `CliExit`, the platform layer inside failure reporting, and the logger outermost. `helpOnUsageError: "stderr"` moves the help printed with a parse error onto stderr beside the error, so a caller piping stdout into `jq` gets nothing on a usage error; `--help` and a bare group invocation still print on stdout.
154
- - `CliRuntime.reported(error, exitCode?)` — marks an error you reported yourself, so the runtime stays quiet about it. A typed `Error` comes back as its own type (the marks are added in place) when it passes `instanceof Error` at runtime; any other value — including one that only satisfies `Error`'s shape structurally — is wrapped in a plain `Error`. A `CliError.UserError` marked with `reported` is treated as already printed and is not rendered — use a different error type if the program has not printed it. It keeps the code you pass: `reported(userError, 3)` exits `3`, not `usageExitCode`.
155
- - `CliExit.set(code)` — records a findings exit code from a successful program; the highest code set during the run wins. `CliExit.layer` mints a fresh cell per provide (`Layer.fresh`) — `CliRuntime.main` provides it for you.
156
- - `CliColor.enabled` — `Effect<boolean, never, Stdio>`, the no-color.org decision: off when stdout is not a terminal, or `NO_COLOR` is a non-empty value. `FORCE_COLOR` is ignored.
157
- - `CliColor.formatterLayer(overrides?)` — core's `CliOutput.Formatter`, coloured by the same decision as `CliColor.enabled`.
158
- - `SchemaIssueRenderer.render(issue)` — a `SchemaIssue` tree becomes one line per rejected value.
159
- - `ConfigIssueRenderer.render(error)` — the same rendering, reading `issue` off a `ConfigValidationError`.
142
+ `SchemaIssueRenderer.render(issue)` and `ConfigIssueRenderer.render(error)` turn an issue tree into lines like `unknown key at groups.g.rulesetz`.
160
143
 
161
- Two behaviours worth knowing before you rely on them:
144
+ ## Logging
162
145
 
163
- - `CliLogger` honours `References.LogToStderr` as a **one-way** override — it can force everything to stderr, and can never move an error onto stdout.
164
- - `CliRuntime` keeps an exit code the error already carries via `Runtime.errorExitCode`; the `exitCode` option is a fallback, not an override. An interrupt is left alone.
146
+ `CliLogger` writes plain lines, with no timestamp, level or fiber id, and routes every level to stderr by default (`stderrFrom` narrows it), so stdout carries only the program's output. Pass `env.log` to `main` for **`CliLog`**: a diagnostics level of its own (`level`, or `envVar` such as `TOOL_LOG_LEVEL`, with core's `--log-level` beating both), pretty lines for a person and NDJSON for an agent or CI, an optional NDJSON log file, and `CliLog.component(name)` tags. `CliLog.status(vocab, name, text)` logs a diagnostic with a painted status glyph (its text still sanitised), and `CliTheme.forAudience` applies the kit's "an agent never gets an escape" rule to a theme you paint with yourself.
165
147
 
166
- ## Testing
148
+ ## Prompts and screens
167
149
 
168
- `@effected/cli/testing` is a separate entrypoint — importing `@effected/cli`
169
- never pulls it in — for spawning a **built** bin hermetically and reading its
170
- exit code and streams as data:
150
+ A prompt fires only when `CliInteractive` is true: a human audience, a terminal on stdin and stdout, and `TERM` not `dumb`. Otherwise it answers a default you supply, or fails cleanly.
171
151
 
172
152
  ```ts
173
- import { CliTest } from "@effected/cli/testing";
174
- import * as NodeServices from "@effect/platform-node/NodeServices";
175
- import { assert, describe, it } from "@effect/vitest";
176
- import { Effect } from "effect";
153
+ import { CliPrompt } from "@effected/cli";
154
+ import { Flag, Prompt } from "effect/cli";
155
+
156
+ // Core's prompt as a flag fallback: asks at a terminal, uses "library" in a pipe.
157
+ const profile = Flag.String("profile").pipe(
158
+ Flag.withFallbackPrompt(
159
+ CliPrompt.fallback(
160
+ Prompt.Select({ message: "Profile", choices: [{ title: "library", value: "library" }, { title: "application", value: "application" }] }),
161
+ { flag: "profile", otherwise: "library" },
162
+ ),
163
+ ),
164
+ );
165
+ ```
166
+
167
+ `@effected/cli/ui` adds Ink screens: `CliUi.run`, `prompt` (with an `otherwise`) and `fallback` (for a flag), over the widgets `Select`, `TextInput` (with a `mask` for secrets, always, or from the moment a predicate spots one anywhere in the value, latched until the value is cleared: its `validate` message is drawn unmasked, so never echo the value in it), `MultiSelect`, `Confirm` (with toggles), `Toggle`, `Tabs` and `Viewport`. Your own screens use the key layer (`KeyTable`, `useKeys`, `KeyHelp`) and the theme bridge (`Styled`, `useTheme`, `useGlyphs`, `useTerminalSize`).
177
168
 
178
- describe("check", () => {
179
- it.effect("exits 1 when it finds a problem", () =>
180
- Effect.gen(function* () {
181
- const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" });
182
- const result = yield* CliTest.run("./dist/check.js", ["--strict"], {
183
- sandbox,
184
- execPath: process.execPath,
185
- });
186
- assert.strictEqual(result.exitCode, 1);
187
- assert.include(result.stdout, "missing changeset");
188
- }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
189
- );
169
+ ```tsx
170
+ import { CliUi, Select } from "@effected/cli/ui";
171
+
172
+ const pickProfile = Select.screen({
173
+ message: "Profile",
174
+ choices: [
175
+ { label: "library", value: "library" },
176
+ { label: "application", value: "application" },
177
+ ],
190
178
  });
179
+
180
+ const profile = CliUi.prompt(pickProfile, { otherwise: "library" });
181
+ ```
182
+
183
+ `CliUi.map(screen, f)` maps a screen's answer and leaves a cancel alone, so a `Confirm` can back a boolean flag ("confirm, or `--yes`"):
184
+
185
+ ```ts
186
+ import { CliUi, Confirm } from "@effected/cli/ui";
187
+ import { Flag } from "effect/cli";
188
+
189
+ const yes = Flag.Boolean("yes").pipe(
190
+ Flag.withFallbackPrompt(
191
+ CliUi.fallback(
192
+ CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
193
+ { flag: "yes", otherwise: false },
194
+ ),
195
+ ),
196
+ );
191
197
  ```
192
198
 
193
- `CliTest.sandbox({ path })` mints a scoped temp directory with a fresh `HOME`
194
- and `XDG_{CONFIG,DATA,STATE,CACHE}_HOME`, `NO_COLOR: "1"`, and the `path` you
195
- pass as `PATH` — the host environment is never inherited. `CliTest.run` never
196
- leaves `stdin` as an open pipe: when you omit it, or pass `""`, the child
197
- receives an already-ended empty input, so a stdin-reading bin cannot hang the
198
- test.
199
+ ## Live views
200
+
201
+ `CliUi.live` folds a stream or a `PubSub` subscription of events into state, and draws **runs** with Ink while they are going: a run starts at `isStart`, redraws on a tick, and commits its final frame at `isTerminal`. Log lines go above the frame through `handle.logConsole`. End with `handle.close`, which folds everything still queued. When nobody is watching (a pipe, an agent, CI), each run's final frame prints once instead: give the view a `final: (state) => Document` and that run prints the document with no Ink or React loaded at all; `render` is then never called on such a run, not even to build an unused string. `render: CliUi.lazyView(() => import("./view.js"))` keeps the view's module, and React, off every run until one draws. `DocView` draws a `Doc` document inside a view byte for byte as `Doc.print` would, and `UiProvider` with `CliUi.context` gives an Ink tree you mount yourself the same theme.
202
+
203
+ ## Testing
204
+
205
+ - **`@effected/cli/testing`**: `CliTest.sandbox` and `CliTest.run` spawn a built bin hermetically and return `{ exitCode, stdout, stderr }` as data. `TestTerminal` drives core's prompts.
206
+ - **`@effected/cli/ui/testing`**: `CliUiTest.render` mounts a screen on in-memory streams (`press`, `type`, `chunk`, `frame`, `result`). `view` mounts a display-only element, `session` drives a whole command's screens (its `transcript` shows what reached the terminal, a live view's `logConsole` lines included, `stdoutWritten`/`stderrWritten` each stream alone as raw bytes, `stdoutTranscript`/`stderrTranscript` each stream alone as plain text, and `renderPath: "production"` makes `clear` observable), and `live` mounts a live view on the production render path with a `TestClock` tick. `CliUiTest.serializer` prints frames as token markup in snapshots: register it in the Vitest config with `snapshotSerializers: ["@effected/cli/ui/testing/serializer"]`, or with `expect.addSnapshotSerializer`. Snapshots are the one place a test needs `expect`, since `assert` has no snapshot form.
207
+
208
+ In-process, provide `layerTest`s from `@effected/env` and `CliTheme.layerTest`, swap in a capturing `Console`, and assert on both streams. Neither testing entrypoint is reachable from a CLI's runtime imports.
209
+
210
+ ## Documentation
211
+
212
+ Guides for every part, and the full API reference, are at [effected.spencerbeg.gs/cli](https://effected.spencerbeg.gs/cli): [getting started](https://effected.spencerbeg.gs/cli/getting-started), [audiences and output](https://effected.spencerbeg.gs/cli/output), [prompts and screens](https://effected.spencerbeg.gs/cli/prompts), [live views](https://effected.spencerbeg.gs/cli/live-views), [testing](https://effected.spencerbeg.gs/cli/testing) and [advanced](https://effected.spencerbeg.gs/cli/advanced).
199
213
 
200
214
  ## License
201
215