@raidiant/notifai 2.0.2 → 4.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.
- package/CHANGELOG.md +48 -0
- package/README.md +7 -4
- package/dist/client.d.ts +2 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -9
- package/dist/commands.js +261 -100
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +9 -9
- package/dist/config-schema.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks.d.ts +12 -7
- package/dist/hooks.js +26 -46
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/main.js +46 -38
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +32 -7
- package/dist/send.js +77 -32
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +46 -0
- package/src/client.ts +13 -0
- package/src/commands.test.ts +333 -37
- package/src/commands.ts +354 -136
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +9 -9
- package/src/config.test.ts +144 -16
- package/src/config.ts +1 -1
- package/src/hooks.test.ts +144 -38
- package/src/hooks.ts +67 -73
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/main.ts +72 -41
- package/src/send.ts +116 -38
- package/src/skill.test.ts +31 -7
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
sessionLabelFromId,
|
|
5
|
+
type SourceContextT,
|
|
6
|
+
} from '@raidiant/notifai-protocol'
|
|
7
|
+
import type { Harness } from './harnesses.js'
|
|
8
|
+
|
|
9
|
+
export type GitCommand = (cwd: string, args: readonly string[]) => string | null
|
|
10
|
+
|
|
11
|
+
const runGit: GitCommand = (cwd, args) => {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
timeout: 2_000,
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
}).trim()
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The useful, privacy-safe context inferred from one invocation directory. */
|
|
25
|
+
export interface InvocationContext {
|
|
26
|
+
project: string | null
|
|
27
|
+
branch?: string
|
|
28
|
+
worktree?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Contract-valid Project slug, or null when the name has no safe characters. */
|
|
32
|
+
export function projectSlugFrom(name: string): string | null {
|
|
33
|
+
const slug = name
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
36
|
+
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
37
|
+
.slice(0, 64)
|
|
38
|
+
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function usesWindowsPaths(...values: string[]): boolean {
|
|
42
|
+
return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pathApi(...values: string[]): typeof path.posix | typeof path.win32 {
|
|
46
|
+
return usesWindowsPaths(...values) ? path.win32 : path.posix
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Basename that can be fixture-tested for POSIX, drive, and UNC paths on any OS. */
|
|
50
|
+
export function portableBasename(value: string): string {
|
|
51
|
+
const api = pathApi(value)
|
|
52
|
+
return api.basename(api.normalize(value))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveGitPath(cwd: string, value: string): string {
|
|
56
|
+
const api = pathApi(cwd, value)
|
|
57
|
+
return api.isAbsolute(value) ? api.normalize(value) : api.resolve(cwd, value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function samePath(left: string, right: string): boolean {
|
|
61
|
+
const api = pathApi(left, right)
|
|
62
|
+
const normalizedLeft = api.normalize(left)
|
|
63
|
+
const normalizedRight = api.normalize(right)
|
|
64
|
+
return api === path.win32
|
|
65
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
66
|
+
: normalizedLeft === normalizedRight
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Truncate a display string by Unicode characters and keep the ellipsis inside the bound. */
|
|
70
|
+
export function truncateContext(value: string, maxLength: number): string {
|
|
71
|
+
const characters = Array.from(value)
|
|
72
|
+
if (characters.length <= maxLength) return value
|
|
73
|
+
return `${characters.slice(0, Math.max(0, maxLength - 1)).join('')}…`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Infer stable Project identity plus branch/worktree display context.
|
|
78
|
+
*
|
|
79
|
+
* Linked worktrees share the parent of Git's common directory, while a non-Git
|
|
80
|
+
* directory falls back to its own basename. Only basenames and branch names are
|
|
81
|
+
* returned; no absolute path can leave this function.
|
|
82
|
+
*/
|
|
83
|
+
export function inferInvocationContext(
|
|
84
|
+
cwd: string,
|
|
85
|
+
git: GitCommand = runGit,
|
|
86
|
+
): InvocationContext {
|
|
87
|
+
const commonRaw = git(cwd, ['rev-parse', '--git-common-dir'])
|
|
88
|
+
if (commonRaw === null || commonRaw === '') {
|
|
89
|
+
return { project: projectSlugFrom(portableBasename(cwd)) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const commonDir = resolveGitPath(cwd, commonRaw)
|
|
93
|
+
const api = pathApi(cwd, commonDir)
|
|
94
|
+
const gitDirRaw = git(cwd, ['rev-parse', '--git-dir'])
|
|
95
|
+
const topLevelRaw = git(cwd, ['rev-parse', '--show-toplevel'])
|
|
96
|
+
const linked =
|
|
97
|
+
gitDirRaw !== null &&
|
|
98
|
+
gitDirRaw !== '' &&
|
|
99
|
+
!samePath(resolveGitPath(cwd, gitDirRaw), commonDir)
|
|
100
|
+
|
|
101
|
+
// In an ordinary checkout the worktree root is the useful Project root. A
|
|
102
|
+
// linked worktree needs its shared repository identity instead: standard Git
|
|
103
|
+
// stores that in <main>/.git, while submodules and --separate-git-dir use a
|
|
104
|
+
// named common directory whose basename is the only stable, path-free label.
|
|
105
|
+
// Treating every common directory as `.git` would infer `modules` or `public`
|
|
106
|
+
// for submodules — an implementation detail, not the repository's identity.
|
|
107
|
+
const commonName = api.basename(commonDir)
|
|
108
|
+
const projectName = linked
|
|
109
|
+
? commonName.toLowerCase() === '.git'
|
|
110
|
+
? api.basename(api.dirname(commonDir))
|
|
111
|
+
: commonName
|
|
112
|
+
: topLevelRaw !== null && topLevelRaw !== ''
|
|
113
|
+
? portableBasename(topLevelRaw)
|
|
114
|
+
: commonName.toLowerCase() === '.git'
|
|
115
|
+
? api.basename(api.dirname(commonDir))
|
|
116
|
+
: commonName
|
|
117
|
+
const project = projectSlugFrom(projectName)
|
|
118
|
+
|
|
119
|
+
const branchRaw = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
120
|
+
const branch =
|
|
121
|
+
branchRaw === null || branchRaw === '' || branchRaw === 'HEAD'
|
|
122
|
+
? undefined
|
|
123
|
+
: truncateContext(branchRaw, 128)
|
|
124
|
+
|
|
125
|
+
const worktree =
|
|
126
|
+
linked && topLevelRaw !== null && topLevelRaw !== ''
|
|
127
|
+
? truncateContext(portableBasename(topLevelRaw), 64)
|
|
128
|
+
: undefined
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
project,
|
|
132
|
+
...(branch !== undefined ? { branch } : {}),
|
|
133
|
+
...(worktree !== undefined && worktree !== '' ? { worktree } : {}),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SourceContextInput {
|
|
138
|
+
env: NodeJS.ProcessEnv
|
|
139
|
+
invocation: InvocationContext
|
|
140
|
+
sessionId?: string
|
|
141
|
+
sessionLabel?: string
|
|
142
|
+
activeHarness?: { harness: Harness; sessionId?: string }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SourceContextBuild =
|
|
146
|
+
| { ok: true; source?: SourceContextT }
|
|
147
|
+
| { ok: false; error: string }
|
|
148
|
+
|
|
149
|
+
/** Resolve per-field source precedence without fabricating session identity. */
|
|
150
|
+
export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
|
|
151
|
+
const sessionId =
|
|
152
|
+
input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
|
|
153
|
+
const explicitLabel = input.sessionLabel ?? input.env['NOTIFAI_SESSION_LABEL']
|
|
154
|
+
if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (sessionId === '') {
|
|
161
|
+
return { ok: false, error: '--session-id must not be empty.' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sessionLabel =
|
|
165
|
+
sessionId === undefined ? undefined : (explicitLabel ?? sessionLabelFromId(sessionId))
|
|
166
|
+
const source: SourceContextT = {
|
|
167
|
+
...(sessionId !== undefined ? { session_id: sessionId } : {}),
|
|
168
|
+
...(sessionLabel !== undefined ? { session_label: sessionLabel } : {}),
|
|
169
|
+
...(sessionId !== undefined && input.activeHarness !== undefined
|
|
170
|
+
? { harness: input.activeHarness.harness }
|
|
171
|
+
: {}),
|
|
172
|
+
...(input.invocation.branch !== undefined ? { branch: input.invocation.branch } : {}),
|
|
173
|
+
...(input.invocation.worktree !== undefined ? { worktree: input.invocation.worktree } : {}),
|
|
174
|
+
}
|
|
175
|
+
return Object.keys(source).length === 0 ? { ok: true } : { ok: true, source }
|
|
176
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -262,18 +262,29 @@ const send = program
|
|
|
262
262
|
.summary('Send a notification')
|
|
263
263
|
.description('Send a notification')
|
|
264
264
|
.optionsGroup(SEND_GROUP.content)
|
|
265
|
-
.requiredOption('--title <title>', '
|
|
266
|
-
.
|
|
267
|
-
.option('--
|
|
268
|
-
.option('--
|
|
269
|
-
.option(
|
|
270
|
-
|
|
265
|
+
.requiredOption('--title <title>', 'brief title whose substance is immediately understandable')
|
|
266
|
+
.option('--body <markdown>', 'canonical Markdown body')
|
|
267
|
+
.option('--body-file <path>', 'read the canonical Markdown body from a file (use - for stdin)')
|
|
268
|
+
.option('--subtitle <subtitle>', 'a plain-text line between the title and body')
|
|
269
|
+
.option(
|
|
270
|
+
'--image <path|url|media_id>',
|
|
271
|
+
'upload or attach an image in collection order (repeatable, maximum 8)',
|
|
272
|
+
(v: string, all: string[]) => [...all, v],
|
|
273
|
+
[],
|
|
274
|
+
)
|
|
275
|
+
.option(
|
|
276
|
+
'--image-alt <text>',
|
|
277
|
+
'alt text paired with --image occurrences by position (repeatable)',
|
|
278
|
+
(v: string, all: string[]) => [...all, v],
|
|
279
|
+
[],
|
|
280
|
+
)
|
|
271
281
|
.optionsGroup(SEND_GROUP.routing)
|
|
272
|
-
.option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
|
|
273
|
-
.option('--project <id>', 'project identifier
|
|
282
|
+
.option('--kind <kind>', 'what this is: update (default) | done | failed | blocked | question (requires --reply)')
|
|
283
|
+
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
274
284
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
275
285
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
276
|
-
.option('--session <id>', 'session
|
|
286
|
+
.option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
|
|
287
|
+
.option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
|
|
277
288
|
.option('--event <event>', 'agent event name, e.g. tests_passed')
|
|
278
289
|
.optionsGroup(SEND_GROUP.presentation)
|
|
279
290
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
@@ -294,8 +305,8 @@ const send = program
|
|
|
294
305
|
// a message pointing at `ask` instead of "unknown option".
|
|
295
306
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
296
307
|
.optionsGroup(SEND_GROUP.advanced)
|
|
297
|
-
.option('--sound <sound>', 'override
|
|
298
|
-
.option('--level <level>', 'override
|
|
308
|
+
.option('--sound <sound>', 'override saved sound: default | done | attention | alert | none')
|
|
309
|
+
.option('--level <level>', 'override saved interruption level: passive | active | time_sensitive')
|
|
299
310
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
300
311
|
.option('--no-wait', 'return immediately after acceptance')
|
|
301
312
|
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
@@ -308,24 +319,22 @@ const send = program
|
|
|
308
319
|
const wait = typeof opts['wait'] === 'number' ? opts['wait'] : undefined
|
|
309
320
|
const noBlock = opts['block'] === false
|
|
310
321
|
const sendOpts = { ...opts }
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
delete sendOpts[
|
|
322
|
+
// Commander collectors default to []; an empty list means "not passed".
|
|
323
|
+
for (const key of ['replyChoice', 'image', 'imageAlt']) {
|
|
324
|
+
if (Array.isArray(sendOpts[key]) && sendOpts[key].length === 0) delete sendOpts[key]
|
|
314
325
|
}
|
|
315
326
|
delete sendOpts['block']
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (sendOpts['detail'] !== undefined) {
|
|
322
|
-
deps.io.err('Pass either --detail or --detail-file, not both.')
|
|
327
|
+
const bodyFile = sendOpts['bodyFile']
|
|
328
|
+
delete sendOpts['bodyFile']
|
|
329
|
+
if (typeof bodyFile === 'string') {
|
|
330
|
+
if (sendOpts['body'] !== undefined) {
|
|
331
|
+
deps.io.err('Pass either --body or --body-file, not both.')
|
|
323
332
|
process.exit(2)
|
|
324
333
|
}
|
|
325
334
|
try {
|
|
326
|
-
sendOpts['
|
|
335
|
+
sendOpts['body'] = readFileSync(bodyFile === '-' ? 0 : bodyFile, 'utf8')
|
|
327
336
|
} catch (err) {
|
|
328
|
-
deps.io.err(`Could not read ${
|
|
337
|
+
deps.io.err(`Could not read ${bodyFile}: ${String(err)}`)
|
|
329
338
|
process.exit(2)
|
|
330
339
|
}
|
|
331
340
|
}
|
|
@@ -337,7 +346,7 @@ const send = program
|
|
|
337
346
|
|
|
338
347
|
send.addHelpText(
|
|
339
348
|
'after',
|
|
340
|
-
`\nKind
|
|
349
|
+
`\nKind describes status in the Companion Apps; it never chooses banner sound or interruption level.\nWithout --sound/--level or saved preferences, those fields are omitted for the destination to handle.\n`,
|
|
341
350
|
)
|
|
342
351
|
|
|
343
352
|
program
|
|
@@ -387,28 +396,46 @@ program
|
|
|
387
396
|
(v: string, all: string[]) => [...all, v], [],
|
|
388
397
|
)
|
|
389
398
|
.option('--multi', 'with --choice, several answers may be selected')
|
|
390
|
-
.option('--
|
|
391
|
-
.option('--
|
|
399
|
+
.option('--body <markdown>', 'optional Markdown context appended after the question block')
|
|
400
|
+
.option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
|
|
392
401
|
.option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
|
|
393
|
-
.option(
|
|
394
|
-
|
|
402
|
+
.option(
|
|
403
|
+
'--image <path|url|media_id>',
|
|
404
|
+
'upload or attach an image in collection order (repeatable, maximum 8)',
|
|
405
|
+
(v: string, all: string[]) => [...all, v],
|
|
406
|
+
[],
|
|
407
|
+
)
|
|
408
|
+
.option(
|
|
409
|
+
'--image-alt <text>',
|
|
410
|
+
'alt text paired with --image occurrences by position (repeatable)',
|
|
411
|
+
(v: string, all: string[]) => [...all, v],
|
|
412
|
+
[],
|
|
413
|
+
)
|
|
414
|
+
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
415
|
+
.option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
|
|
416
|
+
.option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
|
|
417
|
+
.action(async (question: string | undefined, opts: {
|
|
395
418
|
choice?: string[]
|
|
396
419
|
multi?: boolean
|
|
397
|
-
|
|
398
|
-
|
|
420
|
+
body?: string
|
|
421
|
+
bodyFile?: string
|
|
399
422
|
form?: string
|
|
400
|
-
|
|
423
|
+
image?: string[]
|
|
424
|
+
imageAlt?: string[]
|
|
425
|
+
project?: string
|
|
426
|
+
sessionId?: string
|
|
427
|
+
sessionLabel?: string
|
|
401
428
|
}) => {
|
|
402
|
-
let
|
|
403
|
-
if (typeof opts.
|
|
404
|
-
if (
|
|
405
|
-
deps.io.err('Pass either --
|
|
429
|
+
let body = opts.body
|
|
430
|
+
if (typeof opts.bodyFile === 'string') {
|
|
431
|
+
if (body !== undefined) {
|
|
432
|
+
deps.io.err('Pass either --body or --body-file, not both.')
|
|
406
433
|
process.exit(2)
|
|
407
434
|
}
|
|
408
435
|
try {
|
|
409
|
-
|
|
436
|
+
body = readFileSync(opts.bodyFile === '-' ? 0 : opts.bodyFile, 'utf8')
|
|
410
437
|
} catch (err) {
|
|
411
|
-
deps.io.err(`Could not read ${opts.
|
|
438
|
+
deps.io.err(`Could not read ${opts.bodyFile}: ${String(err)}`)
|
|
412
439
|
process.exit(2)
|
|
413
440
|
}
|
|
414
441
|
}
|
|
@@ -421,15 +448,19 @@ program
|
|
|
421
448
|
process.exit(2)
|
|
422
449
|
}
|
|
423
450
|
}
|
|
424
|
-
//
|
|
451
|
+
// Commander collectors default to []; an empty list means "not passed".
|
|
425
452
|
const flags: Parameters<typeof askCommand>[2] = {
|
|
426
|
-
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
427
453
|
...(opts.choice?.length ? { choice: opts.choice } : {}),
|
|
428
454
|
...(opts.multi ? { multi: true } : {}),
|
|
429
|
-
...(
|
|
455
|
+
...(body !== undefined ? { body } : {}),
|
|
430
456
|
...(form !== undefined ? { form } : {}),
|
|
457
|
+
...(opts.image?.length ? { image: opts.image } : {}),
|
|
458
|
+
...(opts.imageAlt?.length ? { imageAlt: opts.imageAlt } : {}),
|
|
459
|
+
...(opts.project !== undefined ? { project: opts.project } : {}),
|
|
460
|
+
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
|
|
461
|
+
...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
|
|
431
462
|
}
|
|
432
|
-
process.exit(askCommand(deps, question, flags))
|
|
463
|
+
process.exit(await askCommand(deps, question, flags))
|
|
433
464
|
})
|
|
434
465
|
|
|
435
466
|
program
|
package/src/send.ts
CHANGED
|
@@ -2,16 +2,20 @@ import {
|
|
|
2
2
|
CLI_SOUNDS,
|
|
3
3
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
4
|
INTERRUPTION_LEVELS,
|
|
5
|
+
MEDIA_MAX_ITEMS,
|
|
5
6
|
NOTIFICATION_SCHEMA_VERSION,
|
|
6
7
|
QUESTION_TEXT_MAX_LENGTH,
|
|
8
|
+
bannerExcerpt,
|
|
7
9
|
type IosOptionsT,
|
|
8
10
|
type LifecycleT,
|
|
9
11
|
type MacosOptionsT,
|
|
12
|
+
type MediaItemT,
|
|
10
13
|
type NotificationDraftT,
|
|
11
14
|
NOTIFICATION_KINDS,
|
|
12
15
|
PLATFORMS,
|
|
13
16
|
type Platform,
|
|
14
17
|
type QuestionT,
|
|
18
|
+
type SourceContextT,
|
|
15
19
|
type SubmissionReceipt,
|
|
16
20
|
} from '@raidiant/notifai-protocol'
|
|
17
21
|
import type { CliConfig } from './config.js'
|
|
@@ -20,15 +24,17 @@ type CliSound = (typeof CLI_SOUNDS)[number]
|
|
|
20
24
|
|
|
21
25
|
export interface SendFlags {
|
|
22
26
|
title: string
|
|
27
|
+
/** The one canonical Markdown body. */
|
|
23
28
|
body: string
|
|
24
29
|
subtitle?: string
|
|
25
|
-
/** Long-form markdown for the app's detail view; never on the banner. */
|
|
26
|
-
detail?: string
|
|
27
30
|
event?: string
|
|
28
|
-
/** What this notification is
|
|
31
|
+
/** What this notification is; absent means update. */
|
|
29
32
|
kind?: string
|
|
30
33
|
project?: string
|
|
31
|
-
session
|
|
34
|
+
/** Opaque exact-session override. */
|
|
35
|
+
sessionId?: string
|
|
36
|
+
/** Human-readable session label override. */
|
|
37
|
+
sessionLabel?: string
|
|
32
38
|
device?: string[]
|
|
33
39
|
all?: boolean
|
|
34
40
|
ttl?: number
|
|
@@ -37,7 +43,15 @@ export interface SendFlags {
|
|
|
37
43
|
threadId?: string
|
|
38
44
|
level?: string
|
|
39
45
|
data?: string[]
|
|
40
|
-
|
|
46
|
+
/** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
|
|
47
|
+
image?: string[]
|
|
48
|
+
/** Optional alt text paired with images by position. */
|
|
49
|
+
imageAlt?: string[]
|
|
50
|
+
/**
|
|
51
|
+
* Complete canonical media manifest for internal callers that already froze
|
|
52
|
+
* uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
|
|
53
|
+
*/
|
|
54
|
+
media?: MediaItemT[]
|
|
41
55
|
/** Enable the inline reply action for this Notification Request. */
|
|
42
56
|
reply?: boolean
|
|
43
57
|
/** How long the server accepts a reply after submission. */
|
|
@@ -67,33 +81,109 @@ export type DraftBuild =
|
|
|
67
81
|
| { ok: true; draft: NotificationDraftT; platform: Platform }
|
|
68
82
|
| { ok: false; error: string }
|
|
69
83
|
|
|
84
|
+
/** Invocation-derived values beneath explicit and configured author input. */
|
|
85
|
+
export interface DraftInvocation {
|
|
86
|
+
inferredProject?: string | null
|
|
87
|
+
source?: SourceContextT
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
|
|
91
|
+
|
|
92
|
+
/** Validate media cardinality and positional alt pairing before any upload starts. */
|
|
93
|
+
export function validateMediaInputs(
|
|
94
|
+
images: readonly string[] | undefined,
|
|
95
|
+
alts: readonly string[] | undefined,
|
|
96
|
+
): string | null {
|
|
97
|
+
const imageCount = images?.length ?? 0
|
|
98
|
+
const imageAlts = alts ?? []
|
|
99
|
+
if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
100
|
+
if (imageAlts.length > imageCount) {
|
|
101
|
+
return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
|
|
102
|
+
}
|
|
103
|
+
if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
|
|
104
|
+
return '--image-alt must be 1-256 characters.'
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Validate a frozen value-object manifest without collapsing sparse alt text. */
|
|
110
|
+
function validateMediaManifest(media: readonly MediaItemT[]): string | null {
|
|
111
|
+
if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
112
|
+
if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
|
|
113
|
+
return 'Media alt text must be 1-256 characters.'
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Rewrite authorable 1-based media positions to canonical ready media ids. */
|
|
119
|
+
export function rewriteMediaReferences(
|
|
120
|
+
body: string,
|
|
121
|
+
mediaIds: readonly string[],
|
|
122
|
+
): { ok: true; body: string } | { ok: false; error: string } {
|
|
123
|
+
let error: string | undefined
|
|
124
|
+
const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
|
|
125
|
+
const position = Number(digits)
|
|
126
|
+
if (
|
|
127
|
+
!Number.isSafeInteger(position) ||
|
|
128
|
+
position < 1 ||
|
|
129
|
+
position > MEDIA_MAX_ITEMS ||
|
|
130
|
+
digits !== String(position) ||
|
|
131
|
+
mediaIds[position - 1] === undefined
|
|
132
|
+
) {
|
|
133
|
+
error = `Body reference "${reference}" has no matching --image occurrence.`
|
|
134
|
+
return reference
|
|
135
|
+
}
|
|
136
|
+
return `media:${mediaIds[position - 1]}`
|
|
137
|
+
})
|
|
138
|
+
return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
|
|
139
|
+
}
|
|
140
|
+
|
|
70
141
|
/** Merge flags over resolved config into a Notification Request draft. */
|
|
71
|
-
export function buildDraft(
|
|
142
|
+
export function buildDraft(
|
|
143
|
+
config: CliConfig,
|
|
144
|
+
flags: SendFlags,
|
|
145
|
+
invocation: DraftInvocation = {},
|
|
146
|
+
): DraftBuild {
|
|
72
147
|
if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
|
|
73
148
|
const platform = resolvePlatform(flags.platform)
|
|
74
149
|
if (!platform) {
|
|
75
150
|
return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
|
|
76
151
|
}
|
|
77
152
|
|
|
153
|
+
if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
|
|
154
|
+
return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
|
|
155
|
+
}
|
|
156
|
+
const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
|
|
157
|
+
const imageAlts = flags.imageAlt ?? []
|
|
158
|
+
const mediaInputError =
|
|
159
|
+
flags.media === undefined
|
|
160
|
+
? validateMediaInputs(imageIds, imageAlts)
|
|
161
|
+
: validateMediaManifest(flags.media)
|
|
162
|
+
if (mediaInputError !== null) return { ok: false, error: mediaInputError }
|
|
163
|
+
const media =
|
|
164
|
+
flags.media ??
|
|
165
|
+
imageIds.map((media_id, index) => ({
|
|
166
|
+
media_id,
|
|
167
|
+
...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
|
|
168
|
+
}))
|
|
169
|
+
const body = rewriteMediaReferences(flags.body, imageIds)
|
|
170
|
+
if (!body.ok) return body
|
|
171
|
+
|
|
78
172
|
const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
|
|
79
173
|
const targets: NotificationDraftT['targets'] =
|
|
80
174
|
deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
|
|
81
175
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const sound = flags.sound ??
|
|
176
|
+
// Kind is semantic status metadata for Companion presentation. It never
|
|
177
|
+
// chooses native-banner attention; only an explicit flag or saved preference
|
|
178
|
+
// may add sound or interruption fields to the platform payload.
|
|
179
|
+
const sound = flags.sound ?? config.sound.value
|
|
86
180
|
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
87
181
|
return {
|
|
88
182
|
ok: false,
|
|
89
183
|
error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
|
|
90
184
|
}
|
|
91
185
|
}
|
|
92
|
-
const
|
|
93
|
-
config.interruption_level.source === 'default'
|
|
94
|
-
? undefined
|
|
95
|
-
: config.interruption_level.value
|
|
96
|
-
const level = flags.level ?? configuredLevel ?? profile?.level
|
|
186
|
+
const level = flags.level ?? config.interruption_level.value
|
|
97
187
|
if (
|
|
98
188
|
level !== null &&
|
|
99
189
|
level !== undefined &&
|
|
@@ -144,10 +234,7 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
144
234
|
}
|
|
145
235
|
}
|
|
146
236
|
|
|
147
|
-
const project = flags.project ?? config.project.value
|
|
148
|
-
// Harnesses can export NOTIFAI_SESSION once instead of passing the flag
|
|
149
|
-
// on every send.
|
|
150
|
-
const session = flags.session ?? process.env['NOTIFAI_SESSION']
|
|
237
|
+
const project = flags.project ?? config.project.value ?? invocation.inferredProject
|
|
151
238
|
|
|
152
239
|
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
153
240
|
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
@@ -161,19 +248,19 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
161
248
|
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
162
249
|
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
|
|
163
250
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
251
|
+
// A single reply question is the first readable block of the canonical body.
|
|
252
|
+
// Context may follow after a blank line without changing what the user answers.
|
|
253
|
+
const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
|
|
167
254
|
if (
|
|
168
255
|
flags.reply &&
|
|
169
256
|
flags.questions === undefined &&
|
|
170
|
-
|
|
257
|
+
derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
|
|
171
258
|
) {
|
|
172
259
|
return {
|
|
173
260
|
ok: false,
|
|
174
261
|
error:
|
|
175
|
-
`
|
|
176
|
-
'
|
|
262
|
+
`Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
|
|
263
|
+
'the longer context after a blank line — the first paragraph is the question.',
|
|
177
264
|
}
|
|
178
265
|
}
|
|
179
266
|
|
|
@@ -185,15 +272,12 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
185
272
|
: {}),
|
|
186
273
|
...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
|
|
187
274
|
...(project !== null && project !== undefined ? { project } : {}),
|
|
188
|
-
...(
|
|
275
|
+
...(invocation.source !== undefined ? { source: invocation.source } : {}),
|
|
189
276
|
presentation: {
|
|
190
277
|
title: flags.title,
|
|
191
|
-
body:
|
|
278
|
+
body: body.body,
|
|
192
279
|
...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
|
|
193
|
-
...(
|
|
194
|
-
? { detail: flags.detail }
|
|
195
|
-
: {}),
|
|
196
|
-
...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
|
|
280
|
+
...(media.length > 0 ? { media } : {}),
|
|
197
281
|
},
|
|
198
282
|
targets,
|
|
199
283
|
delivery: { ttl_seconds: ttl, collapse_key: collapse },
|
|
@@ -204,7 +288,7 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
204
288
|
questions: flags.questions ?? [
|
|
205
289
|
{
|
|
206
290
|
id: 'q1',
|
|
207
|
-
text:
|
|
291
|
+
text: derivedQuestion,
|
|
208
292
|
...(choices !== null ? { choices } : {}),
|
|
209
293
|
...(flags.replyMulti ? { multi: true } : {}),
|
|
210
294
|
},
|
|
@@ -223,12 +307,6 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
223
307
|
return { ok: true, draft, platform }
|
|
224
308
|
}
|
|
225
309
|
|
|
226
|
-
const KIND_PROFILES = {
|
|
227
|
-
update: { sound: 'none', level: 'passive' },
|
|
228
|
-
done: { sound: 'done', level: 'passive' },
|
|
229
|
-
question: { sound: 'attention', level: 'active' },
|
|
230
|
-
} as const
|
|
231
|
-
|
|
232
310
|
function resolvePlatform(value: string | undefined): Platform | null {
|
|
233
311
|
if (value === undefined) return 'ios'
|
|
234
312
|
return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
|