@effected/cli-kit 0.0.0-stage → 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.
Files changed (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +66 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +117 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +240 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +62 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +295 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +213 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +107 -0
  29. package/Token.js +70 -0
  30. package/index.d.ts +4035 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +63 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +173 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +87 -5
  61. package/testing.d.ts +283 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +509 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +166 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +2013 -0
  103. package/ui.js +17 -0
package/README.md CHANGED
@@ -1,3 +1,214 @@
1
- # Temporary Holding Version
1
+ # @effected/cli-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fcli?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/cli-kit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
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
+
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `~4.1.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
+ > [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Why @effected/cli-kit
23
+
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
+
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
+
28
+ This package makes those decisions once, at the edge of the program, from the environment it actually runs in.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install @effected/cli-kit @effected/env @effected/glob @effected/walker effect
34
+ ```
35
+
36
+ ```bash
37
+ pnpm add @effected/cli-kit @effected/env @effected/glob @effected/walker effect
38
+ ```
39
+
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
+
42
+ Optional peers:
43
+
44
+ - **`ink` and `react`**, plus **`@types/react`** for TypeScript, for the interactive screens and live views in `@effected/cli-kit/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
+
46
+ ```bash
47
+ npm install ink react @types/react
48
+ ```
49
+
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.
51
+
52
+ ## Quick start
53
+
54
+ 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`.
55
+
56
+ ```ts
57
+ import { CliAudience, CliExit, CliMessage, CliRuntime, Doc } from "@effected/cli-kit";
58
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
59
+ import { Effect } from "effect";
60
+ import { Command } from "effect/cli";
61
+
62
+ const sync = Command.make("sync", {}, () =>
63
+ Effect.gen(function* () {
64
+ yield* CliMessage.info("syncing 2 repositories");
65
+ yield* Doc.print([
66
+ Doc.table(
67
+ [{ header: "Repository" }, { header: "Files", align: "right" }],
68
+ [
69
+ ["acme/widgets", "12"],
70
+ ["acme/gadgets", "3"],
71
+ ],
72
+ ),
73
+ ]);
74
+ yield* CliMessage.warning("acme/gadgets has no default branch");
75
+ // A finding, not a crash: the handler succeeds and the run still exits 1.
76
+ yield* CliExit.set(1);
77
+ }),
78
+ );
79
+
80
+ const root = Command.make("tool").pipe(Command.withSharedFlags(CliAudience.flags()), Command.withSubcommands([sync]));
81
+
82
+ NodeRuntime.runMain(
83
+ CliRuntime.main(CliAudience.run(root, { version: "1.0.0" }), {
84
+ platform: NodeServices.layer,
85
+ env: {
86
+ audienceEnvVar: "TOOL_AUDIENCE",
87
+ stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true),
88
+ },
89
+ }),
90
+ );
91
+ ```
92
+
93
+ ```text
94
+ $ tool sync
95
+ ℹ syncing 2 repositories
96
+ Repository Files
97
+ ------------ -----
98
+ acme/widgets 12
99
+ acme/gadgets 3
100
+ ⚠ acme/gadgets has no default branch
101
+ $ echo $?
102
+ 1
103
+ ```
104
+
105
+ 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.
106
+
107
+ ## Audiences
108
+
109
+ 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:
110
+
111
+ | | Human | Agent | CI |
112
+ | --- | --- | --- | --- |
113
+ | Colour | When the stream has it | Never, even with `FORCE_COLOR` | When the stream has it (messages) |
114
+ | `Doc.print` | `ansi`: painted, OSC 8 links | `plain` | `githubLog` under GitHub Actions, else `plain` |
115
+ | Width | The terminal's | Unbounded | Unbounded |
116
+ | Diagnostics (`CliLog`) | Pretty lines | NDJSON | NDJSON |
117
+ | Prompts and screens | When interactive | Never | Never |
118
+
119
+ 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.
120
+
121
+ ## Output
122
+
123
+ - **`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.
124
+ - **`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.
125
+ - **`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`.
126
+ - **`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.
127
+ - **`Fmt`**: `sanitize`, `width`, `truncate`, `duration`, `percent` and `plural`.
128
+
129
+ 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.
130
+
131
+ ## Failures and exit codes
132
+
133
+ `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.
134
+
135
+ - `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.
136
+ - `Cancelled` (a prompt quit) exits `130`, and `NotInteractive` (a prompt with nobody to ask) exits `64`, each as one fixed line.
137
+ - A usage error exits `64`. `helpOnUsageError: "stderr"` keeps stdout clean for a caller piping it into `jq`.
138
+ - Without `main`: `CliRuntime.reportFailures()` is the combinator to apply inside your program, and `CliRuntime.reported(error, code)` marks an error you printed yourself.
139
+
140
+ `SchemaIssueRenderer.render(issue)` and `ConfigIssueRenderer.render(error)` turn an issue tree into lines like `unknown key at groups.g.rulesetz`.
141
+
142
+ ## Logging
143
+
144
+ `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.
145
+
146
+ ## Prompts and screens
147
+
148
+ 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.
149
+
150
+ ```ts
151
+ import { CliPrompt } from "@effected/cli-kit";
152
+ import { Flag, Prompt } from "effect/cli";
153
+
154
+ // Core's prompt as a flag fallback: asks at a terminal, uses "library" in a pipe.
155
+ const profile = Flag.String("profile").pipe(
156
+ Flag.withFallbackPrompt(
157
+ CliPrompt.fallback(
158
+ Prompt.Select({ message: "Profile", choices: [{ title: "library", value: "library" }, { title: "application", value: "application" }] }),
159
+ { flag: "profile", otherwise: "library" },
160
+ ),
161
+ ),
162
+ );
163
+ ```
164
+
165
+ `@effected/cli-kit/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`).
166
+
167
+ ```tsx
168
+ import { CliUi, Select } from "@effected/cli-kit/ui";
169
+
170
+ const pickProfile = Select.screen({
171
+ message: "Profile",
172
+ choices: [
173
+ { label: "library", value: "library" },
174
+ { label: "application", value: "application" },
175
+ ],
176
+ });
177
+
178
+ const profile = CliUi.prompt(pickProfile, { otherwise: "library" });
179
+ ```
180
+
181
+ `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`"):
182
+
183
+ ```ts
184
+ import { CliUi, Confirm } from "@effected/cli-kit/ui";
185
+ import { Flag } from "effect/cli";
186
+
187
+ const yes = Flag.Boolean("yes").pipe(
188
+ Flag.withFallbackPrompt(
189
+ CliUi.fallback(
190
+ CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
191
+ { flag: "yes", otherwise: false },
192
+ ),
193
+ ),
194
+ );
195
+ ```
196
+
197
+ ## Live views
198
+
199
+ `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.
200
+
201
+ ## Testing
202
+
203
+ - **`@effected/cli-kit/testing`**: `CliTest.sandbox` and `CliTest.run` spawn a built bin hermetically and return `{ exitCode, stdout, stderr }` as data. `TestTerminal` drives core's prompts.
204
+ - **`@effected/cli-kit/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-kit/ui/testing/serializer"]`, or with `expect.addSnapshotSerializer`. Snapshots are the one place a test needs `expect`, since `assert` has no snapshot form.
205
+
206
+ 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.
207
+
208
+ ## Documentation
209
+
210
+ 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).
211
+
212
+ ## License
213
+
214
+ [MIT](LICENSE)
package/Render.js ADDED
@@ -0,0 +1,280 @@
1
+ import { CliLinks } from "./CliLinks.js";
2
+ import { CliTheme } from "./CliTheme.js";
3
+ import { Glyphs } from "./Glyphs.js";
4
+ import { paintStyle } from "./internal/ansi.js";
5
+ import { underGithubActions } from "./internal/autoFormat.js";
6
+ import { CurrentDisplayPath, identity } from "./internal/displayPath.js";
7
+ import { renderAnsi } from "./internal/renderAnsi.js";
8
+ import { renderGithubLog } from "./internal/renderGithubLog.js";
9
+ import { renderMarkdown } from "./internal/renderMarkdown.js";
10
+ import { renderPlain } from "./internal/renderPlain.js";
11
+ import { Token } from "./Token.js";
12
+ import { Context, Effect } from "effect";
13
+ import { Audience, TerminalEnv } from "@effected/env";
14
+ import { CommandNeutralizer, WorkflowCommand } from "@effected/github-commands";
15
+
16
+ // src/Render.ts
17
+ /**
18
+ * The fiber-local count of open Actions log groups, by the kit-wide key, so a group `@effected/github-actions`'
19
+ * `ActionLogger.group` opened is visible here without an edge between the two packages.
20
+ */
21
+ const GroupDepth = Context.Reference(WorkflowCommand.groupDepthKey, { defaultValue: () => 0 });
22
+ /** A renderer's text, with workflow commands neutralized when the context says the runner is reading it. */
23
+ const guarded = (text, ctx) => ctx.neutralizeWorkflowCommands === true ? CommandNeutralizer.text(text) : text;
24
+ /**
25
+ * Pure renderers of a document: `(doc, context) => string`.
26
+ *
27
+ * @remarks
28
+ * A renderer has no environment and no effects, so the same document and context always give the same string.
29
+ * {@link Render.plain} is for agents; the rest of the set follows.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { Doc, Render } from "@effected/cli-kit"
34
+ *
35
+ * const doc = [Doc.heading(2, "Results"), Doc.paragraph("3 checks passed")]
36
+ * const ctx = Render.contextOf({ audience: "agent" })
37
+ *
38
+ * Render.plain(doc, ctx)
39
+ * // => "Results\n3 checks passed"
40
+ * ```
41
+ *
42
+ * @public
43
+ */
44
+ var Render = class {
45
+ constructor() {}
46
+ /**
47
+ * The {@link RenderContext} for a stream, from the services a CLI already has.
48
+ *
49
+ * @remarks
50
+ * Everything is read once, here, so the renderers stay pure:
51
+ *
52
+ * - `audience` is the `Audience` in force, so an audience flag is honoured;
53
+ * - `color`, `paint` and `glyphs` are the `CliTheme`'s for THAT stream, so redirecting stdout does not quiet
54
+ * stderr; except that an agent's `color` is `none` and its `paint` the identity, so no renderer, `ansi` included,
55
+ * writes an escape for an agent whatever the terminal could do;
56
+ * - `link` is `CliLinks.linker` over that stream's hyperlink support and the audience, so an agent never
57
+ * gets an escape and a terminal without OSC 8 gets the label;
58
+ * - `neutralizeWorkflowCommands` is set when `CurrentRuntimeEnv` says GitHub Actions (read if present, not
59
+ * required), for every audience, since the runner reads whatever is written there;
60
+ * - `width` is the option, else `TerminalEnv.width()` for a human whose stream is a terminal, and **unbounded**
61
+ * (`Infinity`) for a human whose stream is not one (`tool | grep`, `tool > out.txt`), an agent or a CI, so nothing
62
+ * a reader needs is truncated or wrapped for a terminal that is not there: a pipe has no width to honour;
63
+ * - `groupDepth` is the fiber's count of open Actions log groups (the fiber-local keyed by
64
+ * `WorkflowCommand.groupDepthKey`, which `ActionLogger.group` provides), set when it is above `0`, so a
65
+ * document printed inside a group opens no second one; see {@link RenderContext.groupDepth}.
66
+ * - `displayPath` is the option, else the run's own path display (`CliEnvOptions.displayPath`, which
67
+ * `CliEnv.layer` sets), else the identity.
68
+ *
69
+ * @param stream - the stream the output is for
70
+ * @param options - an explicit width and a path display function
71
+ */
72
+ static context = (stream, options) => Effect.gen(function* () {
73
+ const terminal = yield* TerminalEnv;
74
+ const { kind } = yield* Audience;
75
+ const theme = (yield* CliTheme).forStream(stream);
76
+ const seen = CliTheme.forAudience(theme, kind);
77
+ const links = yield* CliLinks;
78
+ const underActions = yield* underGithubActions;
79
+ const groupDepth = yield* GroupDepth;
80
+ return {
81
+ ...underActions ? { neutralizeWorkflowCommands: true } : {},
82
+ ...groupDepth > 0 ? { groupDepth } : {},
83
+ width: options?.width ?? (kind === "human" && terminal[stream].isTerminal ? terminal.width() : Number.POSITIVE_INFINITY),
84
+ audience: kind,
85
+ color: seen.color,
86
+ paint: seen.paint,
87
+ glyphs: theme.glyphs,
88
+ link: CliLinks.linker({
89
+ links,
90
+ hyperlinks: terminal[stream].hyperlinks,
91
+ audience: kind
92
+ }),
93
+ displayPath: options?.displayPath ?? (yield* CurrentDisplayPath)
94
+ };
95
+ });
96
+ /**
97
+ * A {@link RenderContext} from plain options, for a caller outside Effect, such as a test reporter or an Ink tree.
98
+ *
99
+ * @remarks
100
+ * Pure: nothing is read from the environment. The defaults are colour `none`, the identity paint, no links,
101
+ * Unicode glyphs, unbounded width and the identity `displayPath`, so a context built from an audience alone renders
102
+ * with no escape of any kind. A colour level paints with the default token styles. An `agent` is colourless and
103
+ * unlinked whatever `color` and `links` say, as in {@link Render.context}.
104
+ *
105
+ * @param options - the audience, and the colour, glyphs, width, path display, links, neutralizing, link base and
106
+ * open group depth
107
+ */
108
+ static contextOf = (options) => {
109
+ const agent = options.audience === "agent";
110
+ const color = agent ? "none" : options.color ?? "none";
111
+ const links = agent ? "off" : options.links ?? "off";
112
+ return {
113
+ width: options.width ?? Number.POSITIVE_INFINITY,
114
+ audience: options.audience,
115
+ color,
116
+ paint: color === "none" ? (_token, text) => text : (token, text) => paintStyle(Token.resolve(token), color, text),
117
+ glyphs: options.glyphs ?? Glyphs.unicode,
118
+ link: links === "off" ? (_target, label) => label : CliLinks.linker({
119
+ links,
120
+ hyperlinks: true,
121
+ audience: options.audience
122
+ }),
123
+ displayPath: options.displayPath ?? identity,
124
+ ...options.neutralizeWorkflowCommands ?? options.audience === "ci" ? { neutralizeWorkflowCommands: true } : options.neutralizeWorkflowCommands === false ? { neutralizeWorkflowCommands: false } : {},
125
+ ...options.linkBase === void 0 ? {} : { linkBase: options.linkBase },
126
+ ...options.groupDepth === void 0 ? {} : { groupDepth: options.groupDepth }
127
+ };
128
+ };
129
+ /**
130
+ * Render a document as plain text for an agent.
131
+ *
132
+ * @remarks
133
+ * There are no escape sequences of any kind, whatever the context's colour or hyperlinks allow: `paint` and
134
+ * `link` are never called, and a control character in a document's text is removed. The audience is treated as
135
+ * `agent`, so a path joins with ` > `.
136
+ *
137
+ * - A heading is its text alone, code is in backticks, and a link is its label followed by the target in
138
+ * parentheses, as `path:line:col` through `displayPath` for a file, unless the label already is the target.
139
+ * - A paragraph wraps to the width and is never truncated; a word longer than the width, such as a URL, stays
140
+ * whole on its own line.
141
+ * - A list uses `- ` items, and past its cap the overflow row. A table is aligned text columns with a rule under
142
+ * the header; a short row is padded with empty cells, a cell holding line breaks shows its first line and an
143
+ * ellipsis, and cells are truncated only when the table is wider than the context, widest column first. A tree uses the glyph set's tree segments.
144
+ * - A collapsible is its title and the indented body, a callout its upper-case kind and the body, a code block
145
+ * four-space indented, and a diff `- expected` lines then `+ received` lines, the cap limiting each side.
146
+ * - Counts take their total and their visible counters from {@link Doc.total} and {@link Doc.visibleCounters}.
147
+ * Inline gives `3/5 passed, 1 failed (1.2s)`: the first counter is the headline and shows its share of the
148
+ * total, unless `share` is `false`. Columns gives aligned label and number pairs, and row one line of cells.
149
+ * - A link with `suffix: false` never has its target after the label, and one with `suffix: true` always does.
150
+ * - Verbatim text is its lines exactly, each indented by `indent` spaces, never wrapped; an annotation is nothing.
151
+ * - Strong and emphasised content is its text; a file is its display path, unlinked. `Lines` are one line per entry,
152
+ * a `Line` with `truncate` is cut to the width with the ellipsis, and diff text is its lines as given, the cap
153
+ * followed by `… N more lines`. A counts table is a table with a column per counter key and the total row last,
154
+ * and a counts `suffix` follows the duration.
155
+ * - A compact list has no blank lines inside an item. A `style: "pipe"` table is istanbul's shape: a rule of dashes
156
+ * meeting at `|` above and below the header and at the end, and cells joined with ` | `.
157
+ * - Top-level blocks are consecutive lines; a section separates its title and children with blank lines.
158
+ *
159
+ * @param doc - the document
160
+ * @param ctx - where the output is going
161
+ */
162
+ static plain = (doc, ctx) => guarded(renderPlain(doc, ctx), ctx);
163
+ /**
164
+ * Render a document for a person: the same layout as {@link Render.plain}, painted and linked.
165
+ *
166
+ * @remarks
167
+ * The context's `paint` and `link` do the styling, so a context at colour `none` with links off gives exactly
168
+ * what `plain` gives, apart from two things: code has no backticks (it is painted `accent` instead), and a path
169
+ * joins with the audience's separator (`›` for a person) rather than ` > `. Tokens:
170
+ *
171
+ * - headings, section and collapsible titles, and table headers are `emphasis`; the rule under a header, tree
172
+ * lines and overflow rows are `muted`;
173
+ * - a status glyph takes its definition's token, and a diff's `-` lines are `failure` and `+` lines `success`;
174
+ * - a callout's label takes its kind's token (`note` info, `tip` success, `important` accent, `warning` warning,
175
+ * `caution` error), and a counter the token of its status, with the qualifier, the duration and the suffix `muted`.
176
+ * A `Counts` with `paint: "none"` paints none of it, and with `paint: "glyph"` only a status glyph.
177
+ * - strong content is bold and emphasised content italic, over any token it has; in diff text a `+` line is
178
+ * `success` and a `-` line `failure`; a counts table's counts take their status's token.
179
+ *
180
+ * A link goes through `ctx.link`, which makes an OSC 8 hyperlink only when the policy allows it. When it does
181
+ * not (it returns the label unchanged), the target follows the label in parentheses, muted, as in plain text.
182
+ *
183
+ * Text is cut and wrapped before it is painted, so a colour or a hyperlink is never cut in half, and a table
184
+ * cut to the width keeps the colour of what remains. Tables are plain aligned columns, never box drawing:
185
+ * box drawing costs two columns of every row for nothing a rule and the padding do not already say, and it
186
+ * cannot be matched to plain text.
187
+ *
188
+ * @param doc - the document
189
+ * @param ctx - where the output is going
190
+ */
191
+ static ansi = (doc, ctx) => guarded(renderAnsi(doc, ctx), ctx);
192
+ /**
193
+ * Render a document as GitHub-flavoured markdown, for a step summary or a file.
194
+ *
195
+ * @remarks
196
+ * There is no ANSI and no OSC 8 (`paint` and `link` are never called), and the width does not apply: a reader
197
+ * wraps. Everything a document carries as text is escaped so that it cannot become markdown: the characters
198
+ * that mean something, `|` everywhere so text can never form a table, the marker at the start of a line (a
199
+ * heading, bullet, setext underline or ordered item) and the start of an autolink (a URL scheme or `www.`). An
200
+ * email address is not escaped: a reader may make a `mailto:` link of it, which is harmless. A leading indent is
201
+ * dropped, since markdown would read it as code.
202
+ *
203
+ * Under GitHub Actions (`neutralizeWorkflowCommands`) the same neutralizing applies, since markdown can be printed
204
+ * to the log. Markdown escapes `[` in text, so `##[` cannot appear outside code and the headings are untouched
205
+ * (a bare `##` is not a command); code spans and blocks, which are not escaped, get the blank marker, which can
206
+ * also land inside code or table text where it would otherwise have formed a command.
207
+ *
208
+ * GitHub also turns `@user`, `@org/team`, `#123` and commit SHAs in rendered markdown into mentions and references.
209
+ * Nothing here escapes them: in a step summary they do not notify, but markdown posted as a comment could ping
210
+ * whoever the text names.
211
+ *
212
+ * - A heading is `#` repeated by its level. A section's title is a heading of level 2 for a section at the top,
213
+ * one deeper for each section nested inside it, to level 6.
214
+ * - A table is a GFM pipe table: `|` is `\|` in a cell and a line break in a cell is `<br>`. A short row is
215
+ * padded and a long one widens the table. With no header, the header row is empty.
216
+ * - A collapsible is `<details><summary>title</summary>`, a blank line, the body as markdown, a blank line and
217
+ * `</details>`. The title is HTML, so it is HTML-escaped and plain.
218
+ * - A callout is a quoted `[!KIND]` followed by its body. A code block is a fence longer than any backtick run it
219
+ * holds, and a diff a `diff` fence of `-` and `+` lines.
220
+ * - A link is `[label](url)` when it has a URL a reader can follow: an `http`, `https`, `mailto`, `file` or
221
+ * `vscode` URL, or a relative one. A file link has one when its path is absolute (`file://`). Otherwise, such as
222
+ * for a `javascript:` URL or a relative file path, it is the label followed by the target in inline code, as
223
+ * `path:line:col` for a file.
224
+ * - A list is bullets, a tree a nested bullet list under its root label, and overflow rows paragraphs after what
225
+ * they cap. Counts inline is a paragraph, columns a list of `label: n` and row a one-row table of the counter
226
+ * labels over their numbers, every header named (the duration as `duration`), with the label above the table and
227
+ * the qualifier and suffix below it.
228
+ * - Verbatim text is a fenced code block, so its indentation survives; an annotation is nothing.
229
+ * - Strong content is `**…**` and emphasised `*…*`, which GFM reads inside a word too, with the spaces at a run's
230
+ * edges kept outside the markers. `Lines` are one paragraph with a hard break between entries, an empty entry
231
+ * between two others an empty line (one at either end is dropped); a `Line` is one
232
+ * paragraph; diff text a `diff` fence; a counts table a pipe table; a file is its display path as text.
233
+ * - With `linkBase`, a file link goes to the base and the display path, plus `#L<line>`, in place of `file://`. A
234
+ * display path that is absolute or climbs out with `..` is not under the base, so its link has no URL form.
235
+ * - A compact list item joins its parts with no blank line, putting a hard break where two paragraphs would merge.
236
+ * A row of counts names its duration column `duration`.
237
+ *
238
+ * @param doc - the document
239
+ * @param ctx - where the output is going; its glyph set, audience and `displayPath` are used
240
+ */
241
+ static markdown = (doc, ctx) => guarded(renderMarkdown(doc, ctx), ctx);
242
+ /**
243
+ * Render a document for a GitHub Actions log.
244
+ *
245
+ * @remarks
246
+ * Everything is what {@link Render.plain} renders, except an annotation, which is one workflow command
247
+ * (`::error file=…,line=…::message`), and a collapsible that starts a line, which is a group: `::group::title`, its
248
+ * body, `::endgroup::`. An annotation is a command at the top level, as a top-level section's child, and as a direct
249
+ * child of a group's body; anywhere deeper (inside a list, a callout, or a section within a group) it is nothing,
250
+ * as in plain. Its message and properties are escaped, so no text can end
251
+ * the command or start another, and the kit's own command is never neutralized, though a `##[` in its message,
252
+ * title or file is. That is a top-level collapsible, or one that is a direct child of a
253
+ * top-level section. GitHub does not nest groups, so a collapsible inside a group, or inside a list or callout
254
+ * (where it would not start a line), keeps plain's rendering: its title on a line and its body indented.
255
+ *
256
+ * A group already open around the output counts the same way. When the context's `groupDepth` is above `0`
257
+ * ({@link RenderContext.groupDepth}, which `Render.context` reads from the fiber-local `ActionLogger.group` in
258
+ * `@effected/github-actions` provides, by the key `WorkflowCommand.groupDepthKey`), a top-level collapsible keeps
259
+ * plain's rendering too, so a document printed inside an `ActionLogger.group` does not open a second group, which
260
+ * would end the first at the runner. The depth is the context's, read when the context was built. What no depth
261
+ * can fix is two fibers writing groups at once: the runner has one log stream, so concurrent sibling groups, this
262
+ * renderer's or `ActionLogger`'s, interleave in it. Render and print a document from the fiber that owns the
263
+ * stream, not from a fiber racing another group.
264
+ *
265
+ * The runner has two command parsers, and a line is a command if either accepts it: after its leading whitespace it
266
+ * starts with `::`, or `##[` occurs ANYWHERE in it (a bare `##` is not one). A document's text must not be able to
267
+ * do that (`::add-mask::`, `::error::`, `##[error]`), so such a `::` line gets a braille pattern blank (U+2800) in
268
+ * front, which the runner neither trims nor skips, and every `##[` gets one before the `[`. The text is
269
+ * otherwise unchanged. A
270
+ * group's title is a command's data, so its `%`, CR and LF are escaped and a `##[` in it is neutralized. Lines are split at CR, LF and CRLF before
271
+ * that check, as the runner splits them. There is no ANSI and `paint` and `link` are never called, and the audience
272
+ * is treated as `agent`, as `plain` does.
273
+ *
274
+ * @param doc - the document
275
+ * @param ctx - where the output is going; the width, glyph set and `displayPath` are used as plain uses them
276
+ */
277
+ static githubLog = (doc, ctx) => renderGithubLog(doc, ctx);
278
+ };
279
+
280
+ export { Render };
@@ -0,0 +1,47 @@
1
+ import { formatIssue } from "./internal/format.js";
2
+
3
+ // src/SchemaIssueRenderer.ts
4
+ /**
5
+ * Turns a `SchemaIssue` tree into lines a user can act on.
6
+ *
7
+ * @remarks
8
+ * A decode failure arrives as a structured tree; a person needs
9
+ * `unknown key at groups.g.cleanup.rulesetz`. The formatters core ships for
10
+ * this live on `SchemaIssue` rather than on `SchemaError` or `Schema`, and
11
+ * `SchemaError.message` does not use them, so printing the error alone does not
12
+ * give these lines.
13
+ *
14
+ * The lines and `CliFailure`'s tree are two views of the same rejected values (`internal/format`), so a schema
15
+ * failure in the default report and these lines never disagree.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { SchemaIssueRenderer } from "@effected/cli-kit"
20
+ * import { Effect, Schema } from "effect"
21
+ *
22
+ * const result = Schema.decodeUnknownEffect(MySchema)(input, {
23
+ * onExcessProperty: "error",
24
+ * errors: "all",
25
+ * })
26
+ *
27
+ * const reported = result.pipe(
28
+ * Effect.catchTag("SchemaError", (error) =>
29
+ * Effect.forEach(SchemaIssueRenderer.render(error.issue), (line) => Effect.logError(` ${line}`)),
30
+ * ),
31
+ * )
32
+ * ```
33
+ *
34
+ * @public
35
+ */
36
+ var SchemaIssueRenderer = class {
37
+ constructor() {}
38
+ /**
39
+ * One line per rejected value, deepest path last.
40
+ *
41
+ * @param issue - a `SchemaIssue` tree, or any value
42
+ * @returns the lines, or empty when `issue` is not an issue tree
43
+ */
44
+ static render = (issue) => formatIssue(issue);
45
+ };
46
+
47
+ export { SchemaIssueRenderer };