@effected/env 0.1.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/Audience.js ADDED
@@ -0,0 +1,94 @@
1
+ import { CurrentRuntimeEnv } from "./RuntimeEnv.js";
2
+ import { Config, Context, Effect, Layer, Option } from "effect";
3
+
4
+ //#region src/Audience.ts
5
+ const KINDS = [
6
+ "human",
7
+ "agent",
8
+ "ci"
9
+ ];
10
+ /**
11
+ * Who the output is for, decided once.
12
+ *
13
+ * @remarks
14
+ * Precedence: a valid override environment variable, then an agent, then CI, then a human, so an agent inside a
15
+ * CI job gets agent output. A human typing a command inside an agent-detected shell is still detected as an
16
+ * agent, never refused: the override variable flips it back. The shape is one immutable value, so the service is
17
+ * provided with `Layer.succeed` and there is no `Layer.mock` to reach for.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { Audience, CurrentRuntimeEnv } from "@effected/env"
22
+ * import { Effect, Layer } from "effect"
23
+ *
24
+ * const AudienceLive = Audience.layer({ envVar: "MYTOOL_AUDIENCE" }).pipe(Layer.provide(CurrentRuntimeEnv.layer))
25
+ *
26
+ * const program = Effect.gen(function* () {
27
+ * const { kind, source } = yield* Audience
28
+ * return `${kind} (${source})`
29
+ * }).pipe(Effect.provide(AudienceLive))
30
+ * ```
31
+ *
32
+ * @public
33
+ */
34
+ var Audience = class Audience extends Context.Service()("@effected/env/Audience") {
35
+ /**
36
+ * Decide the audience from `CurrentRuntimeEnv`, and from the override variable named by `options.envVar`.
37
+ *
38
+ * @remarks
39
+ * The variable is read through `Config`, lower-cased and matched against `human`, `agent` and `ci`. An empty
40
+ * or absent variable is unset. An invalid value logs one warning through `Effect.logWarning` and falls back to
41
+ * detection; it never fails the run. That warning is once per layer build, so building the layer a second time
42
+ * warns again. The warning goes through `Effect.logWarning`, and Effect's default logger writes to stdout unless
43
+ * `References.LogToStderr` is set: an MCP server or any stdio-sensitive host must route logs to stderr (the `cli`
44
+ * package's `CliLogger` does). Without `options.envVar` the audience is always detected. A layer-returning function
45
+ * mints a fresh layer per call: call it once and bind the result to a constant.
46
+ *
47
+ * @param options - `envVar` names the override variable
48
+ */
49
+ static layer(options) {
50
+ return Layer.effect(Audience, Effect.gen(function* () {
51
+ const runtimeEnv = yield* CurrentRuntimeEnv;
52
+ const detected = {
53
+ kind: Audience.detect(runtimeEnv),
54
+ source: "detected"
55
+ };
56
+ const envVar = options?.envVar;
57
+ if (envVar === void 0) return detected;
58
+ const raw = yield* Config.option(Config.String(envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
59
+ if (Option.isNone(raw) || raw.value === "") return detected;
60
+ const value = raw.value.toLowerCase();
61
+ const kind = KINDS.find((candidate) => candidate === value);
62
+ if (kind !== void 0) return {
63
+ kind,
64
+ source: "override"
65
+ };
66
+ yield* Effect.logWarning(`${envVar}=${raw.value} is not one of ${KINDS.join("|")}; ignoring it`);
67
+ return detected;
68
+ }));
69
+ }
70
+ /**
71
+ * A fixed audience that touches neither `CurrentRuntimeEnv` nor `Config`.
72
+ *
73
+ * @remarks
74
+ * `source` defaults to `override`, since a test that fixes the kind has decided it. Pass `detected` to test a
75
+ * layer stacked on top, such as a CLI flag that only applies when the environment variable did not decide, or
76
+ * `flag` to test a consumer of that layer's result.
77
+ *
78
+ * @param kind - the audience to fix
79
+ * @param source - what decided it: `override` (the default), `detected`, or `flag`
80
+ */
81
+ static layerTest = (kind, source = "override") => Layer.succeed(Audience, {
82
+ kind,
83
+ source
84
+ });
85
+ /**
86
+ * The audience a {@link RuntimeEnv} implies, ignoring any override: an agent, else CI, else a human.
87
+ *
88
+ * @param env - the runtime snapshot to decide from
89
+ */
90
+ static detect = (env) => Option.isSome(env.agent) ? "agent" : Option.isSome(env.ci) ? "ci" : "human";
91
+ };
92
+
93
+ //#endregion
94
+ export { Audience };
package/EnvOverride.js ADDED
@@ -0,0 +1,109 @@
1
+ import { Audience } from "./Audience.js";
2
+ import { Config, ConfigProvider, Effect, Option } from "effect";
3
+
4
+ //#region src/EnvOverride.ts
5
+ const isProvider = (source) => typeof source.load === "function";
6
+ /**
7
+ * Reads an environment variable that picks a mode within an audience.
8
+ *
9
+ * @remarks
10
+ * The kit never learns a consumer's literals: the caller passes, per audience, the values it accepts.
11
+ *
12
+ * @public
13
+ */
14
+ var EnvOverride = class EnvOverride {
15
+ constructor() {}
16
+ /**
17
+ * Read `options.envVar` and say what happened, without logging.
18
+ *
19
+ * @remarks
20
+ * `accepted` is the literal the current audience accepts (matching is case-insensitive and yields the
21
+ * literal), `rejected` is a set value it does not, with the value as written, the audience and the literals
22
+ * that audience accepts, so the caller owns the wording, the stream and any once-per-run dedupe. Unset and
23
+ * empty are neither. At most one of the two is `Some`. {@link EnvOverride.read} is the logging convenience.
24
+ *
25
+ * The variable is read from the ambient `ConfigProvider` unless `source` is given: a record (read fresh on every
26
+ * call, so a host passing `process.env` sees each change, and an `undefined` value is unset) or a `ConfigProvider`
27
+ * of its own.
28
+ *
29
+ * Without `source`, every read takes the `ConfigProvider` of the fiber that runs it, so a reader built once at
30
+ * module level stays testable: a test provides its own provider around the read.
31
+ *
32
+ * ```ts
33
+ * const consoleMode = EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts })
34
+ * // in a test:
35
+ * consoleMode.pipe(
36
+ * Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ MYTOOL_CONSOLE: "silent" })),
37
+ * )
38
+ * ```
39
+ *
40
+ * `source` is for a long-lived host that must re-read a record on each call (`process.env` in a watch-mode runner,
41
+ * where core's default provider snapshots the environment once). It is fixed when the options object is built, so a
42
+ * host that wants it AND testability builds the options inside a function that takes the source:
43
+ *
44
+ * ```ts
45
+ * const consoleModeFrom = (source: Readonly<Record<string, string | undefined>>) =>
46
+ * EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts, source })
47
+ * // production: consoleModeFrom(process.env); a test: consoleModeFrom({ MYTOOL_CONSOLE: "silent" })
48
+ * ```
49
+ *
50
+ * @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts; `source`, if
51
+ * given, is where the variable is read in place of the ambient environment: a long-lived host's record, re-read
52
+ * on every call, or a `ConfigProvider`; omit it to read the fiber's `ConfigProvider`, which a test provides
53
+ */
54
+ static readResult(options) {
55
+ return Effect.gen(function* () {
56
+ const { kind } = yield* Audience;
57
+ const config = Config.option(Config.String(options.envVar));
58
+ const { source } = options;
59
+ const raw = source === void 0 ? yield* config.pipe(Effect.orElseSucceed(() => Option.none())) : yield* config.parse(isProvider(source) ? source : ConfigProvider.fromEnvRecord({ ...source })).pipe(Effect.orElseSucceed(() => Option.none()));
60
+ if (Option.isNone(raw) || raw.value === "") return {
61
+ audience: kind,
62
+ accepted: Option.none(),
63
+ rejected: Option.none()
64
+ };
65
+ const accepts = options.accepts[kind];
66
+ const match = accepts.find((literal) => literal.toLowerCase() === raw.value.toLowerCase());
67
+ if (match !== void 0) return {
68
+ audience: kind,
69
+ accepted: Option.some(match),
70
+ rejected: Option.none()
71
+ };
72
+ return {
73
+ audience: kind,
74
+ accepted: Option.none(),
75
+ rejected: Option.some({
76
+ value: raw.value,
77
+ audience: kind,
78
+ accepts
79
+ })
80
+ };
81
+ });
82
+ }
83
+ /**
84
+ * Read `options.envVar` and accept it only when the current audience accepts it.
85
+ *
86
+ * @remarks
87
+ * Matching is case-insensitive and yields the accepted literal, so the result narrows to the union of every
88
+ * audience's literals. An unset or empty variable is `None`. A value the current audience does not accept logs
89
+ * one warning through `Effect.logWarning`, naming the accepted values, and is `None`; it never fails the run.
90
+ * The warning is once per read, so reading again warns again. It goes through `Effect.logWarning`, and Effect's
91
+ * default logger writes to stdout unless `References.LogToStderr` is set: an MCP server or any stdio-sensitive
92
+ * host must route logs to stderr (the `cli` package's `CliLogger` does).
93
+ *
94
+ * @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts
95
+ */
96
+ static read(options) {
97
+ return Effect.gen(function* () {
98
+ const { accepted, rejected } = yield* EnvOverride.readResult(options);
99
+ if (Option.isSome(rejected)) {
100
+ const { value, audience, accepts } = rejected.value;
101
+ yield* Effect.logWarning(`${options.envVar}=${value} is not accepted for the ${audience} audience (accepts ${accepts.join("|")}); ignoring it`);
102
+ }
103
+ return accepted;
104
+ });
105
+ }
106
+ };
107
+
108
+ //#endregion
109
+ export { EnvOverride };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @effected/env
2
+
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fenv?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/env)
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
+ Effect-native environment detection for any front end of a tool: **who is running it** (an AI agent, a CI job, or neither), **what the terminal can do** (colour level, OSC 8 hyperlinks and width, per stream), and **who the output is for** (a human, an agent or CI). Every answer is read once through `Config`, never from `process`, and every service has a `layerTest`, so a test sets the environment it wants without touching a global.
9
+
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
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
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. Full
18
+ > policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
19
+
20
+ ## Why @effected/env
21
+
22
+ Every tool that prints ends up asking these questions, usually by reading `process.env` and `process.stdout.isTTY` wherever the answer is needed. The answers then drift (the help text and the report disagree about colour), they cannot be tested without mutating globals, and the rules are subtle: `FORCE_COLOR` beats `NO_COLOR`, an empty `NO_COLOR` means unset, `TERM=dumb` is a terminal that cannot move the cursor, a Windows console sets no `TERM` at all yet draws truecolor, and an agent running inside CI should get agent output.
23
+
24
+ This package makes each decision once, as a service, from rules taken from established sources: colour depth from Node's own `getColorDepth` (with `OS=Windows_NT` standing in for its platform check, which gives a Windows terminal truecolor as Node does from Windows 10 build 14931), hyperlink support from std-osc8's terminal table, and agent detection from std-env's table.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install @effected/env effect
30
+ ```
31
+
32
+ ```bash
33
+ pnpm add @effected/env effect
34
+ ```
35
+
36
+ Requires Node.js >=24.11.0. `effect` v4 is the only peer. There are no runtime dependencies, no platform import and no `@effected/*` edge, so it runs unchanged on Node, Bun and Deno.
37
+
38
+ 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.
39
+
40
+ ## Quick start
41
+
42
+ Decide the audience once, as a service, and let anything that prints ask it:
43
+
44
+ ```ts
45
+ import { Audience, CurrentRuntimeEnv } from "@effected/env";
46
+ import { Effect, Layer } from "effect";
47
+
48
+ // A valid MYTOOL_AUDIENCE beats detection; then an agent, then CI, then a human.
49
+ const AudienceLive = Audience.layer({ envVar: "MYTOOL_AUDIENCE" }).pipe(Layer.provide(CurrentRuntimeEnv.layer));
50
+
51
+ const program = Effect.gen(function* () {
52
+ const audience = yield* Audience;
53
+ if (audience.kind === "agent") {
54
+ // plain text, no escapes
55
+ }
56
+ });
57
+
58
+ Effect.runPromise(program.pipe(Effect.provide(AudienceLive)));
59
+ ```
60
+
61
+ What the terminal can do comes from `TerminalEnv`, which reads core's `Stdio` and `Terminal` from your platform layer:
62
+
63
+ ```ts
64
+ import { TerminalEnv } from "@effected/env";
65
+ import { NodeServices } from "@effect/platform-node";
66
+ import { Effect } from "effect";
67
+
68
+ const describe = Effect.gen(function* () {
69
+ const terminal = yield* TerminalEnv;
70
+ return `${terminal.stdout.color} colour, ${terminal.width()} columns`;
71
+ });
72
+
73
+ Effect.runPromise(describe.pipe(Effect.provide(TerminalEnv.layer()), Effect.provide(NodeServices.layer)));
74
+ ```
75
+
76
+ In a test, fix the answers instead:
77
+
78
+ ```ts
79
+ import { Audience, TerminalEnv } from "@effected/env";
80
+ import { Layer, Option } from "effect";
81
+
82
+ const Environment = Layer.mergeAll(
83
+ Audience.layerTest("agent"),
84
+ TerminalEnv.layerTest({ stdout: { isTerminal: true, color: "256", columns: Option.some(120) } }),
85
+ );
86
+ ```
87
+
88
+ ## What it exports
89
+
90
+ - **`RuntimeEnv`**: a schema-class snapshot of the agent, the CI and the terminal program, persistable as plain JSON. `RuntimeEnv.fromRecord(env)` builds one as a pure function.
91
+ - **`CurrentRuntimeEnv`**: that snapshot as a service. `layer` reads the ambient `ConfigProvider` once; `layerFrom(source)` reads a record or provider of your own, fresh on every use, for a long-lived host; `layerTest(overrides)` fixes it.
92
+ - **`TerminalEnv`**: per-stream `isTerminal`, colour level (`none`, `basic`, `256`, `truecolor`), `hyperlinks` and `columns`, plus `width(fallback)`. `layer()` needs `Stdio` and `Terminal`; `layerStdio()` needs only `Stdio`; `colorLevel("stdout")` decides colour alone.
93
+ - **`Audience`**: `human`, `agent` or `ci`, and the `source` that decided it. `layer({ envVar })` decides from `CurrentRuntimeEnv` and an override variable you name.
94
+ - **`EnvOverride`**: reads a variable that picks a mode *within* an audience, accepting only the literals you list per audience. `readResult` reports without logging; `read` logs one warning for a rejected value.
95
+
96
+ ## Documentation
97
+
98
+ The guides and the full API reference are at [effected.spencerbeg.gs/env](https://effected.spencerbeg.gs/env). [`@effected/cli`](https://www.npmjs.com/package/@effected/cli) builds its whole presentation layer on these services.
99
+
100
+ ## License
101
+
102
+ [MIT](LICENSE)
package/RuntimeEnv.js ADDED
@@ -0,0 +1,117 @@
1
+ import { detectAgent, detectCi } from "./internal/agentCi.js";
2
+ import { normalizeEnv, readEnv } from "./internal/envRecord.js";
3
+ import { detectOsc8 } from "./internal/osc8/detect.js";
4
+ import { allKeys } from "./internal/keys.js";
5
+ import { ConfigProvider, Context, Effect, Layer, Option, Schema } from "effect";
6
+
7
+ //#region src/RuntimeEnv.ts
8
+ /**
9
+ * An `Option` field that encodes `None` as `null` and decodes when its key is absent, so a persisted snapshot keeps
10
+ * decoding after a field is added.
11
+ */
12
+ const optionField = (schema) => Schema.OptionFromNullOr(schema).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(Option.none())));
13
+ /**
14
+ * A snapshot of who is running the program: the agent, the CI, and the terminal.
15
+ *
16
+ * @remarks
17
+ * Each field is an `Option` in memory and `null` when absent on the wire, so the snapshot persists as plain JSON
18
+ * through `Schema.fromJsonString(RuntimeEnv)`. Every field decodes when its key is absent, so a persisted snapshot
19
+ * keeps decoding after a field is added. Built from `Config` only (no stream is consulted), so it is safe inside a
20
+ * stdio MCP server.
21
+ *
22
+ * @public
23
+ */
24
+ var RuntimeEnv = class RuntimeEnv extends Schema.Class("@effected/env/RuntimeEnv")({
25
+ /**
26
+ * The AI agent running the process, as its family (`claude` for Claude Code, whatever `AI_AGENT` says it is
27
+ * beyond that), or `None`.
28
+ */
29
+ agent: optionField(Schema.String),
30
+ /** The CI the process runs in: {@link CiName}, so a consumer can match it exhaustively, or `None`. */
31
+ ci: optionField(Schema.Literals(["github-actions", "generic"])),
32
+ /** The identified terminal program and its version when it exposes one, or `None`. */
33
+ terminal: optionField(Schema.Struct({
34
+ name: Schema.String,
35
+ version: optionField(Schema.String)
36
+ }))
37
+ }) {
38
+ /**
39
+ * The snapshot of an environment record, as a pure function: no `Config`, no `process`, no service.
40
+ *
41
+ * @remarks
42
+ * It is what {@link CurrentRuntimeEnv.layer} computes from the variables it reads, so the two agree on the same
43
+ * record. An `undefined` or empty value reads as unset, under every caller. Use it where a service is in the
44
+ * way: a long-lived host that holds its own environment record, or a renderer with no Effect context.
45
+ *
46
+ * @param env - variable name to value
47
+ */
48
+ static fromRecord(env) {
49
+ const clean = normalizeEnv(env);
50
+ return RuntimeEnv.make({
51
+ agent: detectAgent(clean),
52
+ ci: detectCi(clean),
53
+ terminal: detectOsc8(clean, false, false).terminal
54
+ });
55
+ }
56
+ };
57
+ /**
58
+ * The {@link RuntimeEnv} of the running process, as a service.
59
+ *
60
+ * @remarks
61
+ * The service's whole shape is one immutable value, so it is provided with `Layer.succeed` rather than mocked.
62
+ * `layer` reads the ambient `ConfigProvider` once when it is built; `layerTest` is the only way a test changes the
63
+ * environment.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * import { CurrentRuntimeEnv } from "@effected/env"
68
+ * import { Effect, Option } from "effect"
69
+ *
70
+ * const program = Effect.gen(function* () {
71
+ * const env = yield* CurrentRuntimeEnv
72
+ * return Option.getOrElse(env.agent, () => "no agent")
73
+ * }).pipe(Effect.provide(CurrentRuntimeEnv.layerTest({ agent: Option.some("claude") })))
74
+ * ```
75
+ *
76
+ * @public
77
+ */
78
+ var CurrentRuntimeEnv = class CurrentRuntimeEnv extends Context.Service()("@effected/env/CurrentRuntimeEnv") {
79
+ /**
80
+ * Reads the environment through `Config` once, when the layer is built. Requires nothing: the provider is read
81
+ * from the ambient `ConfigProvider`.
82
+ *
83
+ * @remarks
84
+ * Two things make this a frozen read, and a long-lived host (an MCP server, a watch-mode runner) trips on both.
85
+ * Core's default `ConfigProvider.fromEnv()` snapshots `process.env` once per process, so a change after the
86
+ * first read is never seen; and this is one static layer, memoized by reference, so two consumers that provide
87
+ * different providers in one graph share the first snapshot. Use {@link CurrentRuntimeEnv.layerFrom}, which is
88
+ * a fresh layer per call and per use, or provide a fresh `ConfigProvider` for every read.
89
+ */
90
+ static layer = Layer.effect(this, Effect.map(readEnv(allKeys), (env) => RuntimeEnv.fromRecord(env)));
91
+ /**
92
+ * A snapshot of an explicit source: an environment record, or a `ConfigProvider` read instead of the ambient
93
+ * one.
94
+ *
95
+ * @remarks
96
+ * Each call returns a new layer, and the layer is `Layer.fresh`, so it is built again for every use rather
97
+ * than shared through the build's memo: two calls with different sources in one graph see different values,
98
+ * and a provider is read again each time the layer is used. A record is read when the layer is built, never
99
+ * at the call.
100
+ *
101
+ * @param source - a variable-name-to-value record, or a `ConfigProvider`
102
+ */
103
+ static layerFrom = (source) => Layer.fresh(Layer.effect(CurrentRuntimeEnv, typeof source.load === "function" ? Effect.map(readEnv(allKeys).pipe(Effect.provideService(ConfigProvider.ConfigProvider, source)), (env) => RuntimeEnv.fromRecord(env)) : Effect.sync(() => RuntimeEnv.fromRecord(source))));
104
+ /**
105
+ * A fixed snapshot that never touches `Config`: every field is `None` unless `overrides` sets it.
106
+ *
107
+ * @param overrides - the fields to set
108
+ */
109
+ static layerTest = (overrides = {}) => Layer.succeed(CurrentRuntimeEnv, RuntimeEnv.make({
110
+ agent: overrides.agent ?? Option.none(),
111
+ ci: overrides.ci ?? Option.none(),
112
+ terminal: overrides.terminal ?? Option.none()
113
+ }));
114
+ };
115
+
116
+ //#endregion
117
+ export { CurrentRuntimeEnv, RuntimeEnv };
package/TerminalEnv.js ADDED
@@ -0,0 +1,144 @@
1
+ import { readEnv } from "./internal/envRecord.js";
2
+ import { colorDepth, colorKeys } from "./internal/colorDepth.js";
3
+ import { detectOsc8 } from "./internal/osc8/detect.js";
4
+ import { allKeys } from "./internal/keys.js";
5
+ import { Context, Effect, Layer, Option, Stdio, Terminal } from "effect";
6
+
7
+ //#region src/TerminalEnv.ts
8
+ const make = (env, isTTY, columns) => {
9
+ const links = detectOsc8(env, isTTY.stdout, isTTY.stderr);
10
+ const fromEnv = /^\d+$/.test(env.COLUMNS ?? "") ? Number(env.COLUMNS) : 0;
11
+ return {
12
+ stdinIsTerminal: isTTY.stdin,
13
+ stdout: {
14
+ isTerminal: isTTY.stdout,
15
+ color: colorDepth(env, isTTY.stdout),
16
+ hyperlinks: links.stdout,
17
+ columns
18
+ },
19
+ stderr: {
20
+ isTerminal: isTTY.stderr,
21
+ color: colorDepth(env, isTTY.stderr),
22
+ hyperlinks: links.stderr,
23
+ columns
24
+ },
25
+ width: (fallback = 80) => Option.getOrElse(columns, () => fromEnv || fallback)
26
+ };
27
+ };
28
+ /** Read the TTY facts from `Stdio` and the environment from `Config`, with the width the caller already knows. */
29
+ const snapshot = (options, columns) => Effect.gen(function* () {
30
+ const stdio = yield* Stdio.Stdio;
31
+ const stdin = yield* stdio.stdinIsTerminal;
32
+ const stdout = yield* stdio.stdoutIsTerminal;
33
+ const stderr = yield* options?.stderrIsTerminal ?? Effect.succeed(stdout);
34
+ const env = yield* readEnv([...allKeys, "COLUMNS"]);
35
+ return make(env, {
36
+ stdin,
37
+ stdout,
38
+ stderr
39
+ }, columns);
40
+ });
41
+ /** The quiet terminal {@link TerminalEnv.layerTest} starts from: not a terminal, no colour, no links, no width. */
42
+ const quiet = {
43
+ isTerminal: false,
44
+ color: "none",
45
+ hyperlinks: false,
46
+ columns: Option.none()
47
+ };
48
+ /**
49
+ * What the terminal can do: per-stream colour level, hyperlink support and columns, and the width to lay out at.
50
+ *
51
+ * @remarks
52
+ * `layer` reads the environment through `Config` and the TTY state through `Stdio` and `Terminal` once, when it
53
+ * is built. `layerTest` is the only way a test changes it, and defaults to a quiet terminal so a test opts into
54
+ * colour. `colorLevel` needs `Stdio` alone, so a caller that only decides colour never requires `Terminal`.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { TerminalEnv } from "@effected/env"
59
+ * import { Effect, Option } from "effect"
60
+ *
61
+ * const program = Effect.gen(function* () {
62
+ * const terminal = yield* TerminalEnv
63
+ * return { color: terminal.stdout.color, width: terminal.width() }
64
+ * }).pipe(Effect.provide(TerminalEnv.layerTest({ stdout: { color: "256", columns: Option.some(100) } })))
65
+ * ```
66
+ *
67
+ * @public
68
+ */
69
+ var TerminalEnv = class TerminalEnv extends Context.Service()("@effected/env/TerminalEnv") {
70
+ /**
71
+ * Build the snapshot from `Stdio`, `Terminal` and the ambient `ConfigProvider`.
72
+ *
73
+ * @remarks
74
+ * `Stdio` reports only stdout, so stderr mirrors it unless `options.stderrIsTerminal` supplies its own answer.
75
+ * A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
76
+ *
77
+ * @param options - `stderrIsTerminal` overrides the stderr TTY check
78
+ */
79
+ static layer(options) {
80
+ return Layer.effect(TerminalEnv, Effect.gen(function* () {
81
+ const width = yield* (yield* Terminal.Terminal).columns;
82
+ return yield* snapshot(options, width > 0 ? Option.some(width) : Option.none());
83
+ }));
84
+ }
85
+ /**
86
+ * The snapshot from `Stdio` and the ambient `ConfigProvider` alone: it never requires or builds `Terminal`, and
87
+ * reports no columns.
88
+ *
89
+ * @remarks
90
+ * Everything else is what {@link TerminalEnv.layer} reports: the TTY facts come from `Stdio`, the colour and
91
+ * hyperlink decisions from the environment, and `width()` falls back to `COLUMNS`, then the fallback, since no
92
+ * terminal width is known. Use it where building the platform `Terminal` has a cost, for example a long-lived
93
+ * host, where `NodeTerminal` listens on `process.stdin`. A layer-returning function mints a fresh layer per
94
+ * call: call it once and bind the result to a constant.
95
+ *
96
+ * @param options - `stderrIsTerminal` overrides the stderr TTY check
97
+ */
98
+ static layerStdio(options) {
99
+ return Layer.effect(TerminalEnv, snapshot(options, Option.none()));
100
+ }
101
+ /**
102
+ * A fixed snapshot that touches neither `Stdio`, `Terminal` nor `Config`. Everything is quiet unless a field of
103
+ * `partial` sets it.
104
+ *
105
+ * @param partial - the fields to set; `stdout` and `stderr` are merged over the quiet stream
106
+ */
107
+ static layerTest = (partial) => {
108
+ const stdout = {
109
+ ...quiet,
110
+ ...partial?.stdout
111
+ };
112
+ return Layer.succeed(TerminalEnv, {
113
+ stdinIsTerminal: partial?.stdinIsTerminal ?? false,
114
+ stdout,
115
+ stderr: {
116
+ ...quiet,
117
+ ...partial?.stderr
118
+ },
119
+ width: (fallback = 80) => Option.getOrElse(stdout.columns, () => fallback)
120
+ });
121
+ };
122
+ /**
123
+ * The colour level of a stream.
124
+ *
125
+ * @remarks
126
+ * An ambient `TerminalEnv`, when one is provided, answers with its stdout colour, so a test that fixes the
127
+ * terminal with `layerTest` also fixes this. Without one it is decided from `Config` and `Stdio` alone. It
128
+ * requires only `Stdio`, never `Terminal` or `TerminalEnv`, so a caller that only decides colour (a CLI's
129
+ * output formatter) keeps a `Stdio`-only requirement.
130
+ *
131
+ * @param _stream - the stream to decide for; only `stdout` is available, since `Stdio` reports no other
132
+ */
133
+ static colorLevel(_stream) {
134
+ return Effect.gen(function* () {
135
+ const ambient = yield* Effect.serviceOption(TerminalEnv);
136
+ if (Option.isSome(ambient)) return ambient.value.stdout.color;
137
+ const isTTY = yield* (yield* Stdio.Stdio).stdoutIsTerminal;
138
+ return colorDepth(yield* readEnv(colorKeys), isTTY);
139
+ });
140
+ }
141
+ };
142
+
143
+ //#endregion
144
+ export { TerminalEnv };