@raidiant/notifai 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
@@ -1,7 +1,8 @@
1
1
  import { createServer, type Server } from 'node:http'
2
2
  import type { AddressInfo } from 'node:net'
3
3
  import { afterEach, describe, expect, it } from 'vitest'
4
- import { NetworkError, createClient } from './client.js'
4
+ import { CAPABILITIES_HEADER, CLI_VERSION_HEADER } from '@raidiant/notifai-protocol'
5
+ import { ApiCallError, NetworkError, createClient } from './client.js'
5
6
 
6
7
  /**
7
8
  * These run against a real socket rather than a stubbed fetch, because the
@@ -192,3 +193,91 @@ describe('a server that never answers', () => {
192
193
  await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
193
194
  })
194
195
  })
196
+
197
+ describe('compatibility metadata transport', () => {
198
+ it('advertises named CLI capabilities only on authenticated traffic', async () => {
199
+ const seen: Array<{
200
+ url: string | undefined
201
+ cliVersion: string | string[] | undefined
202
+ capabilities: string | string[] | undefined
203
+ }> = []
204
+ const baseUrl = await serving((request, response) => {
205
+ seen.push({
206
+ url: request.url,
207
+ cliVersion: request.headers[CLI_VERSION_HEADER],
208
+ capabilities: request.headers[CAPABILITIES_HEADER],
209
+ })
210
+ response.setHeader('content-type', 'application/json')
211
+ response.end(
212
+ JSON.stringify({
213
+ schema_version: 1,
214
+ platform: 'macos',
215
+ payload_limit_bytes: 4096,
216
+ sounds: [],
217
+ interruption_levels: [],
218
+ fields: [],
219
+ }),
220
+ )
221
+ })
222
+ const options = {
223
+ cliVersion: '5.0.0',
224
+ capabilities: ['agent_acknowledgement'] as const,
225
+ }
226
+
227
+ await createClient(baseUrl, 'Bearer machine', options).capabilities('macos', '0.1.0', '42')
228
+ await createClient(baseUrl, null, options).capabilities('macos')
229
+
230
+ expect(seen).toEqual([
231
+ {
232
+ url: '/api/v1/capabilities/macos?app_version=0.1.0&app_build=42',
233
+ cliVersion: '5.0.0',
234
+ capabilities: 'agent_acknowledgement',
235
+ },
236
+ {
237
+ url: '/api/v1/capabilities/macos',
238
+ cliVersion: undefined,
239
+ capabilities: undefined,
240
+ },
241
+ ])
242
+ })
243
+
244
+ it('keeps recovery typed when a feature is unavailable', async () => {
245
+ const baseUrl = await serving((_request, response) => {
246
+ response.statusCode = 422
247
+ response.setHeader('content-type', 'application/json')
248
+ response.end(
249
+ JSON.stringify({
250
+ error: {
251
+ code: 'feature_unavailable',
252
+ message: "The selected device can't answer questions.",
253
+ next_action: 'run an untrusted server command',
254
+ recovery_action: 'update_companion',
255
+ details: {
256
+ affected_operation: 'answer_questions',
257
+ missing_capabilities: ['answer'],
258
+ },
259
+ },
260
+ }),
261
+ )
262
+ })
263
+
264
+ let caught: unknown
265
+ try {
266
+ await createClient(baseUrl, 'Bearer machine').compatibility()
267
+ } catch (error) {
268
+ caught = error
269
+ }
270
+
271
+ expect(caught).toBeInstanceOf(ApiCallError)
272
+ expect(caught).toMatchObject({
273
+ status: 422,
274
+ code: 'feature_unavailable',
275
+ recoveryAction: 'update_companion',
276
+ nextAction: 'run an untrusted server command',
277
+ details: {
278
+ affected_operation: 'answer_questions',
279
+ missing_capabilities: ['answer'],
280
+ },
281
+ })
282
+ })
283
+ })
package/src/client.ts CHANGED
@@ -9,6 +9,9 @@ import type {
9
9
  EvidenceSnapshot,
10
10
  GetAgentAcknowledgementResponse,
11
11
  ListDevicesResponse,
12
+ ClientCapability,
13
+ CompatibilityResponse,
14
+ RecoveryAction,
12
15
  ListRepliesResponse,
13
16
  Platform,
14
17
  PollPairingResponse,
@@ -17,6 +20,10 @@ import type {
17
20
  SubmissionReceipt,
18
21
  SubmitNotificationRequestT,
19
22
  } from '@raidiant/notifai-protocol'
23
+ import {
24
+ CAPABILITIES_HEADER,
25
+ CLI_VERSION_HEADER,
26
+ } from '@raidiant/notifai-protocol'
20
27
  import type { Logger } from './logging.js'
21
28
 
22
29
  export class ApiCallError extends Error {
@@ -26,6 +33,7 @@ export class ApiCallError extends Error {
26
33
  message: string,
27
34
  public nextAction: string | null = null,
28
35
  public details: unknown = null,
36
+ public recoveryAction: RecoveryAction | null = null,
29
37
  ) {
30
38
  super(message)
31
39
  }
@@ -67,7 +75,12 @@ export interface ApiClient {
67
75
  pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
68
76
  accessStatus(): Promise<AccountAccessResponse>
69
77
  listDevices(): Promise<ListDevicesResponse>
70
- capabilities(platform?: Platform): Promise<CapabilityDocument>
78
+ capabilities(
79
+ platform?: Platform,
80
+ appVersion?: string,
81
+ appBuild?: string,
82
+ ): Promise<CapabilityDocument>
83
+ compatibility(): Promise<CompatibilityResponse>
71
84
  submit(body: SubmitNotificationRequestT, waitSeconds: number): Promise<SubmissionReceipt>
72
85
  evidence(requestId: string): Promise<EvidenceSnapshot>
73
86
  replies(
@@ -112,6 +125,9 @@ export interface ClientOptions {
112
125
  * instantly by a status code and a duration.
113
126
  */
