@raidiant/notifai 0.1.8 → 0.2.1
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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
|
@@ -44,28 +44,21 @@ function ours(): HookConfig {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
describe('choice labels', () => {
|
|
47
|
-
it('
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
])
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('keeps commas inside a label when the flag is repeated', () => {
|
|
55
|
-
// Observed live 2026-08-02: "Yes, it worked,No, something broke" passed as
|
|
56
|
-
// one value silently became four choices, and 2-6 validation let it
|
|
57
|
-
// through because four is in range. Repeating the flag is the escape.
|
|
47
|
+
it('treats a comma as an ordinary character, never a delimiter', () => {
|
|
48
|
+
// Observed live 2026-08-02 under the old comma-split grammar:
|
|
49
|
+
// "Yes, it worked,No, something broke" passed as one value silently
|
|
50
|
+
// became four choices. The delimiter form is gone; one flag, one answer.
|
|
58
51
|
expect(parseChoices(['Yes, it worked', 'No, something broke'])).toEqual([
|
|
59
52
|
{ id: 'yes-it-worked', label: 'Yes, it worked' },
|
|
60
53
|
{ id: 'no-something-broke', label: 'No, something broke' },
|
|
61
54
|
])
|
|
62
|
-
expect(parseChoices('
|
|
55
|
+
expect(parseChoices(['Staging,Production', 'Neither'])).toEqual([
|
|
56
|
+
{ id: 'staging-production', label: 'Staging,Production' },
|
|
57
|
+
{ id: 'neither', label: 'Neither' },
|
|
58
|
+
])
|
|
63
59
|
})
|
|
64
60
|
|
|
65
61
|
it('rejects a set that cannot become a valid closed question', () => {
|
|
66
|
-
// A pipe-separated string is one label, not two — the shape that reached
|
|
67
|
-
// the hook and failed there instead of at registration.
|
|
68
|
-
expect(parseChoices('Yes|No')).toBe('invalid')
|
|
69
62
|
expect(parseChoices(['Only one'])).toBe('invalid')
|
|
70
63
|
expect(parseChoices(['A', 'a'])).toBe('invalid')
|
|
71
64
|
expect(parseChoices(['1', '2', '3', '4', '5', '6', '7'])).toBe('invalid')
|
package/src/main.ts
CHANGED
|
@@ -195,9 +195,10 @@ const send = program
|
|
|
195
195
|
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
|
|
196
196
|
.option(
|
|
197
197
|
'--reply-choice <label>',
|
|
198
|
-
'with --reply, ask a closed question
|
|
198
|
+
'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
|
|
199
199
|
(v: string, all: string[]) => [...all, v], [],
|
|
200
200
|
)
|
|
201
|
+
.option('--reply-multi', 'with --reply-choice, several answers may be selected')
|
|
201
202
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
202
203
|
// a message pointing at `ask` instead of "unknown option".
|
|
203
204
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
@@ -264,19 +265,55 @@ program
|
|
|
264
265
|
})
|
|
265
266
|
|
|
266
267
|
program
|
|
267
|
-
.command('ask
|
|
268
|
+
.command('ask [question]')
|
|
268
269
|
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
269
270
|
.option(
|
|
270
271
|
'--choice <label>',
|
|
271
|
-
'answers to offer instead of free text
|
|
272
|
+
'answers to offer instead of free text; repeat the flag once per answer (2-6)',
|
|
272
273
|
(v: string, all: string[]) => [...all, v], [],
|
|
273
274
|
)
|
|
275
|
+
.option('--multi', 'with --choice, several answers may be selected')
|
|
276
|
+
.option('--detail <markdown>', 'long-form context shown only in the app, never on the banner')
|
|
277
|
+
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
278
|
+
.option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
|
|
274
279
|
.option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
|
|
275
|
-
.action((question: string, opts: {
|
|
280
|
+
.action((question: string | undefined, opts: {
|
|
281
|
+
choice?: string[]
|
|
282
|
+
multi?: boolean
|
|
283
|
+
detail?: string
|
|
284
|
+
detailFile?: string
|
|
285
|
+
form?: string
|
|
286
|
+
session?: string
|
|
287
|
+
}) => {
|
|
288
|
+
let detail = opts.detail
|
|
289
|
+
if (typeof opts.detailFile === 'string') {
|
|
290
|
+
if (detail !== undefined) {
|
|
291
|
+
deps.io.err('Pass either --detail or --detail-file, not both.')
|
|
292
|
+
process.exit(2)
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
detail = readFileSync(opts.detailFile === '-' ? 0 : opts.detailFile, 'utf8')
|
|
296
|
+
} catch (err) {
|
|
297
|
+
deps.io.err(`Could not read ${opts.detailFile}: ${String(err)}`)
|
|
298
|
+
process.exit(2)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let form: string | undefined
|
|
302
|
+
if (typeof opts.form === 'string') {
|
|
303
|
+
try {
|
|
304
|
+
form = readFileSync(opts.form === '-' ? 0 : opts.form, 'utf8')
|
|
305
|
+
} catch (err) {
|
|
306
|
+
deps.io.err(`Could not read ${opts.form}: ${String(err)}`)
|
|
307
|
+
process.exit(2)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
276
310
|
// commander's collector defaults to []; an empty list means "not asked".
|
|
277
311
|
const flags: Parameters<typeof askCommand>[2] = {
|
|
278
312
|
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
279
313
|
...(opts.choice?.length ? { choice: opts.choice } : {}),
|
|
314
|
+
...(opts.multi ? { multi: true } : {}),
|
|
315
|
+
...(detail !== undefined ? { detail } : {}),
|
|
316
|
+
...(form !== undefined ? { form } : {}),
|
|
280
317
|
}
|
|
281
318
|
process.exit(askCommand(deps, question, flags))
|
|
282
319
|
})
|
package/src/send.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
4
|
INTERRUPTION_LEVELS,
|
|
5
5
|
NOTIFICATION_SCHEMA_VERSION,
|
|
6
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
6
7
|
type IosOptionsT,
|
|
7
8
|
type LifecycleT,
|
|
8
9
|
type MacosOptionsT,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
NOTIFICATION_KINDS,
|
|
11
12
|
PLATFORMS,
|
|
12
13
|
type Platform,
|
|
14
|
+
type QuestionT,
|
|
13
15
|
type SubmissionReceipt,
|
|
14
16
|
} from '@raidiant/notifai-protocol'
|
|
15
17
|
import type { CliConfig } from './config.js'
|
|
@@ -41,10 +43,17 @@ export interface SendFlags {
|
|
|
41
43
|
/** How long the server accepts a reply after submission. */
|
|
42
44
|
replyWindow?: number
|
|
43
45
|
/**
|
|
44
|
-
* Answer labels; turns the reply into a closed question. One
|
|
45
|
-
*
|
|
46
|
+
* Answer labels; turns the reply into a closed question. One flag occurrence
|
|
47
|
+
* per label, always — a label is a label, whatever characters it contains.
|
|
46
48
|
*/
|
|
47
|
-
replyChoice?: string
|
|
49
|
+
replyChoice?: string[]
|
|
50
|
+
/** The user may select several of the offered answers. */
|
|
51
|
+
replyMulti?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* A full question set, overriding the single question the flags describe.
|
|
54
|
+
* Not a user-facing flag: `ask --form` and the hooks build it.
|
|
55
|
+
*/
|
|
56
|
+
questions?: QuestionT[]
|
|
48
57
|
/** Platform whose optional notification fields these flags configure. */
|
|
49
58
|
platform?: string
|
|
50
59
|
/**
|
|
@@ -149,6 +158,24 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
149
158
|
error: CHOICE_USAGE,
|
|
150
159
|
}
|
|
151
160
|
}
|
|
161
|
+
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
162
|
+
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
|
|
163
|
+
}
|
|
164
|
+
// The banner body doubles as the question text on a single-question send, so
|
|
165
|
+
// it inherits the question's readable-in-full bound; context beyond that
|
|
166
|
+
// belongs in --detail, which never rides the banner.
|
|
167
|
+
if (
|
|
168
|
+
flags.reply &&
|
|
169
|
+
flags.questions === undefined &&
|
|
170
|
+
flags.body.length > QUESTION_TEXT_MAX_LENGTH
|
|
171
|
+
) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error:
|
|
175
|
+
`A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
|
|
176
|
+
'characters and put the longer context in --detail.',
|
|
177
|
+
}
|
|
178
|
+
}
|
|
152
179
|
|
|
153
180
|
const draft: NotificationDraftT = {
|
|
154
181
|
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
@@ -174,7 +201,14 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
174
201
|
? {
|
|
175
202
|
reply: {
|
|
176
203
|
expires_in_seconds: flags.replyWindow ?? 3600,
|
|
177
|
-
|
|
204
|
+
questions: flags.questions ?? [
|
|
205
|
+
{
|
|
206
|
+
id: 'q1',
|
|
207
|
+
text: flags.body,
|
|
208
|
+
...(choices !== null ? { choices } : {}),
|
|
209
|
+
...(flags.replyMulti ? { multi: true } : {}),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
178
212
|
},
|
|
179
213
|
}
|
|
180
214
|
: {}),
|
|
@@ -229,23 +263,19 @@ export function receiptExitCode(receipt: SubmissionReceipt): number {
|
|
|
229
263
|
}
|
|
230
264
|
|
|
231
265
|
/**
|
|
232
|
-
*
|
|
266
|
+
* One flag occurrence, one answer: `--choice Staging --choice Production` ->
|
|
267
|
+
* `[{id:'staging',label:'Staging'}, ...]`.
|
|
233
268
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* agent meant two.
|
|
269
|
+
* Commas are ordinary characters. The old single-value comma split produced
|
|
270
|
+
* buttons the agent never intended ("Nothing, I'll review" became two answers)
|
|
271
|
+
* and nothing downstream could detect it, so the delimiter form is gone
|
|
272
|
+
* rather than guarded: a label is a label.
|
|
239
273
|
*/
|
|
240
274
|
export function parseChoices(
|
|
241
|
-
value: string
|
|
275
|
+
value: string[] | undefined,
|
|
242
276
|
): { id: string; label: string }[] | null | 'invalid' {
|
|
243
|
-
if (value === undefined) return null
|
|
244
|
-
const
|
|
245
|
-
if (raw.length === 0) return null
|
|
246
|
-
const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
|
|
247
|
-
.map((part) => part.trim())
|
|
248
|
-
.filter((part) => part.length > 0)
|
|
277
|
+
if (value === undefined || value.length === 0) return null
|
|
278
|
+
const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
|
|
249
279
|
if (labels.length < 2 || labels.length > 6) return 'invalid'
|
|
250
280
|
const choices = labels.map((label) => ({ id: slugify(label), label }))
|
|
251
281
|
if (choices.some((choice) => choice.id === '')) return 'invalid'
|
|
@@ -255,38 +285,10 @@ export function parseChoices(
|
|
|
255
285
|
|
|
256
286
|
/** Message shared by every surface that accepts choice labels. */
|
|
257
287
|
export const CHOICE_USAGE =
|
|
258
|
-
'
|
|
259
|
-
'
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Catches the split that looked like it worked.
|
|
263
|
-
*
|
|
264
|
-
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
265
|
-
* buttons rather than three, because the third label contained a comma. Nothing
|
|
266
|
-
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
267
|
-
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
268
|
-
* never intended.
|
|
269
|
-
*
|
|
270
|
-
* The tell is a comma followed by a space. A delimiter list is written
|
|
271
|
-
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
272
|
-
* will occasionally be wrong, which is exactly why this warns and names the
|
|
273
|
-
* escape hatch rather than rejecting: the split may well have been intended.
|
|
274
|
-
*/
|
|
275
|
-
export function ambiguousChoiceSplit(value: string | string[] | undefined): string | null {
|
|
276
|
-
if (value === undefined || Array.isArray(value)) return null
|
|
277
|
-
if (!/,\s/.test(value)) return null
|
|
278
|
-
const parts = value
|
|
279
|
-
.split(',')
|
|
280
|
-
.map((part) => part.trim())
|
|
281
|
-
.filter((part) => part.length > 0)
|
|
282
|
-
return (
|
|
283
|
-
`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
|
|
284
|
-
`(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
|
|
285
|
-
'pass --choice once per answer instead.'
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
+
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
289
|
+
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
288
290
|
|
|
289
|
-
function slugify(label: string): string {
|
|
291
|
+
export function slugify(label: string): string {
|
|
290
292
|
return label
|
|
291
293
|
.toLowerCase()
|
|
292
294
|
.replace(/[^a-z0-9]+/g, '-')
|