@raidiant/notifai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
package/src/send.ts ADDED
@@ -0,0 +1,272 @@
1
+ import {
2
+ CLI_SOUNDS,
3
+ INTERRUPTION_LEVELS,
4
+ NOTIFICATION_SCHEMA_VERSION,
5
+ type IosOptionsT,
6
+ type LifecycleT,
7
+ type MacosOptionsT,
8
+ type NotificationDraftT,
9
+ NOTIFICATION_KINDS,
10
+ PLATFORMS,
11
+ type Platform,
12
+ type SubmissionReceipt,
13
+ } from '@raidiant/notifai-protocol'
14
+ import type { CliConfig } from './config.js'
15
+
16
+ type CliSound = (typeof CLI_SOUNDS)[number]
17
+
18
+ export interface SendFlags {
19
+ title: string
20
+ body: string
21
+ subtitle?: string
22
+ /** Long-form markdown for the app's detail view; never on the banner. */
23
+ detail?: string
24
+ event?: string
25
+ /** What this notification is: update (default), done, or question. */
26
+ kind?: string
27
+ project?: string
28
+ session?: string
29
+ device?: string[]
30
+ all?: boolean
31
+ ttl?: number
32
+ collapseKey?: string
33
+ sound?: string
34
+ badge?: number
35
+ threadId?: string
36
+ level?: string
37
+ relevance?: number
38
+ targetContentId?: string
39
+ data?: string[]
40
+ image?: string
41
+ /** Enable the inline reply action for this Notification Request. */
42
+ reply?: boolean
43
+ /** How long the server accepts a reply after submission. */
44
+ replyWindow?: number
45
+ /**
46
+ * Answer labels; turns the reply into a closed question. One value splits on
47
+ * commas; repeating the flag passes each label through verbatim.
48
+ */
49
+ replyChoice?: string | string[]
50
+ /** Platform whose optional notification fields these flags configure. */
51
+ platform?: string
52
+ /**
53
+ * Lifecycle position of this send; not a user-facing flag. Hooks set it so
54
+ * questions and their retirements ride the wire as what they are.
55
+ */
56
+ lifecycle?: LifecycleT
57
+ }
58
+
59
+ export type DraftBuild =
60
+ | { ok: true; draft: NotificationDraftT; platform: Platform }
61
+ | { ok: false; error: string }
62
+
63
+ /** Merge flags over resolved config into a Notification Request draft. */
64
+ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
65
+ if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
66
+ const platform = resolvePlatform(flags.platform)
67
+ if (!platform) {
68
+ return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
69
+ }
70
+
71
+ const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
72
+ const targets: NotificationDraftT['targets'] =
73
+ deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
74
+
75
+ const sound = flags.sound ?? config.sound.value
76
+ if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
77
+ return {
78
+ ok: false,
79
+ error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
80
+ }
81
+ }
82
+ const level = flags.level ?? config.interruption_level.value
83
+ if (
84
+ level !== null &&
85
+ level !== undefined &&
86
+ !INTERRUPTION_LEVELS.includes(level as (typeof INTERRUPTION_LEVELS)[number])
87
+ ) {
88
+ return { ok: false, error: `Unknown interruption level "${level}".` }
89
+ }
90
+
91
+ const options: IosOptionsT & MacosOptionsT = {}
92
+ if (sound === 'none') options.sound = null
93
+ else if (sound !== null && sound !== undefined) {
94
+ options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
95
+ }
96
+ if (flags.badge !== undefined) options.badge = flags.badge
97
+ if (flags.threadId !== undefined) options.thread_id = flags.threadId
98
+ if (level !== null && level !== undefined) {
99
+ options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
100
+ }
101
+ if (flags.relevance !== undefined) options.relevance_score = flags.relevance
102
+ if (flags.targetContentId !== undefined) options.target_content_id = flags.targetContentId
103
+ if (flags.data?.length) {
104
+ const data: Record<string, string> = {}
105
+ for (const pair of flags.data) {
106
+ const eq = pair.indexOf('=')
107
+ if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
108
+ data[pair.slice(0, eq)] = pair.slice(eq + 1)
109
+ }
110
+ options.custom_data = data
111
+ }
112
+
113
+ if (
114
+ flags.kind !== undefined &&
115
+ !NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
116
+ ) {
117
+ return {
118
+ ok: false,
119
+ error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
120
+ }
121
+ }
122
+
123
+ const ttl = flags.ttl ?? config.ttl_seconds.value
124
+ const collapse = flags.collapseKey ?? config.collapse_key.value
125
+
126
+ const project = flags.project ?? config.project.value
127
+ // Harnesses can export NOTIFAI_SESSION once instead of passing the flag
128
+ // on every send.
129
+ const session = flags.session ?? process.env['NOTIFAI_SESSION']
130
+
131
+ // Ids are derived from labels so an agent writing a one-liner does not have
132
+ // to invent them, but they stay the stable thing it branches on afterwards.
133
+ const choices = parseChoices(flags.replyChoice)
134
+ if (choices === 'invalid') {
135
+ return {
136
+ ok: false,
137
+ error: CHOICE_USAGE,
138
+ }
139
+ }
140
+
141
+ const draft: NotificationDraftT = {
142
+ schema_version: NOTIFICATION_SCHEMA_VERSION,
143
+ ...(flags.event !== undefined ? { event: flags.event } : {}),
144
+ ...(flags.kind !== undefined
145
+ ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
146
+ : {}),
147
+ ...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
148
+ ...(project !== null && project !== undefined ? { project } : {}),
149
+ ...(session !== undefined && session !== '' ? { session } : {}),
150
+ presentation: {
151
+ title: flags.title,
152
+ body: flags.body,
153
+ ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
154
+ ...(flags.detail !== undefined && flags.detail.trim() !== ''
155
+ ? { detail: flags.detail }
156
+ : {}),
157
+ ...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
158
+ },
159
+ targets,
160
+ delivery: { ttl_seconds: ttl, collapse_key: collapse },
161
+ ...(flags.reply
162
+ ? {
163
+ reply: {
164
+ expires_in_seconds: flags.replyWindow ?? 86400,
165
+ ...(choices !== null ? { kind: 'choice' as const, choices } : {}),
166
+ },
167
+ }
168
+ : {}),
169
+ ...(Object.keys(options).length > 0
170
+ ? platform === 'ios'
171
+ ? { platform: { ios: options } }
172
+ : { platform: { macos: options } }
173
+ : {}),
174
+ }
175
+ return { ok: true, draft, platform }
176
+ }
177
+
178
+ function resolvePlatform(value: string | undefined): Platform | null {
179
+ if (value === undefined) return 'ios'
180
+ return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
181
+ }
182
+
183
+ /** Human one-screen receipt summary; agents use --json instead. */
184
+ export function formatReceipt(receipt: SubmissionReceipt): string {
185
+ const lines: string[] = []
186
+ const overall =
187
+ receipt.overall === 'provider_accepted_all'
188
+ ? 'accepted by the push provider for every device'
189
+ : receipt.overall === 'provider_accepted_partial'
190
+ ? 'accepted for some devices'
191
+ : receipt.overall === 'provider_rejected_all'
192
+ ? 'rejected for every device'
193
+ : 'still in flight'
194
+ lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`)
195
+ for (const d of receipt.deliveries) {
196
+ const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
197
+ lines.push(` ${d.device_name}: ${d.state}${detail}`)
198
+ }
199
+ for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
200
+ return lines.join('\n')
201
+ }
202
+
203
+ /** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
204
+ export function receiptExitCode(receipt: SubmissionReceipt): number {
205
+ return receipt.overall === 'provider_rejected_all' ? 1 : 0
206
+ }
207
+
208
+ /**
209
+ * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
210
+ *
211
+ * A single occurrence splits on commas, which keeps the common one-liner
212
+ * short. Repeating the flag is the escape hatch, and the only way to write a
213
+ * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
214
+ * silently yields three choices, and nothing downstream can detect that the
215
+ * agent meant two.
216
+ */
217
+ export function parseChoices(
218
+ value: string | string[] | undefined,
219
+ ): { id: string; label: string }[] | null | 'invalid' {
220
+ if (value === undefined) return null
221
+ const raw = Array.isArray(value) ? value : [value]
222
+ if (raw.length === 0) return null
223
+ const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
224
+ .map((part) => part.trim())
225
+ .filter((part) => part.length > 0)
226
+ if (labels.length < 2 || labels.length > 6) return 'invalid'
227
+ const choices = labels.map((label) => ({ id: slugify(label), label }))
228
+ if (choices.some((choice) => choice.id === '')) return 'invalid'
229
+ if (new Set(choices.map((choice) => choice.id)).size !== choices.length) return 'invalid'
230
+ return choices
231
+ }
232
+
233
+ /** Message shared by every surface that accepts choice labels. */
234
+ export const CHOICE_USAGE =
235
+ 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
236
+ 'commas ("Staging,Production"); repeat the flag when a label contains a comma.'
237
+
238
+ /**
239
+ * Catches the split that looked like it worked.
240
+ *
241
+ * `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
242
+ * buttons rather than three, because the third label contained a comma. Nothing
243
+ * downstream can detect it — the labels are all well-formed — and the CLI
244
+ * cheerfully echoed the wrong list back, so the user saw buttons the agent
245
+ * never intended.
246
+ *
247
+ * The tell is a comma followed by a space. A delimiter list is written
248
+ * "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
249
+ * will occasionally be wrong, which is exactly why this warns and names the
250
+ * escape hatch rather than rejecting: the split may well have been intended.
251
+ */
252
+ export function ambiguousChoiceSplit(value: string | string[] | undefined): string | null {
253
+ if (value === undefined || Array.isArray(value)) return null
254
+ if (!/,\s/.test(value)) return null
255
+ const parts = value
256
+ .split(',')
257
+ .map((part) => part.trim())
258
+ .filter((part) => part.length > 0)
259
+ return (
260
+ `Heads up: "${value}" was split on commas into ${parts.length} answers ` +
261
+ `(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
262
+ 'pass --choice once per answer instead.'
263
+ )
264
+ }
265
+
266
+ function slugify(label: string): string {
267
+ return label
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/g, '-')
270
+ .replace(/^-+|-+$/g, '')
271
+ .slice(0, 32)
272
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src"],
9
+ "exclude": ["src/**/*.test.ts"]
10
+ }