@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 +94 -0
- package/EnvOverride.js +109 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/RuntimeEnv.js +117 -0
- package/TerminalEnv.js +144 -0
- package/index.d.ts +442 -0
- package/index.js +6 -0
- package/internal/agentCi.js +69 -0
- package/internal/colorDepth.js +128 -0
- package/internal/envRecord.js +24 -0
- package/internal/keys.js +19 -0
- package/internal/osc8/detect.js +148 -0
- package/internal/osc8/env.js +21 -0
- package/internal/osc8/semver.js +46 -0
- package/internal/osc8/terminals.js +319 -0
- package/internal/osc8/wrappers.js +23 -0
- package/package.json +48 -0
- package/tsdoc-metadata.json +11 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { ConfigProvider, Context, Effect, Layer, Option, Schema, Stdio, Terminal } from "effect";
|
|
2
|
+
//#region src/RuntimeEnv.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The CI providers a {@link RuntimeEnv} names: `github-actions` when `GITHUB_ACTIONS` is set, `generic` for any other
|
|
5
|
+
* CI signal (`CI`, `CONTINUOUS_INTEGRATION`).
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
type CiName = "github-actions" | "generic";
|
|
10
|
+
declare const RuntimeEnv_base: Schema.Class<RuntimeEnv, Schema.Struct<{
|
|
11
|
+
/**
|
|
12
|
+
* The AI agent running the process, as its family (`claude` for Claude Code, whatever `AI_AGENT` says it is
|
|
13
|
+
* beyond that), or `None`.
|
|
14
|
+
*/
|
|
15
|
+
readonly agent: Schema.withDecodingDefaultTypeKey<Schema.OptionFromNullOr<Schema.String>, never>;
|
|
16
|
+
/** The CI the process runs in: {@link CiName}, so a consumer can match it exhaustively, or `None`. */
|
|
17
|
+
readonly ci: Schema.withDecodingDefaultTypeKey<Schema.OptionFromNullOr<Schema.Literals<readonly ["github-actions", "generic"]>>, never>;
|
|
18
|
+
/** The identified terminal program and its version when it exposes one, or `None`. */
|
|
19
|
+
readonly terminal: Schema.withDecodingDefaultTypeKey<Schema.OptionFromNullOr<Schema.Struct<{
|
|
20
|
+
readonly name: Schema.String;
|
|
21
|
+
readonly version: Schema.withDecodingDefaultTypeKey<Schema.OptionFromNullOr<Schema.String>, never>;
|
|
22
|
+
}>>, never>;
|
|
23
|
+
}>, {}>;
|
|
24
|
+
/**
|
|
25
|
+
* A snapshot of who is running the program: the agent, the CI, and the terminal.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Each field is an `Option` in memory and `null` when absent on the wire, so the snapshot persists as plain JSON
|
|
29
|
+
* through `Schema.fromJsonString(RuntimeEnv)`. Every field decodes when its key is absent, so a persisted snapshot
|
|
30
|
+
* keeps decoding after a field is added. Built from `Config` only (no stream is consulted), so it is safe inside a
|
|
31
|
+
* stdio MCP server.
|
|
32
|
+
*
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
export declare class RuntimeEnv extends RuntimeEnv_base {
|
|
36
|
+
/**
|
|
37
|
+
* The snapshot of an environment record, as a pure function: no `Config`, no `process`, no service.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* It is what {@link CurrentRuntimeEnv.layer} computes from the variables it reads, so the two agree on the same
|
|
41
|
+
* record. An `undefined` or empty value reads as unset, under every caller. Use it where a service is in the
|
|
42
|
+
* way: a long-lived host that holds its own environment record, or a renderer with no Effect context.
|
|
43
|
+
*
|
|
44
|
+
* @param env - variable name to value
|
|
45
|
+
*/
|
|
46
|
+
static fromRecord(env: Readonly<Record<string, string | undefined>>): RuntimeEnv;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The fields {@link CurrentRuntimeEnv.layerTest} can override.
|
|
50
|
+
*
|
|
51
|
+
* @public
|
|
52
|
+
*/
|
|
53
|
+
interface RuntimeEnvOverrides {
|
|
54
|
+
/** Replaces the detected agent. */
|
|
55
|
+
readonly agent?: Option.Option<string>;
|
|
56
|
+
/** Replaces the detected CI. */
|
|
57
|
+
readonly ci?: Option.Option<CiName>;
|
|
58
|
+
/** Replaces the detected terminal. */
|
|
59
|
+
readonly terminal?: Option.Option<{
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly version: Option.Option<string>;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
declare const CurrentRuntimeEnv_base: Context.ServiceClass<CurrentRuntimeEnv, "@effected/env/CurrentRuntimeEnv", RuntimeEnv>;
|
|
65
|
+
/**
|
|
66
|
+
* The {@link RuntimeEnv} of the running process, as a service.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* The service's whole shape is one immutable value, so it is provided with `Layer.succeed` rather than mocked.
|
|
70
|
+
* `layer` reads the ambient `ConfigProvider` once when it is built; `layerTest` is the only way a test changes the
|
|
71
|
+
* environment.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { CurrentRuntimeEnv } from "@effected/env"
|
|
76
|
+
* import { Effect, Option } from "effect"
|
|
77
|
+
*
|
|
78
|
+
* const program = Effect.gen(function* () {
|
|
79
|
+
* const env = yield* CurrentRuntimeEnv
|
|
80
|
+
* return Option.getOrElse(env.agent, () => "no agent")
|
|
81
|
+
* }).pipe(Effect.provide(CurrentRuntimeEnv.layerTest({ agent: Option.some("claude") })))
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @public
|
|
85
|
+
*/
|
|
86
|
+
export declare class CurrentRuntimeEnv extends CurrentRuntimeEnv_base {
|
|
87
|
+
/**
|
|
88
|
+
* Reads the environment through `Config` once, when the layer is built. Requires nothing: the provider is read
|
|
89
|
+
* from the ambient `ConfigProvider`.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* Two things make this a frozen read, and a long-lived host (an MCP server, a watch-mode runner) trips on both.
|
|
93
|
+
* Core's default `ConfigProvider.fromEnv()` snapshots `process.env` once per process, so a change after the
|
|
94
|
+
* first read is never seen; and this is one static layer, memoized by reference, so two consumers that provide
|
|
95
|
+
* different providers in one graph share the first snapshot. Use {@link CurrentRuntimeEnv.layerFrom}, which is
|
|
96
|
+
* a fresh layer per call and per use, or provide a fresh `ConfigProvider` for every read.
|
|
97
|
+
*/
|
|
98
|
+
static readonly layer: Layer.Layer<CurrentRuntimeEnv>;
|
|
99
|
+
/**
|
|
100
|
+
* A snapshot of an explicit source: an environment record, or a `ConfigProvider` read instead of the ambient
|
|
101
|
+
* one.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Each call returns a new layer, and the layer is `Layer.fresh`, so it is built again for every use rather
|
|
105
|
+
* than shared through the build's memo: two calls with different sources in one graph see different values,
|
|
106
|
+
* and a provider is read again each time the layer is used. A record is read when the layer is built, never
|
|
107
|
+
* at the call.
|
|
108
|
+
*
|
|
109
|
+
* @param source - a variable-name-to-value record, or a `ConfigProvider`
|
|
110
|
+
*/
|
|
111
|
+
static readonly layerFrom: (source: Readonly<Record<string, string | undefined>> | ConfigProvider.ConfigProvider) => Layer.Layer<CurrentRuntimeEnv>;
|
|
112
|
+
/**
|
|
113
|
+
* A fixed snapshot that never touches `Config`: every field is `None` unless `overrides` sets it.
|
|
114
|
+
*
|
|
115
|
+
* @param overrides - the fields to set
|
|
116
|
+
*/
|
|
117
|
+
static readonly layerTest: (overrides?: RuntimeEnvOverrides) => Layer.Layer<CurrentRuntimeEnv>;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/Audience.d.ts
|
|
121
|
+
/**
|
|
122
|
+
* Who the output is for: a person at a terminal, an AI agent, or a CI job.
|
|
123
|
+
*
|
|
124
|
+
* @public
|
|
125
|
+
*/
|
|
126
|
+
type AudienceKind = "human" | "agent" | "ci";
|
|
127
|
+
/**
|
|
128
|
+
* The shape of the {@link Audience} service: one immutable value.
|
|
129
|
+
*
|
|
130
|
+
* @public
|
|
131
|
+
*/
|
|
132
|
+
interface AudienceShape {
|
|
133
|
+
/** Who the output is for. */
|
|
134
|
+
readonly kind: AudienceKind;
|
|
135
|
+
/**
|
|
136
|
+
* What decided the kind: the override environment variable, detection, or a flag. `Audience.layer` produces
|
|
137
|
+
* `override` or `detected`; `flag` is for a layer stacked on top (a CLI's `--audience` flag re-provides
|
|
138
|
+
* `Audience`), which lets its own consumers tell what spoke last.
|
|
139
|
+
*/
|
|
140
|
+
readonly source: "override" | "detected" | "flag";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The options {@link Audience.layer} takes.
|
|
144
|
+
*
|
|
145
|
+
* @public
|
|
146
|
+
*/
|
|
147
|
+
interface AudienceOptions {
|
|
148
|
+
/** The environment variable that overrides detection; without it the audience is always detected. */
|
|
149
|
+
readonly envVar?: string;
|
|
150
|
+
}
|
|
151
|
+
declare const Audience_base: Context.ServiceClass<Audience, "@effected/env/Audience", AudienceShape>;
|
|
152
|
+
/**
|
|
153
|
+
* Who the output is for, decided once.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* Precedence: a valid override environment variable, then an agent, then CI, then a human, so an agent inside a
|
|
157
|
+
* CI job gets agent output. A human typing a command inside an agent-detected shell is still detected as an
|
|
158
|
+
* agent, never refused: the override variable flips it back. The shape is one immutable value, so the service is
|
|
159
|
+
* provided with `Layer.succeed` and there is no `Layer.mock` to reach for.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* import { Audience, CurrentRuntimeEnv } from "@effected/env"
|
|
164
|
+
* import { Effect, Layer } from "effect"
|
|
165
|
+
*
|
|
166
|
+
* const AudienceLive = Audience.layer({ envVar: "MYTOOL_AUDIENCE" }).pipe(Layer.provide(CurrentRuntimeEnv.layer))
|
|
167
|
+
*
|
|
168
|
+
* const program = Effect.gen(function* () {
|
|
169
|
+
* const { kind, source } = yield* Audience
|
|
170
|
+
* return `${kind} (${source})`
|
|
171
|
+
* }).pipe(Effect.provide(AudienceLive))
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @public
|
|
175
|
+
*/
|
|
176
|
+
export declare class Audience extends Audience_base {
|
|
177
|
+
/**
|
|
178
|
+
* Decide the audience from `CurrentRuntimeEnv`, and from the override variable named by `options.envVar`.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* The variable is read through `Config`, lower-cased and matched against `human`, `agent` and `ci`. An empty
|
|
182
|
+
* or absent variable is unset. An invalid value logs one warning through `Effect.logWarning` and falls back to
|
|
183
|
+
* detection; it never fails the run. That warning is once per layer build, so building the layer a second time
|
|
184
|
+
* warns again. The warning goes through `Effect.logWarning`, and Effect's default logger writes to stdout unless
|
|
185
|
+
* `References.LogToStderr` is set: an MCP server or any stdio-sensitive host must route logs to stderr (the `cli`
|
|
186
|
+
* package's `CliLogger` does). Without `options.envVar` the audience is always detected. A layer-returning function
|
|
187
|
+
* mints a fresh layer per call: call it once and bind the result to a constant.
|
|
188
|
+
*
|
|
189
|
+
* @param options - `envVar` names the override variable
|
|
190
|
+
*/
|
|
191
|
+
static layer(options?: AudienceOptions): Layer.Layer<Audience, never, CurrentRuntimeEnv>;
|
|
192
|
+
/**
|
|
193
|
+
* A fixed audience that touches neither `CurrentRuntimeEnv` nor `Config`.
|
|
194
|
+
*
|
|
195
|
+
* @remarks
|
|
196
|
+
* `source` defaults to `override`, since a test that fixes the kind has decided it. Pass `detected` to test a
|
|
197
|
+
* layer stacked on top, such as a CLI flag that only applies when the environment variable did not decide, or
|
|
198
|
+
* `flag` to test a consumer of that layer's result.
|
|
199
|
+
*
|
|
200
|
+
* @param kind - the audience to fix
|
|
201
|
+
* @param source - what decided it: `override` (the default), `detected`, or `flag`
|
|
202
|
+
*/
|
|
203
|
+
static readonly layerTest: (kind: AudienceKind, source?: AudienceShape["source"]) => Layer.Layer<Audience>;
|
|
204
|
+
/**
|
|
205
|
+
* The audience a {@link RuntimeEnv} implies, ignoring any override: an agent, else CI, else a human.
|
|
206
|
+
*
|
|
207
|
+
* @param env - the runtime snapshot to decide from
|
|
208
|
+
*/
|
|
209
|
+
static readonly detect: (env: RuntimeEnv) => AudienceKind;
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/ColorLevel.d.ts
|
|
213
|
+
/**
|
|
214
|
+
* The colour support of one output stream: none, 16 colours, 256 colours or truecolor.
|
|
215
|
+
*
|
|
216
|
+
* @public
|
|
217
|
+
*/
|
|
218
|
+
type ColorLevel = "none" | "basic" | "256" | "truecolor";
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/EnvOverride.d.ts
|
|
221
|
+
/**
|
|
222
|
+
* Reads an environment variable that picks a mode within an audience.
|
|
223
|
+
*
|
|
224
|
+
* @remarks
|
|
225
|
+
* The kit never learns a consumer's literals: the caller passes, per audience, the values it accepts.
|
|
226
|
+
*
|
|
227
|
+
* @public
|
|
228
|
+
*/
|
|
229
|
+
export declare class EnvOverride {
|
|
230
|
+
private constructor();
|
|
231
|
+
/**
|
|
232
|
+
* Read `options.envVar` and say what happened, without logging.
|
|
233
|
+
*
|
|
234
|
+
* @remarks
|
|
235
|
+
* `accepted` is the literal the current audience accepts (matching is case-insensitive and yields the
|
|
236
|
+
* literal), `rejected` is a set value it does not, with the value as written, the audience and the literals
|
|
237
|
+
* that audience accepts, so the caller owns the wording, the stream and any once-per-run dedupe. Unset and
|
|
238
|
+
* empty are neither. At most one of the two is `Some`. {@link EnvOverride.read} is the logging convenience.
|
|
239
|
+
*
|
|
240
|
+
* The variable is read from the ambient `ConfigProvider` unless `source` is given: a record (read fresh on every
|
|
241
|
+
* call, so a host passing `process.env` sees each change, and an `undefined` value is unset) or a `ConfigProvider`
|
|
242
|
+
* of its own.
|
|
243
|
+
*
|
|
244
|
+
* Without `source`, every read takes the `ConfigProvider` of the fiber that runs it, so a reader built once at
|
|
245
|
+
* module level stays testable: a test provides its own provider around the read.
|
|
246
|
+
*
|
|
247
|
+
* ```ts
|
|
248
|
+
* const consoleMode = EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts })
|
|
249
|
+
* // in a test:
|
|
250
|
+
* consoleMode.pipe(
|
|
251
|
+
* Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ MYTOOL_CONSOLE: "silent" })),
|
|
252
|
+
* )
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* `source` is for a long-lived host that must re-read a record on each call (`process.env` in a watch-mode runner,
|
|
256
|
+
* where core's default provider snapshots the environment once). It is fixed when the options object is built, so a
|
|
257
|
+
* host that wants it AND testability builds the options inside a function that takes the source:
|
|
258
|
+
*
|
|
259
|
+
* ```ts
|
|
260
|
+
* const consoleModeFrom = (source: Readonly<Record<string, string | undefined>>) =>
|
|
261
|
+
* EnvOverride.readResult({ envVar: "MYTOOL_CONSOLE", accepts, source })
|
|
262
|
+
* // production: consoleModeFrom(process.env); a test: consoleModeFrom({ MYTOOL_CONSOLE: "silent" })
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts; `source`, if
|
|
266
|
+
* given, is where the variable is read in place of the ambient environment: a long-lived host's record, re-read
|
|
267
|
+
* on every call, or a `ConfigProvider`; omit it to read the fiber's `ConfigProvider`, which a test provides
|
|
268
|
+
*/
|
|
269
|
+
static readResult<const M extends Record<AudienceKind, ReadonlyArray<string>>>(options: {
|
|
270
|
+
readonly envVar: string;
|
|
271
|
+
readonly accepts: M;
|
|
272
|
+
readonly source?: Readonly<Record<string, string | undefined>> | ConfigProvider.ConfigProvider | undefined;
|
|
273
|
+
}): Effect.Effect<{
|
|
274
|
+
readonly audience: AudienceKind;
|
|
275
|
+
readonly accepted: Option.Option<M[AudienceKind][number]>;
|
|
276
|
+
readonly rejected: Option.Option<{
|
|
277
|
+
readonly value: string;
|
|
278
|
+
readonly audience: AudienceKind;
|
|
279
|
+
readonly accepts: ReadonlyArray<string>;
|
|
280
|
+
}>;
|
|
281
|
+
}, never, Audience>;
|
|
282
|
+
/**
|
|
283
|
+
* Read `options.envVar` and accept it only when the current audience accepts it.
|
|
284
|
+
*
|
|
285
|
+
* @remarks
|
|
286
|
+
* Matching is case-insensitive and yields the accepted literal, so the result narrows to the union of every
|
|
287
|
+
* audience's literals. An unset or empty variable is `None`. A value the current audience does not accept logs
|
|
288
|
+
* one warning through `Effect.logWarning`, naming the accepted values, and is `None`; it never fails the run.
|
|
289
|
+
* The warning is once per read, so reading again warns again. It goes through `Effect.logWarning`, and Effect's
|
|
290
|
+
* default logger writes to stdout unless `References.LogToStderr` is set: an MCP server or any stdio-sensitive
|
|
291
|
+
* host must route logs to stderr (the `cli` package's `CliLogger` does).
|
|
292
|
+
*
|
|
293
|
+
* @param options - `envVar` is the variable; `accepts` lists, per audience, the literals it accepts
|
|
294
|
+
*/
|
|
295
|
+
static read<const M extends Record<AudienceKind, ReadonlyArray<string>>>(options: {
|
|
296
|
+
readonly envVar: string;
|
|
297
|
+
readonly accepts: M;
|
|
298
|
+
}): Effect.Effect<Option.Option<M[AudienceKind][number]>, never, Audience>;
|
|
299
|
+
}
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/TerminalEnv.d.ts
|
|
302
|
+
/**
|
|
303
|
+
* What one output stream can do.
|
|
304
|
+
*
|
|
305
|
+
* @public
|
|
306
|
+
*/
|
|
307
|
+
interface StreamEnv {
|
|
308
|
+
/** Whether the stream is attached to a terminal. */
|
|
309
|
+
readonly isTerminal: boolean;
|
|
310
|
+
/** The colour level: `none` unless the stream is a terminal or `FORCE_COLOR` says otherwise. */
|
|
311
|
+
readonly color: ColorLevel;
|
|
312
|
+
/**
|
|
313
|
+
* Whether the terminal can render OSC 8 hyperlinks on this stream.
|
|
314
|
+
*
|
|
315
|
+
* @remarks
|
|
316
|
+
* Decided in this order: a truthy `FORCE_HYPERLINK` turns links on, even on a stream that is not a terminal; a
|
|
317
|
+
* truthy `NO_HYPERLINK` or a non-empty `NO_COLOR` turns them off; then the stream must be a terminal, not inside a
|
|
318
|
+
* multiplexer that strips them (tmux, GNU screen), and a known terminal at a version that renders them.
|
|
319
|
+
*
|
|
320
|
+
* This is terminal capability only and does not consider the audience. Turning links off for an agent audience
|
|
321
|
+
* is applied by `@effected/cli`, where the audience is known.
|
|
322
|
+
*/
|
|
323
|
+
readonly hyperlinks: boolean;
|
|
324
|
+
/**
|
|
325
|
+
* The terminal width in columns, or `None` when it is unknown.
|
|
326
|
+
*
|
|
327
|
+
* @remarks
|
|
328
|
+
* Core's `Terminal` exposes one width, so `stderr.columns` reports stdout's width.
|
|
329
|
+
*/
|
|
330
|
+
readonly columns: Option.Option<number>;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The options {@link TerminalEnv.layer} takes.
|
|
334
|
+
*
|
|
335
|
+
* @public
|
|
336
|
+
*/
|
|
337
|
+
interface TerminalEnvOptions {
|
|
338
|
+
/** Overrides the stderr TTY check; `Stdio` reports only stdout, so stderr mirrors stdout when this is omitted. */
|
|
339
|
+
readonly stderrIsTerminal?: Effect.Effect<boolean>;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The options {@link TerminalEnv.layerTest} takes: fields to set over the quiet terminal.
|
|
343
|
+
*
|
|
344
|
+
* @public
|
|
345
|
+
*/
|
|
346
|
+
interface TerminalEnvTestOptions {
|
|
347
|
+
/** Whether standard input is a terminal. */
|
|
348
|
+
readonly stdinIsTerminal?: boolean;
|
|
349
|
+
/** Fields merged over the quiet stdout. */
|
|
350
|
+
readonly stdout?: Partial<StreamEnv>;
|
|
351
|
+
/** Fields merged over the quiet stderr. */
|
|
352
|
+
readonly stderr?: Partial<StreamEnv>;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The shape of the {@link TerminalEnv} service: a snapshot taken when the layer is built, not a live view.
|
|
356
|
+
*
|
|
357
|
+
* @public
|
|
358
|
+
*/
|
|
359
|
+
interface TerminalEnvShape {
|
|
360
|
+
/** Whether standard input is attached to a terminal. */
|
|
361
|
+
readonly stdinIsTerminal: boolean;
|
|
362
|
+
/** The capabilities of standard output. */
|
|
363
|
+
readonly stdout: StreamEnv;
|
|
364
|
+
/** The capabilities of standard error; its `columns` is stdout's width, since core's `Terminal` has one. */
|
|
365
|
+
readonly stderr: StreamEnv;
|
|
366
|
+
/**
|
|
367
|
+
* The width to lay output out at: the stdout columns, else the `COLUMNS` variable, else `fallback`.
|
|
368
|
+
*
|
|
369
|
+
* @param fallback - the width when nothing else is known; defaults to 80
|
|
370
|
+
*/
|
|
371
|
+
readonly width: (fallback?: number) => number;
|
|
372
|
+
}
|
|
373
|
+
declare const TerminalEnv_base: Context.ServiceClass<TerminalEnv, "@effected/env/TerminalEnv", TerminalEnvShape>;
|
|
374
|
+
/**
|
|
375
|
+
* What the terminal can do: per-stream colour level, hyperlink support and columns, and the width to lay out at.
|
|
376
|
+
*
|
|
377
|
+
* @remarks
|
|
378
|
+
* `layer` reads the environment through `Config` and the TTY state through `Stdio` and `Terminal` once, when it
|
|
379
|
+
* is built. `layerTest` is the only way a test changes it, and defaults to a quiet terminal so a test opts into
|
|
380
|
+
* colour. `colorLevel` needs `Stdio` alone, so a caller that only decides colour never requires `Terminal`.
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* import { TerminalEnv } from "@effected/env"
|
|
385
|
+
* import { Effect, Option } from "effect"
|
|
386
|
+
*
|
|
387
|
+
* const program = Effect.gen(function* () {
|
|
388
|
+
* const terminal = yield* TerminalEnv
|
|
389
|
+
* return { color: terminal.stdout.color, width: terminal.width() }
|
|
390
|
+
* }).pipe(Effect.provide(TerminalEnv.layerTest({ stdout: { color: "256", columns: Option.some(100) } })))
|
|
391
|
+
* ```
|
|
392
|
+
*
|
|
393
|
+
* @public
|
|
394
|
+
*/
|
|
395
|
+
export declare class TerminalEnv extends TerminalEnv_base {
|
|
396
|
+
/**
|
|
397
|
+
* Build the snapshot from `Stdio`, `Terminal` and the ambient `ConfigProvider`.
|
|
398
|
+
*
|
|
399
|
+
* @remarks
|
|
400
|
+
* `Stdio` reports only stdout, so stderr mirrors it unless `options.stderrIsTerminal` supplies its own answer.
|
|
401
|
+
* A layer-returning function mints a fresh layer per call: call it once and bind the result to a constant.
|
|
402
|
+
*
|
|
403
|
+
* @param options - `stderrIsTerminal` overrides the stderr TTY check
|
|
404
|
+
*/
|
|
405
|
+
static layer(options?: TerminalEnvOptions): Layer.Layer<TerminalEnv, never, Stdio.Stdio | Terminal.Terminal>;
|
|
406
|
+
/**
|
|
407
|
+
* The snapshot from `Stdio` and the ambient `ConfigProvider` alone: it never requires or builds `Terminal`, and
|
|
408
|
+
* reports no columns.
|
|
409
|
+
*
|
|
410
|
+
* @remarks
|
|
411
|
+
* Everything else is what {@link TerminalEnv.layer} reports: the TTY facts come from `Stdio`, the colour and
|
|
412
|
+
* hyperlink decisions from the environment, and `width()` falls back to `COLUMNS`, then the fallback, since no
|
|
413
|
+
* terminal width is known. Use it where building the platform `Terminal` has a cost, for example a long-lived
|
|
414
|
+
* host, where `NodeTerminal` listens on `process.stdin`. A layer-returning function mints a fresh layer per
|
|
415
|
+
* call: call it once and bind the result to a constant.
|
|
416
|
+
*
|
|
417
|
+
* @param options - `stderrIsTerminal` overrides the stderr TTY check
|
|
418
|
+
*/
|
|
419
|
+
static layerStdio(options?: TerminalEnvOptions): Layer.Layer<TerminalEnv, never, Stdio.Stdio>;
|
|
420
|
+
/**
|
|
421
|
+
* A fixed snapshot that touches neither `Stdio`, `Terminal` nor `Config`. Everything is quiet unless a field of
|
|
422
|
+
* `partial` sets it.
|
|
423
|
+
*
|
|
424
|
+
* @param partial - the fields to set; `stdout` and `stderr` are merged over the quiet stream
|
|
425
|
+
*/
|
|
426
|
+
static readonly layerTest: (partial?: TerminalEnvTestOptions) => Layer.Layer<TerminalEnv>;
|
|
427
|
+
/**
|
|
428
|
+
* The colour level of a stream.
|
|
429
|
+
*
|
|
430
|
+
* @remarks
|
|
431
|
+
* An ambient `TerminalEnv`, when one is provided, answers with its stdout colour, so a test that fixes the
|
|
432
|
+
* terminal with `layerTest` also fixes this. Without one it is decided from `Config` and `Stdio` alone. It
|
|
433
|
+
* requires only `Stdio`, never `Terminal` or `TerminalEnv`, so a caller that only decides colour (a CLI's
|
|
434
|
+
* output formatter) keeps a `Stdio`-only requirement.
|
|
435
|
+
*
|
|
436
|
+
* @param _stream - the stream to decide for; only `stdout` is available, since `Stdio` reports no other
|
|
437
|
+
*/
|
|
438
|
+
static colorLevel(_stream: "stdout"): Effect.Effect<ColorLevel, never, Stdio.Stdio>;
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
export type { AudienceKind, AudienceOptions, AudienceShape, CiName, ColorLevel, RuntimeEnvOverrides, StreamEnv, TerminalEnvOptions, TerminalEnvShape, TerminalEnvTestOptions };
|
|
442
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CurrentRuntimeEnv, RuntimeEnv } from "./RuntimeEnv.js";
|
|
2
|
+
import { Audience } from "./Audience.js";
|
|
3
|
+
import { EnvOverride } from "./EnvOverride.js";
|
|
4
|
+
import { TerminalEnv } from "./TerminalEnv.js";
|
|
5
|
+
|
|
6
|
+
export { Audience, CurrentRuntimeEnv, EnvOverride, RuntimeEnv, TerminalEnv };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/agentCi.ts
|
|
4
|
+
const matches = (value, pattern) => !!value && pattern.test(value);
|
|
5
|
+
const AGENT_RULES = [
|
|
6
|
+
["claude", ["CLAUDECODE", "CLAUDE_CODE"]],
|
|
7
|
+
["replit", ["REPL_ID"]],
|
|
8
|
+
["gemini", ["GEMINI_CLI"]],
|
|
9
|
+
["codex", ["CODEX_SANDBOX", "CODEX_THREAD_ID"]],
|
|
10
|
+
["opencode", ["OPENCODE"]],
|
|
11
|
+
["pi", (env) => matches(env.PATH, /\.pi[\\/]agent/)],
|
|
12
|
+
["auggie", ["AUGMENT_AGENT"]],
|
|
13
|
+
["goose", ["GOOSE_PROVIDER"]],
|
|
14
|
+
["junie", ["JUNIE_DATA", "JUNIE_SHIM_PATH"]],
|
|
15
|
+
["copilot", ["COPILOT_AGENT", "COPILOT_CLI"]],
|
|
16
|
+
["devin", (env) => matches(env.EDITOR, /devin/)],
|
|
17
|
+
["cursor", ["CURSOR_AGENT"]]
|
|
18
|
+
];
|
|
19
|
+
const AGENT_FAMILIES = [...AGENT_RULES.map(([name]) => name), "kiro"];
|
|
20
|
+
/** The family a lower-cased `AI_AGENT` value belongs to: equal to it, or starting with it then `-` or `_`. */
|
|
21
|
+
const familyOf = (value) => AGENT_FAMILIES.find((family) => value === family || value.startsWith(`${family}-`) || value.startsWith(`${family}_`)) ?? value;
|
|
22
|
+
/**
|
|
23
|
+
* The name of the AI agent running this process, if any, as its family: `claude`, not the
|
|
24
|
+
* `claude-code_2-1-285_agent` Claude Code puts in `AI_AGENT`.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* `AI_AGENT` wins over every table row. It is lower-cased, the two `github_copilot_*` names collapse to
|
|
28
|
+
* `copilot`, and a value that is a known family or starts with one followed by `-` or `_` becomes that family.
|
|
29
|
+
* Any other value is returned as it was lower-cased.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
const detectAgent = (env) => {
|
|
34
|
+
if (env.AI_AGENT) {
|
|
35
|
+
const name = env.AI_AGENT.toLowerCase();
|
|
36
|
+
return Option.some(name === "github_copilot_vscode_agent" || name === "github_copilot_cloud_agent" ? "copilot" : familyOf(name));
|
|
37
|
+
}
|
|
38
|
+
for (const [name, rule] of AGENT_RULES) if (typeof rule === "function" ? rule(env) : rule.some((key) => !!env[key])) return Option.some(name);
|
|
39
|
+
return Option.none();
|
|
40
|
+
};
|
|
41
|
+
const isFalsy = (value) => value === void 0 || value === "" || value.toLowerCase() === "false" || value === "0";
|
|
42
|
+
/**
|
|
43
|
+
* The CI this process runs in: `github-actions` when `GITHUB_ACTIONS` is truthy (even under `CI=false`, since the
|
|
44
|
+
* provider signal is explicit), else `generic` when `CI` or `CONTINUOUS_INTEGRATION` is truthy.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
const detectCi = (env) => {
|
|
49
|
+
if (!isFalsy(env.GITHUB_ACTIONS)) return Option.some("github-actions");
|
|
50
|
+
if (!isFalsy(env.CI) || !isFalsy(env.CONTINUOUS_INTEGRATION)) return Option.some("generic");
|
|
51
|
+
return Option.none();
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Every environment variable name the detectors read.
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
const agentCiKeys = [
|
|
59
|
+
"AI_AGENT",
|
|
60
|
+
...AGENT_RULES.flatMap(([, rule]) => typeof rule === "function" ? [] : rule),
|
|
61
|
+
"PATH",
|
|
62
|
+
"EDITOR",
|
|
63
|
+
"GITHUB_ACTIONS",
|
|
64
|
+
"CI",
|
|
65
|
+
"CONTINUOUS_INTEGRATION"
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
export { agentCiKeys, detectAgent, detectCi };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
//#region src/internal/colorDepth.ts
|
|
2
|
+
const TERM_ENVS = /* @__PURE__ */ new Map([
|
|
3
|
+
["eterm", "basic"],
|
|
4
|
+
["cons25", "basic"],
|
|
5
|
+
["console", "basic"],
|
|
6
|
+
["cygwin", "basic"],
|
|
7
|
+
["dtterm", "basic"],
|
|
8
|
+
["gnome", "basic"],
|
|
9
|
+
["hurd", "basic"],
|
|
10
|
+
["jfbterm", "basic"],
|
|
11
|
+
["konsole", "basic"],
|
|
12
|
+
["kterm", "basic"],
|
|
13
|
+
["mlterm", "basic"],
|
|
14
|
+
["mosh", "truecolor"],
|
|
15
|
+
["putty", "basic"],
|
|
16
|
+
["st", "basic"],
|
|
17
|
+
["rxvt-unicode-24bit", "truecolor"],
|
|
18
|
+
["terminator", "truecolor"],
|
|
19
|
+
["xterm-kitty", "truecolor"]
|
|
20
|
+
]);
|
|
21
|
+
const CI_ENVS = [
|
|
22
|
+
["APPVEYOR", "256"],
|
|
23
|
+
["BUILDKITE", "256"],
|
|
24
|
+
["CIRCLECI", "truecolor"],
|
|
25
|
+
["DRONE", "256"],
|
|
26
|
+
["GITEA_ACTIONS", "truecolor"],
|
|
27
|
+
["GITHUB_ACTIONS", "truecolor"],
|
|
28
|
+
["GITLAB_CI", "256"],
|
|
29
|
+
["TRAVIS", "256"]
|
|
30
|
+
];
|
|
31
|
+
const TERM_ENVS_REG_EXP = [
|
|
32
|
+
/ansi/,
|
|
33
|
+
/color/,
|
|
34
|
+
/linux/,
|
|
35
|
+
/direct/,
|
|
36
|
+
/^con[0-9]*x[0-9]/,
|
|
37
|
+
/^rxvt/,
|
|
38
|
+
/^screen/,
|
|
39
|
+
/^xterm/,
|
|
40
|
+
/^vt100/,
|
|
41
|
+
/^vt220/
|
|
42
|
+
];
|
|
43
|
+
const isSet = (value) => value !== void 0 && value !== "";
|
|
44
|
+
/**
|
|
45
|
+
* The table: Node's getColorDepth with the FORCE_COLOR branch and warning removed, and its win32 branch keyed on
|
|
46
|
+
* `OS=Windows_NT`.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* Node's win32 branch reads `process.platform` and the OS release: truecolor from Windows 10 build 14931, 256 colours
|
|
50
|
+
* from build 10586, 16 before. Here the environment is read only through `Config`, so `OS=Windows_NT`, which Windows
|
|
51
|
+
* sets system-wide and Git Bash keeps, stands in for the platform, and the branch gives truecolor. That approximates
|
|
52
|
+
* Node on Windows 10 build 14931 and later; an older build gets more colour than Node would give it.
|
|
53
|
+
*/
|
|
54
|
+
const fromTable = (env) => {
|
|
55
|
+
if (isSet(env.NODE_DISABLE_COLORS) || isSet(env.NO_COLOR) || env.TERM === "dumb") return "none";
|
|
56
|
+
if (env.OS === "Windows_NT") return "truecolor";
|
|
57
|
+
if (env.TMUX) return "truecolor";
|
|
58
|
+
if (env.TF_BUILD !== void 0 && env.AGENT_NAME !== void 0) return "basic";
|
|
59
|
+
if (env.CI !== void 0) {
|
|
60
|
+
for (const [name, level] of CI_ENVS) if (env[name] !== void 0) return level;
|
|
61
|
+
if (env.CI_NAME === "codeship") return "256";
|
|
62
|
+
return "none";
|
|
63
|
+
}
|
|
64
|
+
if (env.TEAMCITY_VERSION !== void 0) return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? "basic" : "none";
|
|
65
|
+
switch (env.TERM_PROGRAM) {
|
|
66
|
+
case "iTerm.app":
|
|
67
|
+
if (!env.TERM_PROGRAM_VERSION || /^[0-2]\./.test(env.TERM_PROGRAM_VERSION)) return "256";
|
|
68
|
+
return "truecolor";
|
|
69
|
+
case "HyperTerm":
|
|
70
|
+
case "MacTerm": return "truecolor";
|
|
71
|
+
case "Apple_Terminal": return "256";
|
|
72
|
+
}
|
|
73
|
+
if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return "truecolor";
|
|
74
|
+
if (env.TERM) {
|
|
75
|
+
if (/truecolor/.test(env.TERM)) return "truecolor";
|
|
76
|
+
if (/^xterm-256/.test(env.TERM)) return "256";
|
|
77
|
+
const term = env.TERM.toLowerCase();
|
|
78
|
+
const known = TERM_ENVS.get(term);
|
|
79
|
+
if (known !== void 0) return known;
|
|
80
|
+
if (TERM_ENVS_REG_EXP.some((re) => re.test(term))) return "basic";
|
|
81
|
+
}
|
|
82
|
+
if (env.COLORTERM) return "basic";
|
|
83
|
+
return "none";
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The colour level of one stream. `FORCE_COLOR`, when present, decides alone (it beats `NO_COLOR`, as in
|
|
87
|
+
* Node); otherwise a stream that is not a TTY has none; otherwise the terminal table decides, which gives a Windows
|
|
88
|
+
* terminal (`OS=Windows_NT`) truecolor unless colour is disabled.
|
|
89
|
+
*
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
const colorDepth = (env, isTTY) => {
|
|
93
|
+
if (env.FORCE_COLOR !== void 0) switch (env.FORCE_COLOR) {
|
|
94
|
+
case "":
|
|
95
|
+
case "1":
|
|
96
|
+
case "true": return "basic";
|
|
97
|
+
case "2": return "256";
|
|
98
|
+
case "3": return "truecolor";
|
|
99
|
+
default: return "none";
|
|
100
|
+
}
|
|
101
|
+
if (!isTTY) return "none";
|
|
102
|
+
return fromTable(env);
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Every environment variable name {@link colorDepth} reads, including the CI provider table.
|
|
106
|
+
*
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
const colorKeys = [
|
|
110
|
+
"FORCE_COLOR",
|
|
111
|
+
"NO_COLOR",
|
|
112
|
+
"NODE_DISABLE_COLORS",
|
|
113
|
+
"OS",
|
|
114
|
+
"TERM",
|
|
115
|
+
"TMUX",
|
|
116
|
+
"TF_BUILD",
|
|
117
|
+
"AGENT_NAME",
|
|
118
|
+
"CI",
|
|
119
|
+
"CI_NAME",
|
|
120
|
+
"TEAMCITY_VERSION",
|
|
121
|
+
"TERM_PROGRAM",
|
|
122
|
+
"TERM_PROGRAM_VERSION",
|
|
123
|
+
"COLORTERM",
|
|
124
|
+
...CI_ENVS.map(([name]) => name)
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
export { colorDepth, colorKeys };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Config, Effect, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/envRecord.ts
|
|
4
|
+
/**
|
|
5
|
+
* Drop an absent or empty value from a plain record, so it carries non-empty values only: the normalisation
|
|
6
|
+
* {@link readEnv} applies to what it reads, for a record a caller already holds.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const normalizeEnv = (record) => Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0 && value !== ""));
|
|
11
|
+
/**
|
|
12
|
+
* Read a fixed key set from the ambient `ConfigProvider` into a plain {@link Env} record.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* A key that is absent, or whose read fails for any reason, is left out of the record, so the record carries
|
|
16
|
+
* non-empty values only. An empty string is normalized to absent here, under every provider, including one built
|
|
17
|
+
* with `preserveEmptyStrings: true`, so `FORCE_COLOR=""` reads as unset whichever provider is ambient.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const readEnv = (keys) => Effect.forEach(keys, (key) => Config.option(Config.String(key)).pipe(Effect.orElseSucceed(() => Option.none()), Effect.map((value) => [key, value]))).pipe(Effect.map((entries) => Object.fromEntries(entries.flatMap(([key, value]) => Option.isSome(value) && value.value !== "" ? [[key, value.value]] : []))));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { normalizeEnv, readEnv };
|