@raidiant/notifai 10.1.0 → 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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,188 +0,0 @@
1
- import { createHash, createHmac, randomBytes } from 'node:crypto'
2
- import {
3
- closeSync,
4
- constants,
5
- existsSync,
6
- fstatSync,
7
- lstatSync,
8
- openSync,
9
- readFileSync,
10
- readdirSync,
11
- rmSync,
12
- } from 'node:fs'
13
- import path from 'node:path'
14
- import type { NotificationDraftT } from '@raidiant/notifai-protocol'
15
- import { atomicWriteFileSync } from './atomic-file.js'
16
- import { stateDir } from './config.js'
17
- import type { MachineCredential } from './credentials.js'
18
-
19
- interface SendAttemptRecord {
20
- version: 1
21
- attempt_id: string
22
- fingerprint: string
23
- idempotency_key: string
24
- credential_tag: string
25
- machine_id: string
26
- service: string
27
- created_at: string
28
- expires_at: string
29
- }
30
-
31
- export const SEND_ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000
32
-
33
- /** Stable pre-upload media identities used only inside the keyed draft digest. */
34
- export function semanticMediaIds(images: readonly string[], cwd: string): string[] {
35
- return images.map((image) => {
36
- if (image.startsWith('med_')) return image
37
- const digest = createHash('sha256')
38
- if (/^https?:\/\//i.test(image)) digest.update(`url\0${image}`)
39
- else digest.update('file\0').update(readFileSync(path.resolve(cwd, image)))
40
- return `med_retry_${digest.digest('base64url')}`
41
- })
42
- }
43
-
44
- function directory(env: NodeJS.ProcessEnv): string {
45
- return path.join(stateDir(env), 'send-attempts')
46
- }
47
-
48
- function credentialTag(credential: MachineCredential): string {
49
- return createHmac('sha256', credential.secret)
50
- .update('notifai-send-attempt-credential-v1')
51
- .digest('base64url')
52
- }
53
-
54
- /** A keyed digest proves equality without persisting notification content. */
55
- export function sendDraftFingerprint(
56
- draft: NotificationDraftT,
57
- credential: MachineCredential,
58
- ): string {
59
- return createHmac('sha256', credential.secret)
60
- .update('notifai-send-draft-v1\0')
61
- .update(JSON.stringify(draft))
62
- .digest('base64url')
63
- }
64
-
65
- function parseRecord(file: string): SendAttemptRecord | null {
66
- let descriptor: number | undefined
67
- try {
68
- const before = lstatSync(file)
69
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
70
- if (before.isSymbolicLink() || !before.isFile() || (uid !== undefined && before.uid !== uid)) {
71
- return null
72
- }
73
- const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW)
74
- descriptor = openSync(file, flags)
75
- const opened = fstatSync(descriptor)
76
- if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
77
- const value = JSON.parse(readFileSync(descriptor, 'utf8')) as Partial<SendAttemptRecord>
78
- return value.version === 1 &&
79
- typeof value.attempt_id === 'string' &&
80
- typeof value.fingerprint === 'string' &&
81
- typeof value.idempotency_key === 'string' &&
82
- typeof value.credential_tag === 'string' &&
83
- typeof value.machine_id === 'string' &&
84
- typeof value.service === 'string' &&
85
- typeof value.created_at === 'string' &&
86
- typeof value.expires_at === 'string'
87
- ? value as SendAttemptRecord
88
- : null
89
- } catch {
90
- return null
91
- } finally {
92
- if (descriptor !== undefined) closeSync(descriptor)
93
- }
94
- }
95
-
96
- function liveRecords(
97
- env: NodeJS.ProcessEnv,
98
- credential: MachineCredential,
99
- now: number,
100
- ): SendAttemptRecord[] {
101
- const dir = directory(env)
102
- if (!existsSync(dir)) return []
103
- const directoryStat = lstatSync(dir)
104
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
105
- if (
106
- directoryStat.isSymbolicLink() ||
107
- !directoryStat.isDirectory() ||
108
- (uid !== undefined && directoryStat.uid !== uid)
109
- ) return []
110
- const tag = credentialTag(credential)
111
- const records: SendAttemptRecord[] = []
112
- for (const name of readdirSync(dir)) {
113
- if (!name.endsWith('.json')) continue
114
- const file = path.join(dir, name)
115
- const record = parseRecord(file)
116
- const expired = record === null || Date.parse(record.expires_at) <= now
117
- const rotated = record !== null && (
118
- record.machine_id !== credential.machineId ||
119
- record.service !== credential.baseUrl ||
120
- record.credential_tag !== tag
121
- )
122
- if (expired || rotated) {
123
- rmSync(file, { force: true })
124
- continue
125
- }
126
- records.push(record)
127
- }
128
- return records
129
- }
130
-
131
- export type BeginAttempt =
132
- | { ok: true; attemptId: string; idempotencyKey: string; replay: boolean }
133
- | { ok: false; code: 'retry_not_found' | 'retry_ambiguous'; message: string }
134
-
135
- export function beginSendAttempt(options: {
136
- env: NodeJS.ProcessEnv
137
- credential: MachineCredential
138
- fingerprint: string
139
- retry: boolean
140
- idempotencyKey?: string
141
- now?: number
142
- }): BeginAttempt {
143
- const now = options.now ?? Date.now()
144
- const live = liveRecords(options.env, options.credential, now)
145
- if (options.retry) {
146
- const matches = live.filter((record) => record.fingerprint === options.fingerprint)
147
- if (matches.length === 0) {
148
- return {
149
- ok: false,
150
- code: 'retry_not_found',
151
- message: 'No unresolved opaque attempt matches this exact send on the current Approved Machine.',
152
- }
153
- }
154
- if (matches.length > 1) {
155
- return {
156
- ok: false,
157
- code: 'retry_ambiguous',
158
- message: 'More than one unresolved opaque attempt matches this exact send; refusing to guess or create another notification.',
159
- }
160
- }
161
- const match = matches[0]!
162
- return { ok: true, attemptId: match.attempt_id, idempotencyKey: match.idempotency_key, replay: true }
163
- }
164
-
165
- const attemptId = `sat_${randomBytes(12).toString('base64url')}`
166
- const idempotencyKey = options.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
167
- const record: SendAttemptRecord = {
168
- version: 1,
169
- attempt_id: attemptId,
170
- fingerprint: options.fingerprint,
171
- idempotency_key: idempotencyKey,
172
- credential_tag: credentialTag(options.credential),
173
- machine_id: options.credential.machineId,
174
- service: options.credential.baseUrl,
175
- created_at: new Date(now).toISOString(),
176
- expires_at: new Date(now + SEND_ATTEMPT_TTL_MS).toISOString(),
177
- }
178
- atomicWriteFileSync(
179
- path.join(directory(options.env), `${attemptId}.json`),
180
- `${JSON.stringify(record)}\n`,
181
- { mode: 0o600, directoryMode: 0o700, requireCurrentUserOwner: true },
182
- )
183
- return { ok: true, attemptId, idempotencyKey, replay: false }
184
- }
185
-
186
- export function settleSendAttempt(env: NodeJS.ProcessEnv, attemptId: string): void {
187
- rmSync(path.join(directory(env), `${attemptId}.json`), { force: true })
188
- }
package/src/send.test.ts DELETED
@@ -1,28 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import {
3
- accidentalEscapedNewlineMessage,
4
- hasAccidentalEscapedNewlines,
5
- rejectAccidentalEscapedNewlines,
6
- } from './send.js'
7
-
8
- describe('accidental escaped newlines', () => {
9
- it('catches a shell-quoted backslash-n body', () => {
10
- expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
11
- expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
12
- accidentalEscapedNewlineMessage(),
13
- )
14
- })
15
-
16
- it('preserves real multiline Markdown', () => {
17
- expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
18
- expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
19
- })
20
-
21
- it('lets an explicit flag keep visible backslash-n text', () => {
22
- expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
23
- })
24
-
25
- it('does not treat an escaped backslash plus n as accidental', () => {
26
- expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
27
- })
28
- })
package/src/send.ts DELETED
@@ -1,436 +0,0 @@
1
- import {
2
- COLLAPSE_KEY_MAX_BYTES,
3
- INTERRUPTION_LEVELS,
4
- MEDIA_MAX_ITEMS,
5
- NOTIFICATION_SCHEMA_VERSION,
6
- QUESTION_TEXT_MAX_LENGTH,
7
- bannerExcerpt,
8
- type AndroidOptionsT,
9
- type IosOptionsT,
10
- type LifecycleT,
11
- type MacosOptionsT,
12
- type MediaItemT,
13
- type NotificationDraftT,
14
- NOTIFICATION_KINDS,
15
- PLATFORMS,
16
- type Platform,
17
- type QuestionT,
18
- type SourceContextT,
19
- type SubmissionReceipt,
20
- } from '@raidiant/notifai-protocol'
21
- import type { CliConfig } from './config.js'
22
- import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
23
-
24
- export interface SendFlags {
25
- title: string
26
- /** The one canonical Markdown body. */
27
- body: string
28
- subtitle?: string
29
- /** What this notification is; absent means update. */
30
- kind?: string
31
- project?: string
32
- /** Deliberately omit Project identity even when config or cwd could supply one. */
33
- projectless?: boolean
34
- /** Opaque exact Agent Session override. */
35
- sessionId?: string
36
- /** Human-readable Agent Session label override. */
37
- sessionLabel?: string
38
- device?: string[]
39
- all?: boolean
40
- ttl?: number
41
- collapseKey?: string
42
- sound?: string
43
- threadId?: string
44
- /** Apple interruption level; null is reserved for internal callers that must suppress config. */
45
- level?: string | null
46
- data?: string[]
47
- /** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
48
- image?: string[]
49
- /** Optional alt text paired with images by position. */
50
- imageAlt?: string[]
51
- /**
52
- * Complete canonical media manifest for internal callers that already froze
53
- * uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
54
- */
55
- media?: MediaItemT[]
56
- /** Enable the inline reply action for this Notification Request. */
57
- reply?: boolean
58
- /** How long the server accepts a reply after submission. */
59
- replyWindow?: number
60
- /**
61
- * Answer labels; turns the reply into a closed question. One flag occurrence
62
- * per label, always — a label is a label, whatever characters it contains.
63
- */
64
- choice?: string[]
65
- /** The user may select several of the offered answers. */
66
- multi?: boolean
67
- /**
68
- * A full question set, overriding the single question the flags describe.
69
- * Not a user-facing flag: `ask --form` and the hooks build it.
70
- */
71
- questions?: QuestionT[]
72
- /** Platform whose optional notification fields these flags configure. */
73
- platform?: string
74
- /**
75
- * Lifecycle position of this send; not a user-facing flag. Hooks set it so
76
- * questions and their retirements ride the wire as what they are.
77
- */
78
- lifecycle?: LifecycleT
79
- /**
80
- * Allow the two-character sequence backslash-n to be sent as visible text.
81
- * Without this, a likely accidental escaped newline is refused.
82
- */
83
- literalBackslashN?: boolean
84
- }
85
-
86
- /** A backslash-n pair that was not itself escaped as a literal backslash. */
87
- const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
88
-
89
- export function hasAccidentalEscapedNewlines(body: string): boolean {
90
- return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
91
- }
92
-
93
- export function accidentalEscapedNewlineMessage(): string {
94
- return [
95
- 'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
96
- 'To send a real line break, pass a multiline --body or --body-file -.',
97
- 'If you meant the visible characters \\n, pass --literal-backslash-n.',
98
- ].join('\n')
99
- }
100
-
101
- export function rejectAccidentalEscapedNewlines(
102
- body: string | undefined,
103
- allowLiteral: boolean | undefined,
104
- ): string | null {
105
- if (allowLiteral === true || body === undefined) return null
106
- return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
107
- }
108
-
109
- export type DraftBuild =
110
- | { ok: true; draft: NotificationDraftT; platform: Platform }
111
- | { ok: false; error: string }
112
-
113
- /** Invocation-derived values beneath explicit and configured author input. */
114
- export interface DraftInvocation {
115
- inferredProject?: string | null
116
- source?: SourceContextT
117
- }
118
-
119
- const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
120
-
121
- /** Validate media cardinality and positional alt pairing before any upload starts. */
122
- export function validateMediaInputs(
123
- images: readonly string[] | undefined,
124
- alts: readonly string[] | undefined,
125
- ): string | null {
126
- const imageCount = images?.length ?? 0
127
- const imageAlts = alts ?? []
128
- if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
129
- if (imageAlts.length > imageCount) {
130
- return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
131
- }
132
- if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
133
- return '--image-alt must be 1-256 characters.'
134
- }
135
- return null
136
- }
137
-
138
- /** Validate a frozen value-object manifest without collapsing sparse alt text. */
139
- function validateMediaManifest(media: readonly MediaItemT[]): string | null {
140
- if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
141
- if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
142
- return 'Media alt text must be 1-256 characters.'
143
- }
144
- return null
145
- }
146
-
147
- /** Rewrite authorable 1-based media positions to canonical ready media ids. */
148
- export function rewriteMediaReferences(
149
- body: string,
150
- mediaIds: readonly string[],
151
- ): { ok: true; body: string } | { ok: false; error: string } {
152
- let error: string | undefined
153
- const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
154
- const position = Number(digits)
155
- if (
156
- !Number.isSafeInteger(position) ||
157
- position < 1 ||
158
- position > MEDIA_MAX_ITEMS ||
159
- digits !== String(position) ||
160
- mediaIds[position - 1] === undefined
161
- ) {
162
- error = `Body reference "${reference}" has no matching --image occurrence.`
163
- return reference
164
- }
165
- return `media:${mediaIds[position - 1]}`
166
- })
167
- return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
168
- }
169
-
170
- /** Merge flags over resolved config into a Notification Request draft. */
171
- export function buildDraft(
172
- config: CliConfig,
173
- flags: SendFlags,
174
- invocation: DraftInvocation = {},
175
- ): DraftBuild {
176
- if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
177
- const platform = resolvePlatform(flags.platform)
178
- if (!platform) {
179
- return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
180
- }
181
-
182
- if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
183
- return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
184
- }
185
- const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
186
- const imageAlts = flags.imageAlt ?? []
187
- const mediaInputError =
188
- flags.media === undefined
189
- ? validateMediaInputs(imageIds, imageAlts)
190
- : validateMediaManifest(flags.media)
191
- if (mediaInputError !== null) return { ok: false, error: mediaInputError }
192
- const media =
193
- flags.media ??
194
- imageIds.map((media_id, index) => ({
195
- media_id,
196
- ...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
197
- }))
198
- const body = rewriteMediaReferences(flags.body, imageIds)
199
- if (!body.ok) return body
200
-
201
- const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
202
- const targets: NotificationDraftT['targets'] =
203
- deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
204
-
205
- if (
206
- flags.kind !== undefined &&
207
- !NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
208
- ) {
209
- return {
210
- ok: false,
211
- error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
212
- }
213
- }
214
-
215
- // Explicit --sound and the saved sound key outrank kind. The server is the
216
- // one that applies kind, Project, and Account maps — stamping a kind default
217
- // here would look like an explicit choice and skip those layers. `none` is
218
- // still silent.
219
- const sound = flags.sound !== undefined ? flags.sound : config.sound.value
220
- if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
221
- return {
222
- ok: false,
223
- error: unknownSoundMessage(sound),
224
- }
225
- }
226
- const level =
227
- flags.level !== undefined ? flags.level : config.interruption_level.value
228
- if (
229
- level !== null &&
230
- level !== undefined &&
231
- !INTERRUPTION_LEVELS.includes(level as (typeof INTERRUPTION_LEVELS)[number])
232
- ) {
233
- return { ok: false, error: `Unknown interruption level "${level}".` }
234
- }
235
- if (platform === 'android' && level !== null && level !== undefined) {
236
- return {
237
- ok: false,
238
- error:
239
- 'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
240
- }
241
- }
242
-
243
- const appleOptions: IosOptionsT & MacosOptionsT = {}
244
- const androidOptions: AndroidOptionsT = {}
245
- if (sound === 'none') {
246
- appleOptions.sound = null
247
- androidOptions.sound = null
248
- } else if (sound !== null && sound !== undefined) {
249
- appleOptions.sound = sound
250
- androidOptions.sound = sound
251
- }
252
- if (flags.threadId !== undefined) {
253
- appleOptions.thread_id = flags.threadId
254
- androidOptions.thread_id = flags.threadId
255
- }
256
- if (level !== null && level !== undefined) {
257
- appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
258
- }
259
- if (flags.data?.length) {
260
- const data: Record<string, string> = {}
261
- for (const pair of flags.data) {
262
- const eq = pair.indexOf('=')
263
- if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
264
- data[pair.slice(0, eq)] = pair.slice(eq + 1)
265
- }
266
- appleOptions.custom_data = data
267
- androidOptions.custom_data = data
268
- }
269
-
270
- const ttl = flags.ttl ?? config.ttl_seconds.value
271
- const collapse = flags.collapseKey ?? config.collapse_key.value
272
- if (
273
- collapse !== null &&
274
- collapse !== undefined &&
275
- Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
276
- ) {
277
- return {
278
- ok: false,
279
- error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
280
- }
281
- }
282
-
283
- if (flags.projectless === true && flags.project !== undefined) {
284
- return { ok: false, error: 'Choose either --project or --projectless, not both.' }
285
- }
286
- const project = flags.projectless === true
287
- ? undefined
288
- : flags.project ?? config.project.value ?? invocation.inferredProject
289
-
290
- // Ids are derived from labels so an agent writing a one-liner does not have
291
- // to invent them, but they stay the stable thing it branches on afterwards.
292
- const choices = parseChoices(flags.choice)
293
- if (choices === 'invalid') {
294
- return {
295
- ok: false,
296
- error: CHOICE_USAGE,
297
- }
298
- }
299
- if (flags.multi && choices === null && flags.questions === undefined) {
300
- return { ok: false, error: '--multi needs answers to select between; add --choice.' }
301
- }
302
- // A single reply question is the first readable block of the canonical body.
303
- // Context may follow after a blank line without changing what the user answers.
304
- const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
305
- if (
306
- flags.reply &&
307
- flags.questions === undefined &&
308
- derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
309
- ) {
310
- return {
311
- ok: false,
312
- error:
313
- `Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
314
- 'the longer context after a blank line — the first paragraph is the question.',
315
- }
316
- }
317
-
318
- const hasAppleOptions = Object.keys(appleOptions).length > 0
319
- const hasAndroidOptions = Object.keys(androidOptions).length > 0
320
- let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
321
- if (flags.platform === undefined) {
322
- if (hasAppleOptions || hasAndroidOptions) {
323
- platformOptions = {
324
- ...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
325
- ...(hasAndroidOptions ? { android: androidOptions } : {}),
326
- }
327
- }
328
- } else if (platform === 'android') {
329
- if (hasAndroidOptions) platformOptions = { android: androidOptions }
330
- } else if (hasAppleOptions) {
331
- platformOptions =
332
- platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
333
- }
334
-
335
- const draft: NotificationDraftT = {
336
- schema_version: NOTIFICATION_SCHEMA_VERSION,
337
- ...(flags.kind !== undefined
338
- ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
339
- : {}),
340
- ...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
341
- ...(project !== null && project !== undefined ? { project } : {}),
342
- ...(invocation.source !== undefined ? { source: invocation.source } : {}),
343
- presentation: {
344
- title: flags.title,
345
- body: body.body,
346
- ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
347
- ...(media.length > 0 ? { media } : {}),
348
- },
349
- targets,
350
- delivery: { ttl_seconds: ttl, collapse_key: collapse },
351
- ...(flags.reply
352
- ? {
353
- reply: {
354
- expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
355
- questions: flags.questions ?? [
356
- {
357
- id: 'q1',
358
- text: derivedQuestion,
359
- ...(choices !== null ? { choices } : {}),
360
- ...(flags.multi ? { multi: true } : {}),
361
- },
362
- ],
363
- },
364
- }
365
- : {}),
366
- ...(platformOptions !== undefined ? { platform: platformOptions } : {}),
367
- }
368
- return { ok: true, draft, platform }
369
- }
370
-
371
- function resolvePlatform(value: string | undefined): Platform | null {
372
- if (value === undefined) return 'ios'
373
- return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
374
- }
375
-
376
- /** Human one-screen receipt summary; agents use --json instead. */
377
- export function formatReceipt(receipt: SubmissionReceipt): string {
378
- const lines: string[] = []
379
- const overall =
380
- receipt.overall === 'provider_accepted_all'
381
- ? 'accepted by the push provider for every device'
382
- : receipt.overall === 'provider_accepted_partial'
383
- ? 'accepted for some devices'
384
- : receipt.overall === 'provider_rejected_all'
385
- ? 'rejected for every device'
386
- : 'still in flight'
387
- lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`)
388
- for (const d of receipt.deliveries) {
389
- const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
390
- lines.push(` ${d.device_name}: ${d.state}${detail}`)
391
- }
392
- lines.push(
393
- ` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
394
- )
395
- for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
396
- return lines.join('\n')
397
- }
398
-
399
- /** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
400
- export function receiptExitCode(receipt: SubmissionReceipt): number {
401
- return receipt.overall === 'provider_rejected_all' ? 1 : 0
402
- }
403
-
404
- /**
405
- * One flag occurrence, one answer: `--choice Staging --choice Production` ->
406
- * `[{id:'staging',label:'Staging'}, ...]`.
407
- *
408
- * Commas are ordinary characters. The old single-value comma split produced
409
- * buttons the agent never intended ("Nothing, I'll review" became two answers)
410
- * and nothing downstream could detect it, so the delimiter form is gone
411
- * rather than guarded: a label is a label.
412
- */
413
- export function parseChoices(
414
- value: string[] | undefined,
415
- ): { id: string; label: string }[] | null | 'invalid' {
416
- if (value === undefined || value.length === 0) return null
417
- const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
418
- if (labels.length < 2 || labels.length > 6) return 'invalid'
419
- const choices = labels.map((label) => ({ id: slugify(label), label }))
420
- if (choices.some((choice) => choice.id === '')) return 'invalid'
421
- if (new Set(choices.map((choice) => choice.id)).size !== choices.length) return 'invalid'
422
- return choices
423
- }
424
-
425
- /** Message shared by every surface that accepts choice labels. */
426
- export const CHOICE_USAGE =
427
- 'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
428
- 'e.g. --choice "Yes, ship it" --choice "Not yet".'
429
-
430
- export function slugify(label: string): string {
431
- return label
432
- .toLowerCase()
433
- .replace(/[^a-z0-9]+/g, '-')
434
- .replace(/^-+|-+$/g, '')
435
- .slice(0, 32)
436
- }