@raidiant/notifai 10.1.1 → 10.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands-core.d.ts +1 -1
  3. package/dist/commands-init.js +9 -6
  4. package/dist/commands-init.js.map +1 -1
  5. package/dist/commands-send-support.js +3 -0
  6. package/dist/commands-send-support.js.map +1 -1
  7. package/dist/commands-skill.js +72 -54
  8. package/dist/commands-skill.js.map +1 -1
  9. package/dist/invocation-context.d.ts +1 -0
  10. package/dist/invocation-context.js +9 -1
  11. package/dist/invocation-context.js.map +1 -1
  12. package/dist/native-skills.d.ts +13 -3
  13. package/dist/native-skills.js +51 -12
  14. package/dist/native-skills.js.map +1 -1
  15. package/dist/node-floor.d.ts +2 -1
  16. package/dist/node-floor.js +9 -5
  17. package/dist/node-floor.js.map +1 -1
  18. package/dist/orca-session-title.d.ts +5 -4
  19. package/dist/orca-session-title.js +32 -19
  20. package/dist/orca-session-title.js.map +1 -1
  21. package/dist/platform.d.ts +11 -17
  22. package/dist/platform.js +20 -23
  23. package/dist/platform.js.map +1 -1
  24. package/dist/release.d.ts +5 -5
  25. package/dist/release.js +5 -5
  26. package/dist/setup-destinations.js +1 -1
  27. package/dist/setup-destinations.js.map +1 -1
  28. package/dist/skill-integrity.d.ts +49 -0
  29. package/dist/skill-integrity.js +154 -0
  30. package/dist/skill-integrity.js.map +1 -0
  31. package/dist/skill-source/manifest.json +16 -0
  32. package/dist/skill-source/notifai/SKILL.md +390 -0
  33. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  34. package/package.json +11 -14
  35. package/src/atomic-file.ts +0 -198
  36. package/src/claude-wake.test.ts +0 -321
  37. package/src/claude-wake.ts +0 -499
  38. package/src/cli-bin.test.ts +0 -176
  39. package/src/cli-bin.ts +0 -233
  40. package/src/cli-release.test.ts +0 -46
  41. package/src/cli-release.ts +0 -78
  42. package/src/cli-update-contract.ts +0 -9
  43. package/src/cli-update.test.ts +0 -125
  44. package/src/client.test.ts +0 -390
  45. package/src/client.ts +0 -352
  46. package/src/codex-wake.test.ts +0 -254
  47. package/src/codex-wake.ts +0 -301
  48. package/src/command-session.ts +0 -38
  49. package/src/commands-agent-sessions.ts +0 -72
  50. package/src/commands-auth.ts +0 -238
  51. package/src/commands-config.ts +0 -336
  52. package/src/commands-core.ts +0 -312
  53. package/src/commands-devices.ts +0 -180
  54. package/src/commands-doctor.ts +0 -1501
  55. package/src/commands-guidance.ts +0 -181
  56. package/src/commands-harness-context.ts +0 -207
  57. package/src/commands-hook-shape.ts +0 -69
  58. package/src/commands-hooks.ts +0 -2318
  59. package/src/commands-init.ts +0 -1055
  60. package/src/commands-io.ts +0 -108
  61. package/src/commands-logs.ts +0 -243
  62. package/src/commands-project.ts +0 -47
  63. package/src/commands-send-support.test.ts +0 -156
  64. package/src/commands-send-support.ts +0 -311
  65. package/src/commands-send.ts +0 -875
  66. package/src/commands-setup-proof.test.ts +0 -72
  67. package/src/commands-setup-proof.ts +0 -124
  68. package/src/commands-skill.test.ts +0 -44
  69. package/src/commands-skill.ts +0 -204
  70. package/src/commands-sounds.test.ts +0 -106
  71. package/src/commands-sounds.ts +0 -49
  72. package/src/commands-update.ts +0 -180
  73. package/src/commands.test.ts +0 -8297
  74. package/src/commands.ts +0 -77
  75. package/src/config-schema.test.ts +0 -153
  76. package/src/config-schema.ts +0 -349
  77. package/src/config.test.ts +0 -581
  78. package/src/config.ts +0 -475
  79. package/src/credentials.test.ts +0 -403
  80. package/src/credentials.ts +0 -403
  81. package/src/file-lock-process-worker.test.ts +0 -64
  82. package/src/file-lock.test.ts +0 -320
  83. package/src/file-lock.ts +0 -393
  84. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  85. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  86. package/src/generated-session-label.ts +0 -155
  87. package/src/guidance-content.ts +0 -278
  88. package/src/guidance-render.ts +0 -56
  89. package/src/guidance.test.ts +0 -327
  90. package/src/guidance.ts +0 -233
  91. package/src/harness-integration.test.ts +0 -294
  92. package/src/harnesses.test.ts +0 -149
  93. package/src/harnesses.ts +0 -164
  94. package/src/hook-adapter.test.ts +0 -496
  95. package/src/hook-adapter.ts +0 -424
  96. package/src/hook-input.test.ts +0 -39
  97. package/src/hook-input.ts +0 -72
  98. package/src/hooks.test.ts +0 -5824
  99. package/src/hooks.ts +0 -4011
  100. package/src/install-hooks.test.ts +0 -1614
  101. package/src/install-hooks.ts +0 -1501
  102. package/src/interactive.test.ts +0 -387
  103. package/src/interactive.ts +0 -858
  104. package/src/invocation-context.test.ts +0 -352
  105. package/src/invocation-context.ts +0 -197
  106. package/src/logging-process-worker.test.ts +0 -45
  107. package/src/logging.test.ts +0 -544
  108. package/src/logging.ts +0 -698
  109. package/src/main-run.ts +0 -45
  110. package/src/main.ts +0 -12
  111. package/src/native-skills.test.ts +0 -115
  112. package/src/native-skills.ts +0 -162
  113. package/src/node-floor.test.ts +0 -27
  114. package/src/node-floor.ts +0 -37
  115. package/src/onboarding-docs.test.ts +0 -25
  116. package/src/openclaw-plugin.ts +0 -409
  117. package/src/opencode-plugin.ts +0 -298
  118. package/src/orca-session-title.test.ts +0 -54
  119. package/src/orca-session-title.ts +0 -103
  120. package/src/pack-feedback-slice.test.ts +0 -212
  121. package/src/pack-feedback-slice.ts +0 -187
  122. package/src/platform.test.ts +0 -152
  123. package/src/platform.ts +0 -173
  124. package/src/program.test.ts +0 -317
  125. package/src/program.ts +0 -842
  126. package/src/project-enablement.test.ts +0 -55
  127. package/src/project-enablement.ts +0 -99
  128. package/src/question-caps.test.ts +0 -49
  129. package/src/question-settlement-process.ts +0 -47
  130. package/src/question-timing.ts +0 -56
  131. package/src/readiness.test.ts +0 -25
  132. package/src/readiness.ts +0 -212
  133. package/src/release.ts +0 -51
  134. package/src/send-attempts.test.ts +0 -143
  135. package/src/send-attempts.ts +0 -188
  136. package/src/send.test.ts +0 -28
  137. package/src/send.ts +0 -436
  138. package/src/session-activation.ts +0 -61
  139. package/src/session-end-log-lock-worker.test.ts +0 -35
  140. package/src/session-labels.test.ts +0 -490
  141. package/src/session-labels.ts +0 -474
  142. package/src/setup-destinations.test.ts +0 -41
  143. package/src/setup-destinations.ts +0 -50
  144. package/src/skill.test.ts +0 -426
  145. package/src/sound-ref.test.ts +0 -31
  146. package/src/sound-ref.ts +0 -34
  147. package/src/trust-policy.test.ts +0 -701
  148. package/src/ui/banner.ts +0 -95
  149. package/src/ui/config-view.test.ts +0 -90
  150. package/src/ui/config-view.ts +0 -138
  151. package/src/ui/help.test.ts +0 -16
  152. package/src/ui/help.ts +0 -128
  153. package/src/ui/theme.test.ts +0 -92
  154. package/src/ui/theme.ts +0 -265
  155. package/src/url-policy.ts +0 -399
  156. package/tsconfig.json +0 -10
