@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.
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- 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
|
+
}
|