@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/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 };