@raidiant/notifai 5.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 (104) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -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 -387
  52. package/dist/commands.js +12 -4536
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +1 -1
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +1 -1
  57. package/dist/config.js +3 -1
  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/hooks.d.ts +2 -2
  62. package/dist/hooks.js +127 -99
  63. package/dist/hooks.js.map +1 -1
  64. package/dist/interactive.js +4 -4
  65. package/dist/interactive.js.map +1 -1
  66. package/dist/logging.js +1 -0
  67. package/dist/logging.js.map +1 -1
  68. package/dist/main.js +1 -1
  69. package/dist/main.js.map +1 -1
  70. package/dist/readiness.d.ts +3 -1
  71. package/dist/readiness.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/client.test.ts +90 -1
  74. package/src/client.ts +35 -2
  75. package/src/commands-auth.ts +174 -0
  76. package/src/commands-config.ts +307 -0
  77. package/src/commands-core.ts +240 -0
  78. package/src/commands-devices.ts +150 -0
  79. package/src/commands-doctor.ts +1209 -0
  80. package/src/commands-harness-context.ts +126 -0
  81. package/src/commands-hook-shape.ts +53 -0
  82. package/src/commands-hooks.ts +1586 -0
  83. package/src/commands-init.ts +721 -0
  84. package/src/commands-io.ts +108 -0
  85. package/src/commands-logs.ts +243 -0
  86. package/src/commands-send-support.ts +224 -0
  87. package/src/commands-send.ts +623 -0
  88. package/src/commands-setup-proof.ts +71 -0
  89. package/src/commands-skill.ts +84 -0
  90. package/src/commands.test.ts +493 -39
  91. package/src/commands.ts +58 -5614
  92. package/src/config-schema.ts +1 -1
  93. package/src/config.test.ts +9 -0
  94. package/src/config.ts +7 -2
  95. package/src/credentials.test.ts +25 -1
  96. package/src/credentials.ts +13 -3
  97. package/src/hooks.test.ts +351 -11
  98. package/src/hooks.ts +145 -97
  99. package/src/interactive.test.ts +19 -1
  100. package/src/interactive.ts +4 -4
  101. package/src/logging.test.ts +15 -2
  102. package/src/logging.ts +1 -0
  103. package/src/main.ts +1 -1
  104. package/src/readiness.ts +3 -1