package/src/config.ts DELETED
@@ -1,475 +0,0 @@
1
- import {
2
- INTERRUPTION_LEVELS,
3
- REPLY_MAX_WINDOW_SECONDS,
4
- } from '@raidiant/notifai-protocol'
5
- import { createHash } from 'node:crypto'
6
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
7
- import path from 'node:path'
8
- import { parse as parseToml } from 'smol-toml'
9
- import { configHome, stateHome } from './platform.js'
10
- import { isCliSoundRef } from './sound-ref.js'
11
- import { normalizeOrigin } from './url-policy.js'
12
-
13
- /**
14
- * Layered configuration with provenance. Most specific wins:
15
- *
16
- * flag > session > project-local > project > machine-global > default
17
- *
18
- * `session` holds what the user told the agent in conversation ("stop
19
- * notifying me for now"). It lives on disk keyed by harness session id rather
20
- * than in the agent's context, because context is lost to compaction and a
21
- * preference that evaporates mid-session is worse than none.
22
- *
23
- * `project-local` is a personal file under the user's config directory, keyed
24
- * by the Project root so a checkout does not have to gitignore anything. Shared
25
- * `.notifai/config.toml` stays in the tree for committed Project identity.
26
- *
27
- * `config show --explain` surfaces the winning layer per key.
28
- */
29
-
30
- export type ConfigSource =
31
- | 'flag'
32
- | 'env'
33
- | `session:${string}`
34
- | `project-local:${string}`
35
- | `project:${string}`
36
- | `global:${string}`
37
- | 'default'
38
-
39
- export interface ResolvedValue<T> {
40
- value: T
41
- source: ConfigSource
42
- }
43
-
44
- type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
45
-
46
- /**
47
- * How much the local log records. Defined here rather than in `logging.ts`
48
- * because it is a configuration vocabulary first — `logging.ts` reads config,
49
- * so the dependency has to point this way.
50
- */
51
- export const LOG_LEVELS = ['off', 'error', 'info', 'debug'] as const
52
- export type LogLevel = (typeof LOG_LEVELS)[number]
53
-
54
- export interface CliConfig {
55
- base_url: ResolvedValue<string>
56
- wait_seconds: ResolvedValue<number>
57
- ttl_seconds: ResolvedValue<number>
58
- collapse_key: ResolvedValue<string | null>
59
- devices: ResolvedValue<string[] | null>
60
- sound: ResolvedValue<string | null>
61
- interruption_level: ResolvedValue<CliInterruptionLevel | null>
62
- /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
63
- project: ResolvedValue<string | null>
64
- /** Whether a registered agent question may reach companion devices at all. */
65
- ask_notifications: ResolvedValue<boolean>
66
- /**
67
- * Terminal-first grace window, measured from the moment the question was
68
- * sent: the agent asks in the terminal and starts a timer, and only once it
69
- * elapses does the question reach companion devices.
70
- *
71
- * A timer, and only a timer. Whether the user happens to be at this keyboard
72
- * decides nothing; the harness-specific owner continues waiting either in
73
- * the held turn or out of band.
74
- */
75
- ask_grace_seconds: ResolvedValue<number>
76
- /**
77
- * How much of what this CLI does is recorded to the local log.
78
- *
79
- * The log exists for agents: a hook runs headless and its most consequential
80
- * act is usually a decision to do nothing, which leaves no other trace. `off`
81
- * disables the sink entirely; `debug` adds config resolution and request
82
- * detail, which is verbose enough to want a reason.
83
- */
84
- log_level: ResolvedValue<LogLevel>
85
- /**
86
- * Bytes the active log file may reach before it is rotated. With
87
- * `log_max_files` this is the whole disk budget — nothing here grows without
88
- * a ceiling, because a log that fills a disk is a bug that outlives its
89
- * usefulness.
90
- */
91
- log_max_bytes: ResolvedValue<number>
92
- /** How long the server accepts an answer and Question Routing owns its return path. */
93
- reply_window_seconds: ResolvedValue<number>
94
- /** Log files kept in total, the active one included. */
95
- log_max_files: ResolvedValue<number>
96
- /**
97
- * Extra origins remote `--image` URLs may use beyond the default public
98
- * HTTPS policy — the self-host and intranet escape hatch. User-owned: the
99
- * repository layer cannot supply it (see `USER_OWNED_CONFIG_KEYS`).
100
- */
101
- media_origins: ResolvedValue<string[]>
102
- /**
103
- * Extra dashboard origins a pairing approval URL may open, for self-hosts
104
- * whose dashboard lives on a different origin than the API. User-owned.
105
- */
106
- approve_origins: ResolvedValue<string[]>
107
- }
108
-
109
- export const CONFIG_KEYS = [
110
- 'wait_seconds',
111
- 'ttl_seconds',
112
- 'collapse_key',
113
- 'devices',
114
- 'sound',
115
- 'interruption_level',
116
- 'project',
117
- 'ask_notifications',
118
- 'ask_grace_seconds',
119
- 'reply_window_seconds',
120
- 'log_level',
121
- 'log_max_bytes',
122
- 'log_max_files',
123
- 'media_origins',
124
- 'approve_origins',
125
- ] as const
126
- export type ConfigKey = (typeof CONFIG_KEYS)[number]
127
-
128
- /**
129
- * Keys only the User may set. The shared `.notifai/config.toml` arrives with
130
- * a clone, so for most keys an unwanted value is at worst a clamped nuisance —
131
- * but these keys widen which network destinations this machine will touch,
132
- * and a hostile repository must not be able to widen them. Resolution skips
133
- * the repository layer for them entirely.
134
- */
135
- export const USER_OWNED_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
136
-
137
- /** Keys whose TOML value must be a list of normalized http(s) origins. */
138
- export const ORIGIN_LIST_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
139
-
140
- /** Keys whose TOML value must parse as a boolean; used by `config set`. */
141
- export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = ['ask_notifications']
142
-
143
- /** Keys whose TOML value must parse as an integer within its configured bounds. */
144
- export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
145
- 'wait_seconds',
146
- 'ttl_seconds',
147
- 'ask_grace_seconds',
148
- 'reply_window_seconds',
149
- 'log_max_bytes',
150
- 'log_max_files',
151
- ]
152
-
153
- /**
154
- * Keys whose value must be one of a fixed set.
155
- *
156
- * Config is readable from a repository, so an unrecognised value is untrusted
157
- * input rather than a typo — `coerce` drops it and the layer below wins, which
158
- * is the same discipline the numeric bounds follow.
159
- */
160
- export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
161
- interruption_level: INTERRUPTION_LEVELS,
162
- log_level: LOG_LEVELS,
163
- }
164
-
165
- /** Compiled service origin. Not a user-facing setting; login may override with `--base-url` or `NOTIFAI_BASE_URL`. */
166
- export const DEFAULT_BASE_URL = 'https://api.notifai.sh'
167
-
168
- const DEFAULTS = {
169
- wait_seconds: 10,
170
- ttl_seconds: 86400,
171
- collapse_key: null,
172
- devices: null,
173
- sound: null,
174
- interruption_level: null,
175
- project: null,
176
- // Installing the hooks is the opt-in; these exist to switch the behaviour
177
- // back off for a project or a session without uninstalling anything.
178
- ask_notifications: true,
179
- // Questions reach devices as soon as the turn ends. A terminal-first grace
180
- // window is an opt-in delay on top of that.
181
- ask_grace_seconds: 0,
182
- // A day. The wire contract's own default, and long enough that a question
183
- // asked before someone steps away is still answerable when they come back.
184
- reply_window_seconds: 86_400,
185
- // On by default: the log is the only account of what a headless hook did, and
186
- // a diagnostic nobody switched on before the thing went wrong is not one.
187
- log_level: 'info',
188
- // 2 MB × 3 files caps the logs at roughly 6 MB, which is weeks of ordinary
189
- // agent traffic and small enough that nobody has to think about it.
190
- log_max_bytes: 2_000_000,
191
- log_max_files: 3,
192
- // Empty by default: remote images use public HTTPS and pairing approval
193
- // uses the canonical dashboard, the pairing origin, or loopback. Entries
194
- // here are the User widening that, never a repository.
195
- media_origins: [],
196
- approve_origins: [],
197
- } satisfies Record<ConfigKey, unknown>
198
-
199
- /** The shipped value beneath every user-controlled configuration layer. */
200
- export function configDefaultValue(key: ConfigKey): unknown {
201
- return DEFAULTS[key]
202
- }
203
-
204
- export function globalConfigDir(
205
- env: NodeJS.ProcessEnv = process.env,
206
- platform: NodeJS.Platform = process.platform,
207
- ): string {
208
- return path.join(configHome(env, platform), 'notifai')
209
- }
210
-
211
- export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
212
- return path.join(globalConfigDir(env), 'config.toml')
213
- }
214
-
215
- /**
216
- * Machine-local mutable state: presence markers, pending questions, frozen
217
- * session names, and session config overrides. Separate from the config dir
218
- * because none of it should ever be synced or committed.
219
- */
220
- export function stateDir(
221
- env: NodeJS.ProcessEnv = process.env,
222
- platform: NodeJS.Platform = process.platform,
223
- ): string {
224
- return path.join(stateHome(env, platform), 'notifai')
225
- }
226
-
227
- /** Per-harness-session override file; see the ConfigSource doc comment. */
228
- export function sessionConfigPath(sessionId: string, env: NodeJS.ProcessEnv = process.env): string {
229
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.config.toml`)
230
- }
231
-
232
- /**
233
- * Session ids come from harness JSON, so they reach the filesystem from
234
- * outside. Hash rather than merely sanitising: replacing unsafe characters
235
- * collapsed distinct ids onto one file — `a/b` and `a?b` both became `a_b`, and
236
- * two sessions would then share presence, pending questions and overrides.
237
- * A readable prefix is kept only so the directory is debuggable.
238
- */
239
- export function sanitizeSessionId(sessionId: string): string {
240
- const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 16)
241
- const readable = sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32)
242
- return readable === '' ? digest : `${readable}-${digest}`
243
- }
244
-
245
- /**
246
- * Pointer from a Project directory to the Agent Session currently working in
247
- * it, written by the UserPromptSubmit hook. Without it `notifai ask` has no way
248
- * to learn its own Agent Session id: an ordinary agent shell command receives no hook
249
- * payload, and no harness exports one into the environment.
250
- */
251
- export function projectSessionPointerPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
252
- const digest = createHash('sha256').update(canonicalDir(cwd)).digest('hex').slice(0, 32)
253
- return path.join(stateDir(env), 'projects', `${digest}.json`)
254
- }
255
-
256
- /**
257
- * Resolve symlinks before hashing. The harness reports `cwd` as it knows it
258
- * while a shell reports `process.cwd()` already resolved — on macOS `/tmp` and
259
- * `/private/tmp` are the same directory, so hashing the unresolved paths gave
260
- * two different keys and `notifai ask` could never find the pointer the hook
261
- * had just written.
262
- */
263
- function canonicalDir(dir: string): string {
264
- try {
265
- return realpathSync(path.resolve(dir))
266
- } catch {
267
- return path.resolve(dir)
268
- }
269
- }
270
-
271
- /** Walk up from cwd looking for .notifai/<name> (Project Override). */
272
- function findProjectFile(startDir: string, name: string): string | null {
273
- let dir = path.resolve(startDir)
274
- for (;;) {
275
- const candidate = path.join(dir, '.notifai', name)
276
- if (existsSync(candidate)) return candidate
277
- const parent = path.dirname(dir)
278
- if (parent === dir) return null
279
- dir = parent
280
- }
281
- }
282
-
283
- export function findProjectConfigPath(startDir: string): string | null {
284
- return findProjectFile(startDir, 'config.toml')
285
- }
286
-
287
- /** Walk up from cwd looking for a `.git` file or directory (worktree or checkout). */
288
- function findGitRoot(startDir: string): string | null {
289
- let dir = path.resolve(startDir)
290
- for (;;) {
291
- if (existsSync(path.join(dir, '.git'))) return canonicalDir(dir)
292
- const parent = path.dirname(dir)
293
- if (parent === dir) return null
294
- dir = parent
295
- }
296
- }
297
-
298
- /**
299
- * Stable identity for personal project preferences.
300
- *
301
- * Prefer the directory that owns the shared `.notifai/config.toml`, then the
302
- * git checkout root, then cwd. Subdirectories of one project therefore share
303
- * one personal file, and nothing is written inside the repository.
304
- */
305
- export function personalProjectIdentity(cwd: string): string {
306
- const projectConfig = findProjectConfigPath(cwd)
307
- const root =
308
- projectConfig !== null ? path.dirname(path.dirname(projectConfig)) : (findGitRoot(cwd) ?? cwd)
309
- return createHash('sha256').update(canonicalDir(root)).digest('hex').slice(0, 32)
310
- }
311
-
312
- /** Personal project layer: `$XDG_CONFIG_HOME/notifai/projects/<identity>.toml`. */
313
- export function personalProjectConfigPath(
314
- cwd: string,
315
- env: NodeJS.ProcessEnv = process.env,
316
- ): string {
317
- return path.join(globalConfigDir(env), 'projects', `${personalProjectIdentity(cwd)}.toml`)
318
- }
319
-
320
- /** Existing personal project file, or null when this checkout has none. */
321
- export function findProjectLocalConfigPath(
322
- startDir: string,
323
- env: NodeJS.ProcessEnv = process.env,
324
- ): string | null {
325
- const file = personalProjectConfigPath(startDir, env)
326
- return existsSync(file) ? file : null
327
- }
328
-
329
- /**
330
- * Ranges every layer is clamped to. Config is readable from a repository, so an
331
- * out-of-range value is attacker input, not a typo.
332
- */
333
- const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
334
- wait_seconds: { min: 0, max: 300 },
335
- ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
336
- // Six minutes is the supported terminal-first preference bound. The Stop
337
- // owner carries separate startup headroom, so this never shortens the answer
338
- // window that begins after submission.
339
- ask_grace_seconds: { min: 0, max: 360 },
340
- // The floor is the shortest window the wire contract accepts; the ceiling is
341
- // the point past which a question would outlive the retained content it
342
- // refers to.
343
- reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
344
- // The floor keeps rotation from thrashing (a file smaller than a handful of
345
- // records would rotate on nearly every write); the ceiling is the point past
346
- // which "local diagnostics" has become "a database on your disk".
347
- log_max_bytes: { min: 64_000, max: 100_000_000 },
348
- log_max_files: { min: 1, max: 20 },
349
- }
350
-
351
- /** Drops values a layer must not be trusted to supply, rather than throwing. */
352
- function coerce(key: ConfigKey, value: unknown): unknown | undefined {
353
- const bounds = NUMERIC_BOUNDS[key]
354
- if (bounds !== undefined) {
355
- if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
356
- return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
357
- }
358
- if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
359
- if (ORIGIN_LIST_CONFIG_KEYS.includes(key)) {
360
- // A trust rule is all-or-nothing: one malformed entry drops the whole
361
- // value rather than silently enforcing a subset of what was written.
362
- if (!Array.isArray(value)) return undefined
363
- const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
364
- return origins.every((origin): origin is string => origin !== null) ? origins : undefined
365
- }
366
- if (key === 'sound') {
367
- return typeof value === 'string' && isCliSoundRef(value) ? value : undefined
368
- }
369
- const allowed = ENUM_CONFIG_VALUES[key]
370
- if (allowed !== undefined) {
371
- return typeof value === 'string' && allowed.includes(value) ? value : undefined
372
- }
373
- return value
374
- }
375
-
376
- export function configBounds(key: ConfigKey): { min: number; max: number } | undefined {
377
- return NUMERIC_BOUNDS[key]
378
- }
379
-
380
- function readTomlFile(filePath: string): Record<string, unknown> {
381
- try {
382
- return parseToml(readFileSync(filePath, 'utf8')) as Record<string, unknown>
383
- } catch (err) {
384
- throw new Error(`Could not parse ${filePath}: ${String(err)}`)
385
- }
386
- }
387
-
388
- export interface FlagOverrides {
389
- base_url?: string
390
- wait_seconds?: number
391
- ttl_seconds?: number
392
- collapse_key?: string | null
393
- devices?: string[]
394
- sound?: string
395
- interruption_level?: CliInterruptionLevel
396
- }
397
-
398
- /**
399
- * Service origin is not a user setting. Flag and env are pairing/self-host
400
- * overrides for unsigned-in traffic (login). Signed-in traffic always uses
401
- * the origin stored with the Machine Credential.
402
- */
403
- function resolveServiceUrl(
404
- flags: FlagOverrides,
405
- env: NodeJS.ProcessEnv,
406
- ): ResolvedValue<string> {
407
- if (typeof flags.base_url === 'string' && flags.base_url !== '') {
408
- return { value: flags.base_url, source: 'flag' }
409
- }
410
- const fromEnv = env['NOTIFAI_BASE_URL']
411
- if (typeof fromEnv === 'string' && fromEnv !== '') {
412
- return { value: fromEnv, source: 'env' }
413
- }
414
- return { value: DEFAULT_BASE_URL, source: 'default' }
415
- }
416
-
417
- export function loadConfig(options: {
418
- cwd?: string
419
- env?: NodeJS.ProcessEnv
420
- flags?: FlagOverrides
421
- /** Agent Session id; enables the Agent Session override layer when present. */
422
- sessionId?: string | undefined
423
- }): CliConfig {
424
- const env = options.env ?? process.env
425
- const flags = options.flags ?? {}
426
- const globalPath = globalConfigPath(env)
427
- const globalRaw = existsSync(globalPath) ? readTomlFile(globalPath) : {}
428
- const cwd = options.cwd ?? process.cwd()
429
- const projectPath = findProjectConfigPath(cwd)
430
- const projectRaw = projectPath ? readTomlFile(projectPath) : {}
431
- const projectLocalPath = findProjectLocalConfigPath(cwd, env)
432
- const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
433
- const sessionId = options.sessionId ?? env['NOTIFAI_SESSION_ID']
434
- const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
435
- const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
436
-
437
- function resolve<K extends ConfigKey>(key: K): ResolvedValue<never> {
438
- // The shared project file arrives with a clone, so keys that widen
439
- // network trust never read from it — whatever a repository writes there.
440
- const repositoryLayerReadable = !USER_OWNED_CONFIG_KEYS.includes(key)
441
- const layers: [unknown, ConfigSource][] = [
442
- [(flags as Record<string, unknown>)[key], 'flag'],
443
- [sessionPath ? sessionRaw[key] : undefined, `session:${sessionPath ?? ''}`],
444
- [projectLocalPath ? projectLocalRaw[key] : undefined, `project-local:${projectLocalPath ?? ''}`],
445
- [projectPath && repositoryLayerReadable ? projectRaw[key] : undefined, `project:${projectPath ?? ''}`],
446
- [globalRaw[key], `global:${globalPath}`],
447
- ]
448
- for (const [raw, source] of layers) {
449
- if (raw === undefined) continue
450
- const coerced = coerce(key, raw)
451
- if (coerced === undefined) continue
452
- return { value: coerced as never, source }
453
- }
454
- return { value: DEFAULTS[key] as never, source: 'default' }
455
- }
456
-
457
- return {
458
- base_url: resolveServiceUrl(flags, env),
459
- wait_seconds: resolve('wait_seconds'),
460
- ttl_seconds: resolve('ttl_seconds'),
461
- collapse_key: resolve('collapse_key'),
462
- devices: resolve('devices'),
463
- sound: resolve('sound'),
464
- interruption_level: resolve('interruption_level'),
465
- project: resolve('project'),
466
- ask_notifications: resolve('ask_notifications'),
467
- ask_grace_seconds: resolve('ask_grace_seconds'),
468
- reply_window_seconds: resolve('reply_window_seconds'),
469
- log_level: resolve('log_level'),
470
- log_max_bytes: resolve('log_max_bytes'),
471
- log_max_files: resolve('log_max_files'),
472
- media_origins: resolve('media_origins'),
473
- approve_origins: resolve('approve_origins'),
474
- }
475
- }