@raidiant/notifai 0.2.0 → 0.3.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/dist/commands.d.ts +33 -3
- package/dist/commands.js +283 -59
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +2 -1
- package/src/commands.test.ts +153 -11
- package/src/commands.ts +328 -60
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
authStatusCommand,
|
|
10
10
|
capabilitiesCommand,
|
|
11
11
|
closeCommand,
|
|
12
|
+
configExplainCommand,
|
|
12
13
|
configSetCommand,
|
|
13
14
|
configShowCommand,
|
|
14
15
|
devicesCommand,
|
|
@@ -29,6 +30,7 @@ import { defaultCredentialStore } from './credentials.js'
|
|
|
29
30
|
import { HARNESSES } from './install-hooks.js'
|
|
30
31
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
31
32
|
import { nativeSkills, type SkillScope } from './native-skills.js'
|
|
33
|
+
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
32
34
|
|
|
33
35
|
const deps: CommandDeps = {
|
|
34
36
|
io: realIo(),
|
|
@@ -92,10 +94,51 @@ function version(): string {
|
|
|
92
94
|
const program = new Command('notifai')
|
|
93
95
|
.description('Send native device notifications from agents and local programs')
|
|
94
96
|
.version(version())
|
|
97
|
+
.configureHelp(helpConfiguration)
|
|
98
|
+
// Lazy on purpose. `addHelpText` also takes a string, but that builds the
|
|
99
|
+
// footer on every invocation — including the hook that runs in front of
|
|
100
|
+
// every prompt the user types, which never renders help at all.
|
|
101
|
+
.addHelpText('after', () => rootHelpFooter())
|
|
102
|
+
// Commander's default is to print the full help and exit 0 for an unknown
|
|
103
|
+
// `help` topic, so `notifai help nonsense` looked like it had worked. A
|
|
104
|
+
// reader who mistyped a command deserves to be told, and a script deserves
|
|
105
|
+
// a nonzero code.
|
|
106
|
+
.showSuggestionAfterError(true)
|
|
107
|
+
// Commander drops the implicit `help [command]` as soon as the program
|
|
108
|
+
// itself has an action handler — which the interactive default below gives
|
|
109
|
+
// it. Without this, adding that default would silently break
|
|
110
|
+
// `notifai help send` for everyone who reaches for it before `--help`.
|
|
111
|
+
.helpCommand(true)
|
|
112
|
+
// Where the implicit help command lands; every other command names its own.
|
|
113
|
+
.commandsGroup(GROUP.help)
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bare `notifai`.
|
|
117
|
+
*
|
|
118
|
+
* At a human terminal this opens the interactive app; anywhere else it prints
|
|
119
|
+
* help exactly as before. The check is the same one every other prompt in this
|
|
120
|
+
* CLI is gated on — stdin and stdout both TTYs, not CI, `NOTIFAI_NO_INPUT`
|
|
121
|
+
* unset — because an agent that reaches a prompt does not fail, it hangs
|
|
122
|
+
* for ever waiting on a stdin nobody is typing into.
|
|
123
|
+
*
|
|
124
|
+
* The import is dynamic so that the prompt library, the banner and the whole
|
|
125
|
+
* interactive tree cost nothing to the paths that never draw them — `send`,
|
|
126
|
+
* `ask`, and the hook that runs in front of every prompt the user types.
|
|
127
|
+
*/
|
|
128
|
+
program.action(async () => {
|
|
129
|
+
if (deps.io.interactive !== true) {
|
|
130
|
+
program.outputHelp()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const { interactiveCommand } = await import('./interactive.js')
|
|
134
|
+
process.exit(await interactiveCommand(deps, version()))
|
|
135
|
+
})
|
|
95
136
|
|
|
96
137
|
// Setup leads: `init` is the single entry command for first-run friendliness.
|
|
97
138
|
program
|
|
98
139
|
.command('init')
|
|
140
|
+
.helpGroup(GROUP.start)
|
|
141
|
+
.summary('Set this project up, step by step')
|
|
99
142
|
.description(
|
|
100
143
|
'Set up Notifai here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
101
144
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
@@ -112,6 +155,8 @@ program
|
|
|
112
155
|
|
|
113
156
|
program
|
|
114
157
|
.command('doctor')
|
|
158
|
+
.helpGroup(GROUP.daily)
|
|
159
|
+
.summary('Check every part of the setup')
|
|
115
160
|
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof; exits nonzero when any line is FAIL (no live send)')
|
|
116
161
|
.option('--json', 'machine-readable output')
|
|
117
162
|
.action(async (opts: { json?: boolean }) => {
|
|
@@ -120,6 +165,8 @@ program
|
|
|
120
165
|
|
|
121
166
|
program
|
|
122
167
|
.command('login')
|
|
168
|
+
.helpGroup(GROUP.start)
|
|
169
|
+
.summary('Sign in and pair this machine')
|
|
123
170
|
.description('Pair this machine with your Notifai account via browser approval')
|
|
124
171
|
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
125
172
|
.option('--base-url <url>', 'Notifai server URL')
|
|
@@ -130,12 +177,18 @@ program
|
|
|
130
177
|
|
|
131
178
|
program
|
|
132
179
|
.command('logout')
|
|
180
|
+
.helpGroup(GROUP.advanced)
|
|
181
|
+
.summary('Remove the stored machine credential')
|
|
133
182
|
.description('Remove the stored machine credential')
|
|
134
183
|
.action(() => {
|
|
135
184
|
process.exit(logoutCommand(deps))
|
|
136
185
|
})
|
|
137
186
|
|
|
138
|
-
const auth = program
|
|
187
|
+
const auth = program
|
|
188
|
+
.command('auth')
|
|
189
|
+
.description('Authentication helpers')
|
|
190
|
+
.summary('Machine identity and account plan')
|
|
191
|
+
.helpGroup(GROUP.advanced)
|
|
139
192
|
auth
|
|
140
193
|
.command('status')
|
|
141
194
|
.description('Show the stored machine identity')
|
|
@@ -153,6 +206,8 @@ auth
|
|
|
153
206
|
|
|
154
207
|
program
|
|
155
208
|
.command('devices')
|
|
209
|
+
.helpGroup(GROUP.daily)
|
|
210
|
+
.summary('List your devices and whether they can receive')
|
|
156
211
|
.description('List registered devices and their delivery readiness')
|
|
157
212
|
.option('--json', 'machine-readable output')
|
|
158
213
|
.action(async (opts: { json?: boolean }) => {
|
|
@@ -161,6 +216,8 @@ program
|
|
|
161
216
|
|
|
162
217
|
program
|
|
163
218
|
.command('capabilities')
|
|
219
|
+
.helpGroup(GROUP.advanced)
|
|
220
|
+
.summary('Show what a platform can render')
|
|
164
221
|
.description('Show a platform capability contract')
|
|
165
222
|
.option('--platform <platform>', 'platform to describe (default: ios)')
|
|
166
223
|
.option('--json', 'machine-readable output')
|
|
@@ -170,26 +227,31 @@ program
|
|
|
170
227
|
|
|
171
228
|
const send = program
|
|
172
229
|
.command('send')
|
|
230
|
+
.helpGroup(GROUP.agent)
|
|
231
|
+
.summary('Send a notification')
|
|
173
232
|
.description('Send a notification')
|
|
233
|
+
.optionsGroup(SEND_GROUP.content)
|
|
174
234
|
.requiredOption('--title <title>', 'notification title')
|
|
175
235
|
.requiredOption('--body <body>', 'notification body')
|
|
176
|
-
.option('--subtitle <subtitle>')
|
|
236
|
+
.option('--subtitle <subtitle>', 'a line between the title and the body')
|
|
177
237
|
.option('--detail <markdown>', 'long-form markdown shown only in the app, never on the banner')
|
|
178
238
|
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
179
|
-
.option('--
|
|
239
|
+
.option('--image <path|url|media_id>', 'upload or attach an image')
|
|
240
|
+
.optionsGroup(SEND_GROUP.routing)
|
|
180
241
|
.option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
|
|
181
242
|
.option('--project <id>', 'project identifier, e.g. my-app (lazily registered)')
|
|
182
|
-
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
183
243
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
184
244
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
185
|
-
.option('--
|
|
186
|
-
.option('--
|
|
187
|
-
.
|
|
245
|
+
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
246
|
+
.option('--event <event>', 'agent event name, e.g. tests_passed')
|
|
247
|
+
.optionsGroup(SEND_GROUP.presentation)
|
|
188
248
|
.option('--sound <sound>', 'default | done | attention | alert | none')
|
|
189
|
-
.option('--thread-id <id>', 'group related notifications')
|
|
190
249
|
.option('--level <level>', 'interruption level: passive | active | time_sensitive')
|
|
191
|
-
.option('--
|
|
192
|
-
.option('--
|
|
250
|
+
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
251
|
+
.option('--thread-id <id>', 'group related notifications')
|
|
252
|
+
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
253
|
+
.option('--platform <platform>', 'limit optional fields to ios or macos (default: both)')
|
|
254
|
+
.optionsGroup(SEND_GROUP.reply)
|
|
193
255
|
.option('--reply', 'enable the inline reply action and block for the answer')
|
|
194
256
|
.option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
|
|
195
257
|
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
|
|
@@ -202,8 +264,10 @@ const send = program
|
|
|
202
264
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
203
265
|
// a message pointing at `ask` instead of "unknown option".
|
|
204
266
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
267
|
+
.optionsGroup(SEND_GROUP.advanced)
|
|
205
268
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
206
269
|
.option('--no-wait', 'return immediately after acceptance')
|
|
270
|
+
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
207
271
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
208
272
|
.option('--base-url <url>', 'Notifai server URL')
|
|
209
273
|
.option('--json', 'print the full submission receipt as JSON')
|
|
@@ -247,6 +311,8 @@ send.addHelpText(
|
|
|
247
311
|
|
|
248
312
|
program
|
|
249
313
|
.command('replies [request_id]')
|
|
314
|
+
.helpGroup(GROUP.agent)
|
|
315
|
+
.summary('Retrieve replies to a question')
|
|
250
316
|
.description('Retrieve replies for a notification request')
|
|
251
317
|
.option('--pending', 'use the pushed question pending for this project session')
|
|
252
318
|
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
@@ -258,6 +324,8 @@ program
|
|
|
258
324
|
|
|
259
325
|
program
|
|
260
326
|
.command('status <request_id>')
|
|
327
|
+
.helpGroup(GROUP.agent)
|
|
328
|
+
.summary('Show the evidence trail for a request')
|
|
261
329
|
.description('Show the evidence trail for a notification request')
|
|
262
330
|
.option('--json', 'machine-readable output')
|
|
263
331
|
.action(async (requestId: string, opts: { json?: boolean }) => {
|
|
@@ -266,6 +334,8 @@ program
|
|
|
266
334
|
|
|
267
335
|
program
|
|
268
336
|
.command('ask [question]')
|
|
337
|
+
.helpGroup(GROUP.agent)
|
|
338
|
+
.summary('Register a question, then end the turn')
|
|
269
339
|
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
270
340
|
.option(
|
|
271
341
|
'--choice <label>',
|
|
@@ -320,13 +390,18 @@ program
|
|
|
320
390
|
|
|
321
391
|
program
|
|
322
392
|
.command('close <request_id>')
|
|
393
|
+
.helpGroup(GROUP.agent)
|
|
394
|
+
.summary('Retire a question so late answers are rejected')
|
|
323
395
|
.description('Retire a question so late answers are rejected rather than lost')
|
|
324
396
|
.action(async (requestId: string) => {
|
|
325
397
|
process.exit(await closeCommand(deps, requestId))
|
|
326
398
|
})
|
|
327
399
|
|
|
400
|
+
// Hidden, not removed. It is an entry point the hook installer writes into a
|
|
401
|
+
// harness config file, never something a person types — and listing it beside
|
|
402
|
+
// `send` invited exactly one reading, which is that you were supposed to.
|
|
328
403
|
program
|
|
329
|
-
.command('hook <event>')
|
|
404
|
+
.command('hook <event>', { hidden: true })
|
|
330
405
|
.description('Internal: run a harness hook (reads hook JSON on stdin)')
|
|
331
406
|
// Inert, and the point of it is that it is inert: the installed command line
|
|
332
407
|
// carries a marker that says "Notifai wrote this" independently of which
|
|
@@ -340,7 +415,11 @@ program
|
|
|
340
415
|
)
|
|
341
416
|
})
|
|
342
417
|
|
|
343
|
-
const hooks = program
|
|
418
|
+
const hooks = program
|
|
419
|
+
.command('hooks')
|
|
420
|
+
.description('Install harness hooks for registered-question routing')
|
|
421
|
+
.summary('Wire a harness to route questions to your devices')
|
|
422
|
+
.helpGroup(GROUP.advanced)
|
|
344
423
|
hooks
|
|
345
424
|
.command('install')
|
|
346
425
|
.description('Wire this harness to route registered questions to your devices')
|
|
@@ -361,15 +440,27 @@ hooks
|
|
|
361
440
|
process.exit(hooksUninstallCommand(deps, opts))
|
|
362
441
|
})
|
|
363
442
|
|
|
364
|
-
const config = program
|
|
443
|
+
const config = program
|
|
444
|
+
.command('config')
|
|
445
|
+
.description('Show or change settings')
|
|
446
|
+
.summary('Show or change settings')
|
|
447
|
+
.helpGroup(GROUP.daily)
|
|
365
448
|
config
|
|
366
449
|
.command('show')
|
|
367
|
-
.description('Show
|
|
368
|
-
.option('--explain', '
|
|
450
|
+
.description('Show every setting, what it does, and where its value came from')
|
|
451
|
+
.option('--explain', 'include advanced settings and the file each value came from')
|
|
452
|
+
.option('--plain', 'the flat key = value form, even at a terminal')
|
|
369
453
|
.option('--json', 'machine-readable output')
|
|
370
|
-
.action((opts: { json?: boolean; explain?: boolean }) => {
|
|
454
|
+
.action((opts: { json?: boolean; explain?: boolean; plain?: boolean }) => {
|
|
371
455
|
process.exit(configShowCommand(deps, opts))
|
|
372
456
|
})
|
|
457
|
+
config
|
|
458
|
+
.command('explain <key>')
|
|
459
|
+
.description('Explain one setting in full: what it does, what it accepts, what it is now')
|
|
460
|
+
.option('--json', 'machine-readable output')
|
|
461
|
+
.action((key: string, opts: { json?: boolean }) => {
|
|
462
|
+
process.exit(configExplainCommand(deps, key, opts))
|
|
463
|
+
})
|
|
373
464
|
config
|
|
374
465
|
.command('set <key> <value>')
|
|
375
466
|
.description(
|
package/src/ui/banner.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wordmark shown when a human opens the interactive app.
|
|
3
|
+
*
|
|
4
|
+
* Three tiers, chosen by what the terminal can actually render rather than by
|
|
5
|
+
* what looks best in a screenshot: box-drawing blocks when the terminal is wide
|
|
6
|
+
* and Unicode-capable, a pure-ASCII form that survives anywhere, and a plain
|
|
7
|
+
* word when neither fits. A banner that wraps is worse than no banner, so the
|
|
8
|
+
* width check is not optional.
|
|
9
|
+
*
|
|
10
|
+
* The art is embedded rather than rendered at run time. It is one fixed word
|
|
11
|
+
* that will never change, and shipping a font engine to redraw a constant on
|
|
12
|
+
* every launch would cost every install — including the agents that never see
|
|
13
|
+
* it — for no benefit.
|
|
14
|
+
*/
|
|
15
|
+
import { gradientBlock, style, terminalWidth, unicode } from './theme.js'
|
|
16
|
+
|
|
17
|
+
/** ANSI Shadow. 50 columns, 6 rows. */
|
|
18
|
+
const BLOCK = [
|
|
19
|
+
'███╗ ██╗ ██████╗ ████████╗██╗███████╗ █████╗ ██╗',
|
|
20
|
+
'████╗ ██║██╔═══██╗╚══██╔══╝██║██╔════╝██╔══██╗██║',
|
|
21
|
+
'██╔██╗ ██║██║ ██║ ██║ ██║█████╗ ███████║██║',
|
|
22
|
+
'██║╚██╗██║██║ ██║ ██║ ██║██╔══╝ ██╔══██║██║',
|
|
23
|
+
'██║ ╚████║╚██████╔╝ ██║ ██║██║ ██║ ██║██║',
|
|
24
|
+
'╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Pure ASCII. 34 columns, 4 rows — safe on any terminal, any code page. */
|
|
28
|
+
const ASCII = [
|
|
29
|
+
' _ _ ___ _____ ___ ___ _ ___ ',
|
|
30
|
+
' | \\| |/ _ \\_ _|_ _| __/_\\ |_ _|',
|
|
31
|
+
' | .` | (_) || | | || _/ _ \\ | | ',
|
|
32
|
+
' |_|\\_|\\___/ |_| |___|_/_/ \\_\\___|',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export type BannerSize = 'block' | 'ascii' | 'word'
|
|
36
|
+
|
|
37
|
+
export function bannerSize(columns = terminalWidth()): BannerSize {
|
|
38
|
+
if (unicode && columns >= 54) return 'block'
|
|
39
|
+
if (columns >= 38) return 'ascii'
|
|
40
|
+
return 'word'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The wordmark as rendered lines, gradient included. */
|
|
44
|
+
export function bannerLines(columns = terminalWidth()): string[] {
|
|
45
|
+
switch (bannerSize(columns)) {
|
|
46
|
+
case 'block':
|
|
47
|
+
return gradientBlock(BLOCK)
|
|
48
|
+
case 'ascii':
|
|
49
|
+
return gradientBlock(ASCII)
|
|
50
|
+
case 'word':
|
|
51
|
+
return gradientBlock(['N O T I F A I'])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reveal the wordmark one row at a time.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately brief and deliberately skippable: this runs before someone can
|
|
59
|
+
* do anything, so it may not become a wait. It is skipped entirely when output
|
|
60
|
+
* is not a TTY, when `NOTIFAI_NO_ANIMATION` is set, and under `CI`.
|
|
61
|
+
*/
|
|
62
|
+
export async function printBanner(
|
|
63
|
+
options: { animate?: boolean; env?: NodeJS.ProcessEnv; write?: (line: string) => void } = {},
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const env = options.env ?? process.env
|
|
66
|
+
const write = options.write ?? ((line: string) => process.stdout.write(`${line}\n`))
|
|
67
|
+
const lines = bannerLines()
|
|
68
|
+
const animate =
|
|
69
|
+
options.animate !== false &&
|
|
70
|
+
process.stdout.isTTY === true &&
|
|
71
|
+
(env['NOTIFAI_NO_ANIMATION'] ?? '') === '' &&
|
|
72
|
+
(env['CI'] ?? '') === ''
|
|
73
|
+
|
|
74
|
+
write('')
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
// Two columns, matching the tagline and the status card beneath it. Flush
|
|
77
|
+
// left, the wordmark reads as a separate thing sitting above the app.
|
|
78
|
+
write(` ${line}`)
|
|
79
|
+
if (animate) await new Promise((resolve) => setTimeout(resolve, 26))
|
|
80
|
+
}
|
|
81
|
+
write('')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One line of orientation under the wordmark — and it must stay one line.
|
|
86
|
+
* Wrapped onto two it stops reading as a subtitle and starts reading as the
|
|
87
|
+
* first paragraph of something, so a narrow terminal gets the version alone.
|
|
88
|
+
*/
|
|
89
|
+
export function tagline(version: string, columns = terminalWidth()): string {
|
|
90
|
+
const full = 'Native notifications for agents and local programs'
|
|
91
|
+
// Two leading columns of indent, two trailing for the version.
|
|
92
|
+
return columns >= full.length + 12
|
|
93
|
+
? `${style.dim(full)} ${style.dim(`v${version}`)}`
|
|
94
|
+
: style.dim(`v${version}`)
|
|
95
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { CONFIG_KEYS, loadConfig } from '../config.js'
|
|
5
|
+
import { renderConfigExplain, renderConfigList, renderConfigPlain } from './config-view.js'
|
|
6
|
+
import { width } from './theme.js'
|
|
7
|
+
|
|
8
|
+
const config = loadConfig({
|
|
9
|
+
cwd: os.tmpdir(),
|
|
10
|
+
env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-config-view-tests') },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('renderConfigPlain', () => {
|
|
14
|
+
it('is the flat form scripts already parse', () => {
|
|
15
|
+
// This is the agent-facing shape and the reason the pretty renderer is
|
|
16
|
+
// gated on a human terminal. Restyling it would be a breaking change for
|
|
17
|
+
// an audience that cannot see the styling.
|
|
18
|
+
const lines = renderConfigPlain(config, false)
|
|
19
|
+
expect(lines).toHaveLength(CONFIG_KEYS.length)
|
|
20
|
+
expect(lines[0]).toBe(`base_url = ${JSON.stringify(config.base_url.value)}`)
|
|
21
|
+
for (const line of lines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('appends the raw source tag under --explain', () => {
|
|
25
|
+
const lines = renderConfigPlain(config, true)
|
|
26
|
+
expect(lines[0]).toBe(
|
|
27
|
+
`base_url = ${JSON.stringify(config.base_url.value)} [${config.base_url.source}]`,
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('carries no colour, so a pipe gets no escape bytes', () => {
|
|
32
|
+
for (const line of renderConfigPlain(config, true)) {
|
|
33
|
+
expect(line).toBe(line.replace(/\[[0-9;]*m/g, ''))
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('renderConfigList', () => {
|
|
39
|
+
it('names every non-advanced key and explains it', () => {
|
|
40
|
+
const text = renderConfigList(config).join('\n')
|
|
41
|
+
for (const key of CONFIG_KEYS) {
|
|
42
|
+
const advanced = key === 'base_url' || key === 'collapse_key'
|
|
43
|
+
if (advanced) continue
|
|
44
|
+
expect(text, key).toContain(key)
|
|
45
|
+
}
|
|
46
|
+
expect(text).toContain('Questions & presence')
|
|
47
|
+
expect(text).toContain('notifai config explain')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('reveals advanced keys only when asked', () => {
|
|
51
|
+
expect(renderConfigList(config).join('\n')).not.toContain('base_url')
|
|
52
|
+
expect(renderConfigList(config, { showAdvanced: true }).join('\n')).toContain('base_url')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('fits the terminal, so no row wraps into the next', () => {
|
|
56
|
+
const columns = process.stdout.columns
|
|
57
|
+
Object.defineProperty(process.stdout, 'columns', { value: 80, configurable: true })
|
|
58
|
+
try {
|
|
59
|
+
for (const line of renderConfigList(config, { showAdvanced: true })) {
|
|
60
|
+
expect(width(line), line).toBeLessThanOrEqual(80)
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('renderConfigExplain', () => {
|
|
69
|
+
it('answers what it is, what it is now, and what it accepts', () => {
|
|
70
|
+
const text = renderConfigExplain('require_idle', config).join('\n')
|
|
71
|
+
expect(text).toContain('require_idle')
|
|
72
|
+
expect(text).toContain('Only when I have stepped away')
|
|
73
|
+
expect(text).toContain('Accepts')
|
|
74
|
+
expect(text).toContain('true or false')
|
|
75
|
+
// The copyable next step; the old output stopped at the value.
|
|
76
|
+
expect(text).toContain('notifai config set require_idle')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('lists what each choice of an enum means', () => {
|
|
80
|
+
const text = renderConfigExplain('sound', config).join('\n')
|
|
81
|
+
for (const choice of ['default', 'done', 'attention', 'alert', 'none']) {
|
|
82
|
+
expect(text, choice).toContain(choice)
|
|
83
|
+
}
|
|
84
|
+
expect(text).toContain('the completion chime')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for the configuration surfaces a person reads.
|
|
3
|
+
*
|
|
4
|
+
* The agent-facing shapes are untouched and live elsewhere: `--json` is the
|
|
5
|
+
* contract, and the flat `key = value` form stays exactly as it was for
|
|
6
|
+
* anything that is not a terminal. What is new is only what a human sees.
|
|
7
|
+
*/
|
|
8
|
+
import { CONFIG_KEYS, type CliConfig, type ConfigKey } from '../config.js'
|
|
9
|
+
import {
|
|
10
|
+
CONFIG_GROUPS,
|
|
11
|
+
acceptedValues,
|
|
12
|
+
configInfo,
|
|
13
|
+
configKeysByGroup,
|
|
14
|
+
describeSource,
|
|
15
|
+
formatValue,
|
|
16
|
+
} from '../config-schema.js'
|
|
17
|
+
import { glyph, pad, style, terminalWidth, width, wrap } from './theme.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The full settings listing, grouped and explained.
|
|
21
|
+
*
|
|
22
|
+
* Each key gets its name (what you type), its value (what is in force), where
|
|
23
|
+
* that value came from, and one line on what it does. The name leads rather
|
|
24
|
+
* than the friendly label because the next thing a reader does with it is type
|
|
25
|
+
* it into `config set`.
|
|
26
|
+
*/
|
|
27
|
+
export function renderConfigList(
|
|
28
|
+
config: CliConfig,
|
|
29
|
+
options: { showAdvanced?: boolean } = {},
|
|
30
|
+
): string[] {
|
|
31
|
+
const columns = terminalWidth()
|
|
32
|
+
const lines: string[] = []
|
|
33
|
+
const nameWidth = Math.max(...CONFIG_KEYS.map((key) => key.length))
|
|
34
|
+
const SUMMARY_INDENT = ' '
|
|
35
|
+
|
|
36
|
+
for (const group of CONFIG_GROUPS) {
|
|
37
|
+
const keys = configKeysByGroup(group.id).filter(
|
|
38
|
+
(info) => options.showAdvanced === true || info.advanced !== true,
|
|
39
|
+
)
|
|
40
|
+
if (keys.length === 0) continue
|
|
41
|
+
|
|
42
|
+
lines.push('')
|
|
43
|
+
lines.push(style.heading(group.title))
|
|
44
|
+
for (const line of wrap(group.blurb, columns - 2)) lines.push(style.dim(` ${line}`))
|
|
45
|
+
lines.push('')
|
|
46
|
+
|
|
47
|
+
for (const info of keys) {
|
|
48
|
+
const entry = config[info.key]
|
|
49
|
+
const source = describeSource(entry.source)
|
|
50
|
+
const value = formatValue(info.key, entry.value)
|
|
51
|
+
const isSet = source.label !== 'default'
|
|
52
|
+
// The provenance mark is only drawn when there is provenance to show;
|
|
53
|
+
// an empty dim segment still emits escape codes, so a `trimEnd` on the
|
|
54
|
+
// finished string cannot remove it.
|
|
55
|
+
const provenance = isSet ? ` ${style.dim(`${glyph.bullet} ${source.label}`)}` : ''
|
|
56
|
+
lines.push(
|
|
57
|
+
` ${style.code(pad(info.key, nameWidth))} ` +
|
|
58
|
+
`${isSet ? style.value(value) : style.unset(value)}${provenance}`,
|
|
59
|
+
)
|
|
60
|
+
for (const line of wrap(info.summary, columns - SUMMARY_INDENT.length)) {
|
|
61
|
+
lines.push(style.dim(`${SUMMARY_INDENT}${line}`))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push('')
|
|
67
|
+
lines.push(
|
|
68
|
+
style.dim(
|
|
69
|
+
`${glyph.arrow} ${style.code('notifai config explain <key>')} for what one of these means and how to change it.`,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
return lines
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Everything about one key, for someone who has asked about it specifically.
|
|
77
|
+
*
|
|
78
|
+
* This is the payoff of the listing above staying terse: the reader arrives
|
|
79
|
+
* here already knowing which key they care about, so the full paragraph, the
|
|
80
|
+
* legal values, and the layer the current value came from can all be spent on
|
|
81
|
+
* that one key.
|
|
82
|
+
*/
|
|
83
|
+
export function renderConfigExplain(key: ConfigKey, config: CliConfig): string[] {
|
|
84
|
+
const info = configInfo(key)
|
|
85
|
+
const entry = config[key]
|
|
86
|
+
const source = describeSource(entry.source)
|
|
87
|
+
const columns = terminalWidth()
|
|
88
|
+
const lines: string[] = []
|
|
89
|
+
|
|
90
|
+
lines.push('')
|
|
91
|
+
lines.push(`${style.code(key)} ${style.dim(glyph.bullet)} ${style.heading(info.label)}`)
|
|
92
|
+
lines.push('')
|
|
93
|
+
|
|
94
|
+
for (const paragraph of info.detail.split('\n\n')) {
|
|
95
|
+
for (const line of wrap(paragraph, columns - 2)) lines.push(` ${line}`)
|
|
96
|
+
lines.push('')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const valueLabel = formatValue(key, entry.value)
|
|
100
|
+
lines.push(
|
|
101
|
+
` ${style.dim(pad('Now', 10))}${style.value(valueLabel)} ${style.dim(`${glyph.bullet} ${source.label}`)}`,
|
|
102
|
+
)
|
|
103
|
+
if (source.path !== null) lines.push(` ${style.dim(pad('', 10))}${style.dim(source.path)}`)
|
|
104
|
+
lines.push(` ${style.dim(pad('Accepts', 10))}${acceptedValues(key)}`)
|
|
105
|
+
|
|
106
|
+
if (info.choices !== undefined && info.choiceHints !== undefined) {
|
|
107
|
+
lines.push('')
|
|
108
|
+
for (const choice of info.choices) {
|
|
109
|
+
const hint = info.choiceHints[choice]
|
|
110
|
+
lines.push(
|
|
111
|
+
` ${style.dim(pad('', 10))}${style.code(pad(choice, 14))}${style.dim(hint ?? '')}`.trimEnd(),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
lines.push('')
|
|
117
|
+
const example = info.example ?? (info.choices?.[0] ?? 'value')
|
|
118
|
+
lines.push(` ${style.dim('Change it')} ${style.code(`notifai config set ${key} ${example}`)}`)
|
|
119
|
+
lines.push('')
|
|
120
|
+
return lines
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The flat form, unchanged, for anything that is not a person at a terminal. */
|
|
124
|
+
export function renderConfigPlain(config: CliConfig, explain: boolean): string[] {
|
|
125
|
+
return CONFIG_KEYS.map((key) => {
|
|
126
|
+
const entry = config[key]
|
|
127
|
+
const provenance = explain ? ` [${entry.source}]` : ''
|
|
128
|
+
return `${key} = ${JSON.stringify(entry.value)}${provenance}`
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Longest visible line, for callers that need to size a rule or a box. */
|
|
133
|
+
export function widestLine(lines: readonly string[]): number {
|
|
134
|
+
return Math.max(0, ...lines.map((line) => width(line)))
|
|
135
|
+
}
|