@@ -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
+ }
@@ -0,0 +1,240 @@
1
+ import { SHIPPED_CLI_CAPABILITIES, type RecoveryAction } from '@raidiant/notifai-protocol'
2
+ import { type ClaudeWakeAdapters } from './claude-wake.js'
3
+ import {
4
+ ApiCallError,
5
+ NetworkError,
6
+ createClient,
7
+ type ApiClient,
8
+ type ClientOptions,
9
+ } from './client.js'
10
+ import { type CodexWakeAdapters } from './codex-wake.js'
11
+ import { loadConfig, type CliConfig } from './config.js'
12
+ import type { CredentialStore, MachineCredential } from './credentials.js'
13
+ import { type HookAdapterTarget } from './hook-adapter.js'
14
+ import { logConfigResolved, logSettingsFrom, nullLogger, type Logger } from './logging.js'
15
+ import type { NativeSkills } from './native-skills.js'
16
+ import { packageVersion } from './release.js'
17
+ import type { Tone } from './ui/theme.js'
18
+
19
+ export interface CommandIo {
20
+ out(line: string): void
21
+ err(line: string): void
22
+ /** Interactive confirmation; resolves `fallback` (default false) when not interactive. */
23
+ confirm(question: string, fallback?: boolean): Promise<boolean>
24
+ openUrl(url: string): void
25
+ /**
26
+ * True only when a human is demonstrably driving a terminal. Everything below
27
+ * is optional sugar that MUST only be called behind this flag: an agent that
28
+ * reaches an interactive prompt does not error, it hangs — the prompt
29
+ * libraries wait on stdin for ever — so the gate is bypass, not handling.
30
+ * Test fakes leave all of this undefined and exercise the plain paths.
31
+ */
32
+ interactive?: boolean
33
+ select?(message: string, options: { value: string; label: string; hint?: string }[]): Promise<string | null>
34
+ /** Interactive multi-select. `initial` are pre-selected values. */
35
+ multiselect?(
36
+ message: string,
37
+ options: { value: string; label: string; hint?: string }[],
38
+ initial?: string[],
39
+ ): Promise<string[] | null>
40
+ intro?(title: string): Promise<void>
41
+ outro?(message: string): Promise<void>
42
+ note?(message: string, title?: string): Promise<void>
43
+ spinner?(message: string): Promise<CommandSpinner | null>
44
+ /**
45
+ * A report line. `tone` distinguishes the two states that are neither pass
46
+ * nor fail — something optional the user declined, and something that was
47
+ * never evaluated — which a boolean has to round to one or the other.
48
+ * Optional so existing implementations keep type-checking.
49
+ */
50
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>
51
+ }
52
+
53
+ export interface CommandSpinner {
54
+ message(message: string): void
55
+ stop(message: string): void
56
+ error(message: string): void
57
+ }
58
+
59
+ export interface CommandDeps {
60
+ io: CommandIo
61
+ store: CredentialStore
62
+ env: NodeJS.ProcessEnv
63
+ cwd: string
64
+ /** Test seam; production fixes the hook adapter under os.homedir(). */
65
+ hookAdapterHome?: string
66
+ /** Test seam; production uses this process's Node and CLI paths. */
67
+ hookInstallTarget?: HookAdapterTarget
68
+ /** Test seam; production uses process.platform for adapter format and quoting. */
69
+ hookPlatform?: NodeJS.Platform
70
+ /** Test seam; production uses fetch against base_url. */
71
+ clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
72
+ /** Test seam for bounded polling without wall-clock sleeps. */
73
+ now?: () => number
74
+ /** Test seam for retry/backoff timing. */
75
+ sleep?: (milliseconds: number) => Promise<void>
76
+ /** Test seams for Claude liveness, socket delivery, and cold resume. */
77
+ claudeWake?: ClaudeWakeAdapters
78
+ claudeSourcePid?: number
79
+ /** Test seams for the Codex thread-writer probe and cold resume. */
80
+ codexWake?: CodexWakeAdapters
81
+ codexSourcePid?: number
82
+ /** Test seam and production adapter for the external native skills installer. */
83
+ nativeSkills?: NativeSkills
84
+ /**
85
+ * The local record of what this invocation did. Optional so a test fake need
86
+ * not carry one; `log(deps)` supplies a logger that records nothing.
87
+ */
88
+ logger?: Logger
89
+ }
90
+
91
+ /** The invocation's logger, or one that discards everything. */
92
+ export function log(deps: CommandDeps): Logger {
93
+ return deps.logger ?? nullLogger()
94
+ }
95
+
96
+ export function loadLoggedConfig(
97
+ deps: CommandDeps,
98
+ options: Parameters<typeof loadConfig>[0],
99
+ ): CliConfig {
100
+ const config = loadConfig(options)
101
+ const logger = log(deps)
102
+ logger.adopt(logSettingsFrom(config))
103
+ logger.bind({ project: config.project.value })
104
+ logConfigResolved(logger, config)
105
+ return config
106
+ }
107
+
108
+ export const EXIT = {
109
+ ok: 0,
110
+ failed: 1,
111
+ usage: 2,
112
+ noReply: 3,
113
+ auth: 4,
114
+ network: 5,
115
+ } as const
116
+
117
+ export function makeClient(
118
+ deps: CommandDeps,
119
+ baseUrl: string,
120
+ bearer: string | null,
121
+ options?: ClientOptions,
122
+ ): ApiClient {
123
+ return (deps.clientFactory ?? createClient)(baseUrl, bearer, {
124
+ ...options,
125
+ logger: log(deps),
126
+ cliVersion: packageVersion(),
127
+ capabilities: SHIPPED_CLI_CAPABILITIES,
128
+ })
129
+ }
130
+
131
+ export function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
132
+ return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value
133
+ }
134
+
135
+ export function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
136
+ const credential = deps.store.load()
137
+ if (!credential) {
138
+ // The commonest reason a command does nothing, and one that leaves no other
139
+ // trace: it returns before any request is made, so without this the log
140
+ // shows an exit code and no cause.
141
+ log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
142
+ deps.io.err('Not signed in. Run `notifai login` first.')
143
+ return null
144
+ }
145
+ const baseUrl = resolvedBaseUrl(config, credential)
146
+ return {
147
+ client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
148
+ baseUrl,
149
+ }
150
+ }
151
+
152
+ export const UPDATE_CLI_COMMAND = 'npm install -g @raidiant/notifai'
153
+
154
+ function localRecovery(action: RecoveryAction | null): string | null {
155
+ switch (action) {
156
+ case 'update_cli':
157
+ return `next: ${UPDATE_CLI_COMMAND}`
158
+ case 'update_companion':
159
+ return 'next: Update Notifai on the named device.'
160
+ case 'wait_for_service':
161
+ return 'next: The service is being updated; try again later.'
162
+ default:
163
+ return null
164
+ }
165
+ }
166
+
167
+ export function reportError(
168
+ deps: CommandDeps,
169
+ err: unknown,
170
+ context: Record<string, unknown> = {},
171
+ ): number {
172
+ // Recorded before it is printed: stderr from an agent's shell call is often
173
+ // the one thing that does not survive into the next turn, and this is exactly
174
+ // the line someone comes back looking for.
175
+ log(deps).error('cli.error', {
176
+ ...context,
177
+ ...(err instanceof ApiCallError
178
+ ? {
179
+ kind: 'api',
180
+ status: err.status,
181
+ code: err.code,
182
+ message: err.message,
183
+ next_action: err.nextAction,
184
+ recovery_action: err.recoveryAction,
185
+ details: err.details,
186
+ }
187
+ : err instanceof NetworkError
188
+ ? { kind: 'network', message: err.message }
189
+ : { kind: 'unknown', message: String(err) }),
190
+ })
191
+ if (err instanceof ApiCallError) {
192
+ deps.io.err(
193
+ deps.io.interactive === true && err.recoveryAction !== null
194
+ ? err.message
195
+ : `${err.code}: ${err.message}`,
196
+ )
197
+ // The server's field-level details were already being logged above, but
198
+ // stderr is the one surface an agent's next turn actually reads — so the
199
+ // diagnosis has to be on it, the same way the hook path prints it.
200
+ const paths = rejectedPaths(err.details)
201
+ if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
202
+ // A 422 on a request this CLI built is not a user error: the two sides
203
+ // disagree about the contract, in whichever direction. `doctor` says which.
204
+ // Codes that name a policy the account chose are the exception — the
205
+ // contract held, the body simply did not satisfy that account.
206
+ if (
207
+ err.status === 422 &&
208
+ err.code !== 'acknowledgement_text_required' &&
209
+ err.code !== 'feature_unavailable'
210
+ ) {
211
+ deps.io.err(
212
+ 'this build sent a field the server did not accept — the CLI and server ' +
213
+ 'disagree about the contract; check with `notifai doctor`',
214
+ )
215
+ }
216
+ const recovery = localRecovery(err.recoveryAction)
217
+ if (recovery !== null) deps.io.err(recovery)
218
+ else if (err.recoveryAction === null && err.nextAction) deps.io.err(`next: ${err.nextAction}`)
219
+ if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
220
+ return err.status >= 500 ? EXIT.network : EXIT.failed
221
+ }
222
+ if (err instanceof NetworkError) {
223
+ deps.io.err(err.message)
224
+ return EXIT.network
225
+ }
226
+ deps.io.err(String(err))
227
+ return EXIT.failed
228
+ }
229
+
230
+ export function rejectedPaths(details: unknown): string[] {
231
+ if (!Array.isArray(details)) return []
232
+ return details
233
+ .map((issue) =>
234
+ typeof issue === 'object' && issue !== null && typeof (issue as { path?: unknown }).path === 'string'
235
+ ? (issue as { path: string }).path
236
+ : null,
237
+ )
238
+ .filter((path): path is string => path !== null && path !== '')
239
+ .slice(0, 5)
240
+ }
@@ -0,0 +1,150 @@
1
+ import { PLATFORMS, type Platform, type RoutableDevice } from '@raidiant/notifai-protocol'
2
+ import {
3
+ EXIT,
4
+ authedClient,
5
+ loadLoggedConfig,
6
+ makeClient,
7
+ reportError,
8
+ resolvedBaseUrl,
9
+ type CommandDeps,
10
+ } from './commands-core.js'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // devices / capabilities
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
17
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
18
+ const authed = authedClient(deps, config)
19
+ if (!authed) return EXIT.auth
20
+ try {
21
+ const result = await authed.client.listDevices()
22
+ if (flags.json) {
23
+ deps.io.out(JSON.stringify(result, null, 2))
24
+ return EXIT.ok
25
+ }
26
+ if (result.devices.length === 0) {
27
+ const supportUrl = supportPageUrl(authed.baseUrl)
28
+ let email: string | null = null
29
+ try {
30
+ email = (await authed.client.accessStatus()).email
31
+ } catch {
32
+ // Best-effort: the empty-state copy still points at /support without it.
33
+ }
34
+ deps.io.out(
35
+ `No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
36
+ )
37
+ return EXIT.ok
38
+ }
39
+ for (const d of result.devices) {
40
+ deps.io.out(
41
+ `${d.device_id} ${d.display_name} ${d.platform} ${d.status_message ?? 'Working'}`,
42
+ )
43
+ }
44
+ return EXIT.ok
45
+ } catch (err) {
46
+ return reportError(deps, err)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Devices as data, for surfaces that render them themselves.
52
+ *
53
+ * `devicesCommand` prints and returns an exit code, which is the right shape
54
+ * for a command and the wrong one for a menu that wants to show names beside
55
+ * checkboxes. Silent on every failure by design: the interactive app already
56
+ * shows the credential and connectivity state on its status card, so a second
57
+ * error line here would be reporting the same fault twice.
58
+ */
59
+ export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
60
+ if (!deps.store.load()) return null
61
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
62
+ const authed = authedClient(deps, config)
63
+ if (!authed) return null
64
+ try {
65
+ return (await authed.client.listDevices()).devices
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Whether a device is in a state where a notification would actually arrive. */
72
+ export function canDeviceReceive(device: RoutableDevice): boolean {
73
+ return deviceCanReceive(device)
74
+ }
75
+
76
+ export async function capabilitiesCommand(
77
+ deps: CommandDeps,
78
+ flags: { json?: boolean; platform?: Platform },
79
+ ): Promise<number> {
80
+ // Locally, and with the same message `send` gives. Spending a round trip to
81
+ // have the server answer "Request validation failed" told the caller neither
82
+ // which flag was wrong nor what it accepts.
83
+ if (flags.platform !== undefined && !(PLATFORMS as readonly string[]).includes(flags.platform)) {
84
+ deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`)
85
+ return EXIT.usage
86
+ }
87
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
88
+ const credential = deps.store.load()
89
+ const baseUrl = resolvedBaseUrl(config, credential)
90
+ const client = makeClient(deps, baseUrl, null)
91
+ try {
92
+ const doc = await client.capabilities(flags.platform ?? 'ios')
93
+ if (flags.json) {
94
+ deps.io.out(JSON.stringify(doc, null, 2))
95
+ return EXIT.ok
96
+ }
97
+ deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`)
98
+ for (const field of doc.fields) {
99
+ deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`)
100
+ }
101
+ return EXIT.ok
102
+ } catch (err) {
103
+ return reportError(deps, err)
104
+ }
105
+ }
106
+
107
+ export function supportPageUrl(baseUrl: string): string {
108
+ return `${baseUrl.replace(/\/$/, '')}/support`
109
+ }
110
+
111
+ /** Same-account line for the device hop; prefers the real email when known. */
112
+ export function sameAccountSignInLine(email: string | null | undefined): string {
113
+ return email
114
+ ? `sign in with the same email as this account (${email})`
115
+ : 'sign in with the same email as this account'
116
+ }
117
+
118
+ export function deviceInstallRemedy(options: {
119
+ baseUrl: string
120
+ email: string | null
121
+ devices: readonly RoutableDevice[]
122
+ }): string {
123
+ const support = supportPageUrl(options.baseUrl)
124
+ const sameEmail = sameAccountSignInLine(options.email)
125
+ if (options.devices.length === 0) {
126
+ return (
127
+ `open the install steps at ${support} on your iPhone, ` +
128
+ `install Notifai, ${sameEmail}, and allow notifications`
129
+ )
130
+ }
131
+ if (options.devices.some((d) => d.permission_status === 'denied')) {
132
+ return 'allow notifications for Notifai in iPhone Settings'
133
+ }
134
+ return 'open Notifai on your iPhone and allow its notification prompt'
135
+ }
136
+
137
+ export function deviceCanReceive(device: RoutableDevice): boolean {
138
+ const receiveIsFloored =
139
+ device.support?.state === 'must_update' &&
140
+ device.support.affected_operation === 'receive_notifications'
141
+ return (
142
+ !receiveIsFloored &&
143
+ device.registration_healthy &&
144
+ (device.permission_status === 'authorized' || device.permission_status === 'provisional')
145
+ )
146
+ }
147
+
148
+ export function readyIosDevices(devices: readonly RoutableDevice[]): RoutableDevice[] {
149
+ return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device))
150
+ }