@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.
@@ -44,28 +44,21 @@ function ours(): HookConfig {
44
44
  }
45
45
 
46
46
  describe('choice labels', () => {
47
- it('splits a single value on commas for the one-liner case', () => {
48
- expect(parseChoices('Staging,Production')).toEqual([
49
- { id: 'staging', label: 'Staging' },
50
- { id: 'production', label: 'Production' },
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('Yes, it worked,No, something broke')).toHaveLength(4)
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 (2-6 answers); one value splits on commas, or repeat the flag',
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 <question>')
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 (2-6); one value splits on commas, or repeat the flag',
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: { choice?: string[]; session?: string }) => {
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 value splits on
45
- * commas; repeating the flag passes each label through verbatim.
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 | 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
- ...(choices !== null ? { kind: 'choice' as const, choices } : {}),
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
- * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
266
+ * One flag occurrence, one answer: `--choice Staging --choice Production` ->
267
+ * `[{id:'staging',label:'Staging'}, ...]`.
233
268
  *
234
- * A single occurrence splits on commas, which keeps the common one-liner
235
- * short. Repeating the flag is the escape hatch, and the only way to write a
236
- * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
237
- * silently yields three choices, and nothing downstream can detect that the
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 | string[] | undefined,
275
+ value: string[] | undefined,
242
276
  ): { id: string; label: string }[] | null | 'invalid' {
243
- if (value === undefined) return null
244
- const raw = Array.isArray(value) ? value : [value]
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
- 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
259
- 'commas ("Staging,Production"); repeat the flag when a label contains a comma.'
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, '-')