114
127
  logger?: Pick<Logger, 'debug' | 'error'>
128
+ /** Artifact identity and named jobs advertised only on authenticated traffic. */
129
+ cliVersion?: string | null
130
+ capabilities?: readonly ClientCapability[]
115
131
  }
116
132
 
117
133
  /** Generous enough for a slow link, short enough that nothing hangs for ever. */
@@ -126,6 +142,14 @@ export function createClient(
126
142
  const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
127
143
  const now = options.now ?? Date.now
128
144
  const logger = options.logger
145
+ const clientHeaders: Record<string, string> = bearer
146
+ ? {
147
+ ...(options.cliVersion ? { [CLI_VERSION_HEADER]: options.cliVersion } : {}),
148
+ ...(options.capabilities
149
+ ? { [CAPABILITIES_HEADER]: options.capabilities.join(',') }
150
+ : {}),
151
+ }
152
+ : {}
129
153
 
130
154
  async function call<T>(
131
155
  method: string,
@@ -182,6 +206,7 @@ export function createClient(
182
206
  headers: {
183
207
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
184
208
  ...(bearer ? { authorization: bearer } : {}),
209
+ ...clientHeaders,
185
210
  },
186
211
  signal,
187
212
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@ -202,6 +227,7 @@ export function createClient(
202
227
  parsed?.error.message ?? `Request failed with status ${response.status}`,
203
228
  parsed?.error.next_action ?? null,
204
229
  parsed?.error.details ?? null,
230
+ parsed?.error.recovery_action ?? null,
205
231
  )
206
232
  }
207
233
  if (response.status === 204) return undefined as T
@@ -220,7 +246,14 @@ export function createClient(
220
246
  call('POST', `/api/v1/pairings/${pairingId}/poll`, { poll_verifier: pollVerifier }),
221
247
  accessStatus: () => call('GET', '/api/v1/account/access'),
222
248
  listDevices: () => call('GET', '/api/v1/devices'),
223
- capabilities: (platform = 'ios') => call('GET', `/api/v1/capabilities/${platform}`),
249
+ capabilities: (platform = 'ios', appVersion, appBuild) => {
250
+ const query = new URLSearchParams()
251
+ if (appVersion !== undefined) query.set('app_version', appVersion)
252
+ if (appBuild !== undefined) query.set('app_build', appBuild)
253
+ const suffix = query.size > 0 ? `?${query.toString()}` : ''
254
+ return call('GET', `/api/v1/capabilities/${platform}${suffix}`)
255
+ },
256
+ compatibility: () => call('GET', '/api/v1/compatibility'),
224
257
  submit: (body, waitSeconds) =>
225
258
  call('POST', `/api/v1/notifications?wait_seconds=${waitSeconds}`, body, waitSeconds),
226
259
  evidence: (requestId) => call('GET', `/api/v1/notifications/${requestId}`),
@@ -0,0 +1,174 @@
1
+ import { type AccountAccessResponse } from '@raidiant/notifai-protocol'
2
+ import { sha256Hex } from '@raidiant/notifai-protocol/node'
3
+ import { randomBytes } from 'node:crypto'
4
+ import os from 'node:os'
5
+ import { NetworkError } from './client.js'
6
+ import { type FlagOverrides } from './config.js'
7
+ import {
8
+ EXIT,
9
+ authedClient,
10
+ loadLoggedConfig,
11
+ makeClient,
12
+ reportError,
13
+ type CommandDeps,
14
+ } from './commands-core.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // login / logout / auth status
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export async function loginCommand(
21
+ deps: CommandDeps,
22
+ flags: { name?: string; baseUrl?: string; open?: boolean },
23
+ ): Promise<number> {
24
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
25
+ const baseUrl = config.base_url.value
26
+ const machineName = flags.name ?? os.hostname()
27
+ const secret = randomBytes(32).toString('base64url')
28
+ const pollVerifier = randomBytes(24).toString('base64url')
29
+ const client = makeClient(deps, baseUrl, null)
30
+
31
+ let begin
32
+ try {
33
+ begin = await client.beginPairing({
34
+ machine_name: machineName,
35
+ credential_hash: sha256Hex(secret),
36
+ poll_verifier_hash: sha256Hex(pollVerifier),
37
+ })
38
+ } catch (err) {
39
+ return reportError(deps, err)
40
+ }
41
+
42
+ const interactive = deps.io.interactive === true
43
+ if (interactive) {
44
+ await deps.io.intro?.('Notifai sign in')
45
+ await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
46
+ } else {
47
+ deps.io.out(`Pairing code: ${begin.code}`)
48
+ deps.io.out(`Approve this machine at: ${begin.approve_url}`)
49
+ deps.io.out('Waiting for approval…')
50
+ }
51
+ if (flags.open !== false) deps.io.openUrl(begin.approve_url)
52
+
53
+ const expiresAt = new Date(begin.expires_at).getTime()
54
+ const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
55
+ const now = deps.now ?? Date.now
56
+ const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
57
+ const approvalWaitMessage = (): string => {
58
+ const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
59
+ const minutes = Math.floor(remainingSec / 60)
60
+ const seconds = remainingSec % 60
61
+ const remaining =
62
+ minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
63
+ return `Waiting for approval… code ${begin.code} · ${remaining} left`
64
+ }
65
+ const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null
66
+ while (now() < expiresAt) {
67
+ await sleep(intervalMs)
68
+ let poll
69
+ try {
70
+ poll = await client.pollPairing(begin.pairing_id, pollVerifier)
71
+ } catch (err) {
72
+ if (err instanceof NetworkError) {
73
+ spinner?.message(`Connection lost — retrying… code ${begin.code}`)
74
+ continue
75
+ }
76
+ spinner?.error('Pairing failed')
77
+ return reportError(deps, err)
78
+ }
79
+ if (poll.status === 'approved' && poll.machine_id) {
80
+ deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName })
81
+ if (interactive) {
82
+ spinner?.stop(`Machine "${machineName}" approved`)
83
+ await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`)
84
+ } else {
85
+ deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`)
86
+ }
87
+ return EXIT.ok
88
+ }
89
+ if (poll.status === 'denied') {
90
+ spinner?.error('Pairing denied')
91
+ deps.io.err('Pairing was denied from the dashboard.')
92
+ return EXIT.auth
93
+ }
94
+ if (poll.status === 'no_active_plan') {
95
+ const next =
96
+ poll.next_action ??
97
+ `Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`
98
+ spinner?.error('Account has no Alpha access')
99
+ deps.io.err('This account has no active plan or temporary Alpha access.')
100
+ deps.io.err(`next: ${next}`)
101
+ deps.io.err('After access is granted, run `notifai login` again.')
102
+ return EXIT.auth
103
+ }
104
+ if (poll.status === 'expired') break
105
+ spinner?.message(approvalWaitMessage())
106
+ }
107
+ spinner?.error('Pairing expired')
108
+ deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
109
+ return EXIT.auth
110
+ }
111
+
112
+ export function logoutCommand(deps: CommandDeps): number {
113
+ deps.store.clear()
114
+ deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.')
115
+ return EXIT.ok
116
+ }
117
+
118
+ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }): number {
119
+ const credential = deps.store.load()
120
+ if (flags.json) {
121
+ deps.io.out(
122
+ JSON.stringify(
123
+ credential
124
+ ? {
125
+ signed_in: true,
126
+ machine_id: credential.machineId,
127
+ machine_name: credential.machineName,
128
+ base_url: credential.baseUrl,
129
+ store: deps.store.describe(),
130
+ }
131
+ : { signed_in: false },
132
+ null,
133
+ 2,
134
+ ),
135
+ )
136
+ return credential ? EXIT.ok : EXIT.auth
137
+ }
138
+ if (!credential) {
139
+ deps.io.err('Not signed in. Run `notifai login`.')
140
+ return EXIT.auth
141
+ }
142
+ deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`)
143
+ deps.io.out(`Server: ${credential.baseUrl}`)
144
+ deps.io.out(`Credential store: ${deps.store.describe()}`)
145
+ return EXIT.ok
146
+ }
147
+
148
+ /** Show the server's account access decision without attempting a product mutation. */
149
+ export async function accessStatusCommand(
150
+ deps: CommandDeps,
151
+ flags: { json?: boolean },
152
+ ): Promise<number> {
153
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
154
+ const authed = authedClient(deps, config)
155
+ if (!authed) return EXIT.auth
156
+ try {
157
+ const access: AccountAccessResponse = await authed.client.accessStatus()
158
+ if (flags.json) {
159
+ deps.io.out(JSON.stringify(access, null, 2))
160
+ return access.status === 'active' ? EXIT.ok : EXIT.failed
161
+ }
162
+ if (access.status === 'no_active_plan') {
163
+ const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`
164
+ deps.io.out('No active plan or temporary Alpha access for this account.')
165
+ deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
166
+ return EXIT.failed
167
+ }
168
+ const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
169
+ deps.io.out(`Access active (${access.reason})${expiry}`)
170
+ return EXIT.ok
171
+ } catch (err) {
172
+ return reportError(deps, err)
173
+ }
174
+ }
@@ -0,0 +1,307 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
10
+ import { acceptedValues, configInfo } from './config-schema.js'
11
+ import {
12
+ BOOLEAN_CONFIG_KEYS,
13
+ CONFIG_KEYS,
14
+ NUMERIC_CONFIG_KEYS,
15
+ configBounds,
16
+ configDefaultValue,
17
+ findProjectConfigPath,
18
+ globalConfigPath,
19
+ personalProjectConfigPath,
20
+ sessionConfigPath,
21
+ type ConfigKey,
22
+ } from './config.js'
23
+ import { renderConfigExplain, renderConfigList, renderConfigPlain } from './ui/config-view.js'
24
+ import { EXIT, loadLoggedConfig, type CommandDeps } from './commands-core.js'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // config show / set / unset
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export function configShowCommand(
31
+ deps: CommandDeps,
32
+ flags: { json?: boolean; explain?: boolean; plain?: boolean },
33
+ ): number {
34
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
35
+ if (flags.json) {
36
+ const output = Object.fromEntries(
37
+ CONFIG_KEYS.map((key) => [
38
+ key,
39
+ {
40
+ value: config[key].value,
41
+ source: config[key].source,
42
+ summary: configInfo(key).summary,
43
+ },
44
+ ]),
45
+ )
46
+ deps.io.out(JSON.stringify(output, null, 2))
47
+ return EXIT.ok
48
+ }
49
+ // Anything that is not a person at a terminal keeps the flat `key = value`
50
+ // form it has always had. Scripts parse this, and a prettier layout for an
51
+ // audience that cannot see it would only be a breaking change.
52
+ if (deps.io.interactive !== true || flags.plain === true) {
53
+ for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
54
+ return EXIT.ok
55
+ }
56
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
57
+ deps.io.out(line)
58
+ }
59
+ return EXIT.ok
60
+ }
61
+
62
+ /**
63
+ * One setting, explained in full.
64
+ *
65
+ * The gap this closes: `config show` prints a value and provenance but that is
66
+ * not enough to explain the setting's consequences. Every key already had a
67
+ * careful explanation — in a TypeScript comment, read by everyone except the
68
+ * person who needed it.
69
+ */
70
+ export function configExplainCommand(
71
+ deps: CommandDeps,
72
+ key: string,
73
+ flags: { json?: boolean } = {},
74
+ ): number {
75
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
76
+ deps.io.err(`Unknown setting "${key}".`)
77
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
78
+ return EXIT.usage
79
+ }
80
+ const configKey = key as ConfigKey
81
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
82
+ const info = configInfo(configKey)
83
+ const entry = config[configKey]
84
+
85
+ if (flags.json) {
86
+ deps.io.out(
87
+ JSON.stringify(
88
+ {
89
+ key: configKey,
90
+ label: info.label,
91
+ group: info.group,
92
+ kind: info.kind,
93
+ summary: info.summary,
94
+ detail: info.detail,
95
+ accepts: acceptedValues(configKey),
96
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
97
+ value: entry.value,
98
+ source: entry.source,
99
+ },
100
+ null,
101
+ 2,
102
+ ),
103
+ )
104
+ return EXIT.ok
105
+ }
106
+
107
+ if (deps.io.interactive !== true) {
108
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
109
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'))
110
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`)
111
+ return EXIT.ok
112
+ }
113
+ for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
114
+ return EXIT.ok
115
+ }
116
+
117
+ /**
118
+ * Closest config key by edit distance, or null when nothing is close.
119
+ *
120
+ * The threshold matters more than the algorithm: suggesting a key that shares
121
+ * three letters with the typo sends the reader to the wrong setting with
122
+ * confidence, which is worse than listing all fourteen and letting them look.
123
+ */
124
+ function nearestKey(input: string): string | null {
125
+ let best: string | null = null
126
+ let bestDistance = Number.POSITIVE_INFINITY
127
+ for (const candidate of CONFIG_KEYS) {
128
+ const distance = editDistance(input.toLowerCase(), candidate)
129
+ if (distance < bestDistance) {
130
+ bestDistance = distance
131
+ best = candidate
132
+ }
133
+ }
134
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
135
+ }
136
+
137
+ function editDistance(a: string, b: string): number {
138
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
139
+ for (let i = 1; i <= a.length; i += 1) {
140
+ let diagonal = previous[0]!
141
+ previous[0] = i
142
+ for (let j = 1; j <= b.length; j += 1) {
143
+ const above = previous[j]!
144
+ previous[j] = Math.min(
145
+ previous[j]! + 1,
146
+ previous[j - 1]! + 1,
147
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
148
+ )
149
+ diagonal = above
150
+ }
151
+ }
152
+ return previous[b.length]!
153
+ }
154
+
155
+ export async function configSetCommand(
156
+ deps: CommandDeps,
157
+ key: string,
158
+ rawValue: string,
159
+ flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
160
+ ): Promise<number> {
161
+ const configKey = configMutationKey(deps, key)
162
+ if (configKey === null) return EXIT.usage
163
+ const info = configInfo(configKey)
164
+ let value: unknown = rawValue
165
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
166
+ const numeric = Number(rawValue)
167
+ if (!Number.isInteger(numeric)) {
168
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
169
+ return EXIT.usage
170
+ }
171
+ const bounds = configBounds(configKey)
172
+ if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
173
+ deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
174
+ return EXIT.usage
175
+ }
176
+ value = numeric
177
+ }
178
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
179
+ if (rawValue !== 'true' && rawValue !== 'false') {
180
+ deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
181
+ return EXIT.usage
182
+ }
183
+ value = rawValue === 'true'
184
+ }
185
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
186
+ // value the sender would later reject — a typo that only surfaces at the
187
+ // moment a notification fails to carry the sound you asked for.
188
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
189
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
190
+ return EXIT.usage
191
+ }
192
+ if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
193
+
194
+ const target = await configMutationTarget(deps, flags)
195
+ if (target === null) return EXIT.usage
196
+ if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
197
+ deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
198
+ deps.io.err(
199
+ `Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`,
200
+ )
201
+ return EXIT.usage
202
+ }
203
+
204
+ if (!flags.yes) {
205
+ const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`)
206
+ if (!confirmed) {
207
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
208
+ return EXIT.usage
209
+ }
210
+ }
211
+
212
+ const existing = existsSync(target.path)
213
+ ? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
214
+ : {}
215
+ existing[key] = value
216
+ mkdirSync(path.dirname(target.path), { recursive: true })
217
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`)
218
+ deps.io.out(`Wrote ${key} to ${target.path}`)
219
+ return EXIT.ok
220
+ }
221
+
222
+ function configMutationKey(deps: CommandDeps, key: string): ConfigKey | null {
223
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
224
+ deps.io.err(`Unknown setting "${key}".`)
225
+ const near = nearestKey(key)
226
+ if (near !== null) deps.io.err(`Did you mean "${near}"?`)
227
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
228
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
229
+ return null
230
+ }
231
+ return key as ConfigKey
232
+ }
233
+
234
+ type ConfigMutationFlags = { project?: boolean; local?: boolean; session?: string; yes?: boolean }
235
+ type ConfigMutationLayer = 'global' | 'project' | 'local' | 'session'
236
+
237
+ async function configMutationTarget(
238
+ deps: CommandDeps,
239
+ flags: ConfigMutationFlags,
240
+ ): Promise<{ path: string; layer: ConfigMutationLayer } | null> {
241
+ let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
242
+ if (
243
+ flags.session === undefined &&
244
+ flags.local !== true &&
245
+ flags.project !== true &&
246
+ flags.yes !== true &&
247
+ deps.io.interactive === true &&
248
+ deps.io.select
249
+ ) {
250
+ const selected = await deps.io.select('Where should this setting live?', [
251
+ { value: 'global', label: 'This machine', hint: 'applies across projects' },
252
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
253
+ { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
254
+ ])
255
+ if (selected === null) {
256
+ deps.io.err('No configuration layer selected.')
257
+ return null
258
+ }
259
+ layer = selected
260
+ }
261
+
262
+ const targetPath = flags.session
263
+ ? sessionConfigPath(flags.session, deps.env)
264
+ : layer === 'local'
265
+ ? personalProjectConfigPath(deps.cwd, deps.env)
266
+ : layer === 'project'
267
+ ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
268
+ : globalConfigPath(deps.env)
269
+ return {
270
+ path: targetPath,
271
+ layer: flags.session ? 'session' : (layer as Exclude<ConfigMutationLayer, 'session'>),
272
+ }
273
+ }
274
+
275
+ export async function configUnsetCommand(
276
+ deps: CommandDeps,
277
+ key: string,
278
+ flags: ConfigMutationFlags,
279
+ ): Promise<number> {
280
+ const configKey = configMutationKey(deps, key)
281
+ if (configKey === null) return EXIT.usage
282
+ const target = await configMutationTarget(deps, flags)
283
+ if (target === null) return EXIT.usage
284
+ const existing = existsSync(target.path)
285
+ ? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
286
+ : {}
287
+ if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
288
+ deps.io.out(`${configKey} is not set in ${target.path}`)
289
+ return EXIT.ok
290
+ }
291
+ if (!flags.yes) {
292
+ const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`)
293
+ if (!confirmed) {
294
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
295
+ return EXIT.usage
296
+ }
297
+ }
298
+
299
+ delete existing[configKey]
300
+ if (Object.keys(existing).length === 0) {
301
+ rmSync(target.path, { force: true })
302
+ } else {
303
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`)
304
+ }
305
+ deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`)
306
+ return EXIT.ok
307
+ }