@raidiant/notifai 0.3.0 → 0.4.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/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +891 -43
- package/src/commands.ts +1133 -332
- package/src/config-schema.ts +51 -9
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +3 -3
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/config-view.test.ts +4 -2
- package/src/ui/config-view.ts +3 -0
package/src/main.ts
CHANGED
|
@@ -12,14 +12,17 @@ import {
|
|
|
12
12
|
configExplainCommand,
|
|
13
13
|
configSetCommand,
|
|
14
14
|
configShowCommand,
|
|
15
|
+
configUnsetCommand,
|
|
15
16
|
devicesCommand,
|
|
16
17
|
doctorCommand,
|
|
18
|
+
hookDefersDiagnosticsUntilAfterCleanup,
|
|
17
19
|
hookRunCommand,
|
|
18
20
|
hooksInstallCommand,
|
|
19
21
|
hooksUninstallCommand,
|
|
20
22
|
initCommand,
|
|
21
23
|
loginCommand,
|
|
22
24
|
logoutCommand,
|
|
25
|
+
logsCommand,
|
|
23
26
|
realIo,
|
|
24
27
|
repliesCommand,
|
|
25
28
|
sendCommand,
|
|
@@ -32,42 +35,26 @@ import type { Platform } from '@raidiant/notifai-protocol'
|
|
|
32
35
|
import { nativeSkills, type SkillScope } from './native-skills.js'
|
|
33
36
|
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
34
37
|
|
|
38
|
+
import { readStdinWithTimeout } from './hook-input.js'
|
|
39
|
+
import { bootstrapLogger } from './logging.js'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The local record for this invocation.
|
|
43
|
+
*
|
|
44
|
+
* Built before the command tree so that the very first thing recorded is the
|
|
45
|
+
* command starting — including for a command that goes on to fail before it has
|
|
46
|
+
* resolved anything. It configures itself from disk and disables itself if it
|
|
47
|
+
* cannot write, so nothing below has to handle it failing.
|
|
48
|
+
*/
|
|
49
|
+
const logger = bootstrapLogger()
|
|
50
|
+
|
|
35
51
|
const deps: CommandDeps = {
|
|
36
52
|
io: realIo(),
|
|
37
53
|
store: defaultCredentialStore(),
|
|
38
54
|
env: process.env,
|
|
39
55
|
cwd: process.cwd(),
|
|
40
56
|
nativeSkills,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Harness hooks deliver their event payload on stdin. Bounded on both time and
|
|
45
|
-
* size: a wrapper that opens the pipe without writing would otherwise hold the
|
|
46
|
-
* read until the harness killed the hook, delaying the user's own prompt.
|
|
47
|
-
*/
|
|
48
|
-
async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string> {
|
|
49
|
-
if (process.stdin.isTTY) return ''
|
|
50
|
-
return new Promise((resolve) => {
|
|
51
|
-
const chunks: Buffer[] = []
|
|
52
|
-
let total = 0
|
|
53
|
-
let settled = false
|
|
54
|
-
const finish = (): void => {
|
|
55
|
-
if (settled) return
|
|
56
|
-
settled = true
|
|
57
|
-
clearTimeout(timer)
|
|
58
|
-
process.stdin.destroy()
|
|
59
|
-
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
60
|
-
}
|
|
61
|
-
const timer = setTimeout(finish, timeoutMs)
|
|
62
|
-
timer.unref?.()
|
|
63
|
-
process.stdin.on('data', (chunk: Buffer) => {
|
|
64
|
-
total += chunk.length
|
|
65
|
-
if (total > maxBytes) return finish()
|
|
66
|
-
chunks.push(chunk)
|
|
67
|
-
})
|
|
68
|
-
process.stdin.on('end', finish)
|
|
69
|
-
process.stdin.on('error', finish)
|
|
70
|
-
})
|
|
57
|
+
logger,
|
|
71
58
|
}
|
|
72
59
|
|
|
73
60
|
/**
|
|
@@ -91,6 +78,39 @@ function version(): string {
|
|
|
91
78
|
return 'unknown'
|
|
92
79
|
}
|
|
93
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The full command path, e.g. `config set`, so a filter on `cmd` distinguishes
|
|
83
|
+
* subcommands that share a leaf name.
|
|
84
|
+
*/
|
|
85
|
+
function commandPath(command: Command): string {
|
|
86
|
+
const parts: string[] = []
|
|
87
|
+
for (let node: Command | null = command; node?.parent != null; node = node.parent) {
|
|
88
|
+
parts.unshift(node.name())
|
|
89
|
+
}
|
|
90
|
+
return parts.join(' ') || 'notifai'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which flags were passed, without their values.
|
|
95
|
+
*
|
|
96
|
+
* The flag names answer nearly every question worth asking of an invocation —
|
|
97
|
+
* was `--reply` set, was `--all` — while the values are notification content
|
|
98
|
+
* and user text that has no business being recorded merely because a command
|
|
99
|
+
* ran. Values are available under `log_level = debug`, which is a deliberate act.
|
|
100
|
+
*/
|
|
101
|
+
function flagNames(argv: readonly string[]): string[] {
|
|
102
|
+
return argv.filter((token) => token.startsWith('--'))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const startedAt = Date.now()
|
|
106
|
+
process.on('exit', (code) => {
|
|
107
|
+
logger.info('cli.end', {
|
|
108
|
+
exit: code,
|
|
109
|
+
duration_ms: Date.now() - startedAt,
|
|
110
|
+
flags: flagNames(process.argv.slice(2)),
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
94
114
|
const program = new Command('notifai')
|
|
95
115
|
.description('Send native device notifications from agents and local programs')
|
|
96
116
|
.version(version())
|
|
@@ -111,6 +131,22 @@ const program = new Command('notifai')
|
|
|
111
131
|
.helpCommand(true)
|
|
112
132
|
// Where the implicit help command lands; every other command names its own.
|
|
113
133
|
.commandsGroup(GROUP.help)
|
|
134
|
+
.hook('preAction', (_program, actionCommand) => {
|
|
135
|
+
logger.bind({ cmd: commandPath(actionCommand) })
|
|
136
|
+
// SessionEnd uses the hook policy shared with commands.ts: local cleanup
|
|
137
|
+
// precedes every diagnostic that can wait on the shared log lock.
|
|
138
|
+
const deferDiagnostics =
|
|
139
|
+
actionCommand.name() === 'hook' &&
|
|
140
|
+
hookDefersDiagnosticsUntilAfterCleanup(actionCommand.processedArgs[0])
|
|
141
|
+
// Values only at `debug`; `cli.end` carries the flag names at every level.
|
|
142
|
+
if (!deferDiagnostics) {
|
|
143
|
+
logger.debug('cli.start', {
|
|
144
|
+
version: version(),
|
|
145
|
+
argv: process.argv.slice(2),
|
|
146
|
+
cwd: process.cwd(),
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
114
150
|
|
|
115
151
|
/**
|
|
116
152
|
* Bare `notifai`.
|
|
@@ -411,7 +447,7 @@ program
|
|
|
411
447
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
412
448
|
const harness = HARNESSES.find((candidate) => candidate === opts.harness)
|
|
413
449
|
process.exit(
|
|
414
|
-
await hookRunCommand(deps, event,
|
|
450
|
+
await hookRunCommand(deps, event, () => readStdinWithTimeout(), harness),
|
|
415
451
|
)
|
|
416
452
|
})
|
|
417
453
|
|
|
@@ -425,7 +461,7 @@ hooks
|
|
|
425
461
|
.description('Wire this harness to route registered questions to your devices')
|
|
426
462
|
.option(
|
|
427
463
|
'--harness <name>',
|
|
428
|
-
'claude-code | codex | cursor | opencode (default: detected
|
|
464
|
+
'claude-code | codex | cursor | opencode (default: detected)',
|
|
429
465
|
)
|
|
430
466
|
.option('--global', 'install for every project instead of just this one')
|
|
431
467
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -440,6 +476,34 @@ hooks
|
|
|
440
476
|
process.exit(hooksUninstallCommand(deps, opts))
|
|
441
477
|
})
|
|
442
478
|
|
|
479
|
+
program
|
|
480
|
+
.command('logs')
|
|
481
|
+
.helpGroup(GROUP.daily)
|
|
482
|
+
.summary('Show what this machine recorded about what it did')
|
|
483
|
+
.description(
|
|
484
|
+
'Read the local record of commands, notifications, and the question decisions harness hooks made. ' +
|
|
485
|
+
'Bounded and scoped to this project by default; never leaves this machine',
|
|
486
|
+
)
|
|
487
|
+
.option('-n, --limit <count>', 'how many records to show (default: 30)', (v: string) => Number(v))
|
|
488
|
+
.option('--all', 'lift the default limit, up to a hard cap')
|
|
489
|
+
.option('--since <when>', 'only records newer than this: 10m, 2h, 1d, or an ISO 8601 instant')
|
|
490
|
+
.option('--level <level>', 'minimum severity: error | info | debug (error shows only failures)')
|
|
491
|
+
.option('--event <name>', 'only this event; repeatable', (v: string, all: string[]) => [...all, v], [])
|
|
492
|
+
.option('--run <id>', 'everything one command invocation did')
|
|
493
|
+
.option('--request <id>', 'everything about one notification request')
|
|
494
|
+
.option('--session <id>', 'only this harness session')
|
|
495
|
+
.option('--project <id>', 'only this project')
|
|
496
|
+
.option('--all-projects', 'do not scope to the project in this directory')
|
|
497
|
+
.option('--grep <text>', 'only records containing this text')
|
|
498
|
+
.option('--json', 'one JSON record per line on stdout')
|
|
499
|
+
.option('--path', 'print the log file paths instead of the records')
|
|
500
|
+
.option('--clear', 'delete the log files')
|
|
501
|
+
.action((opts: Record<string, unknown>) => {
|
|
502
|
+
const flags = { ...opts } as Parameters<typeof logsCommand>[1]
|
|
503
|
+
if (Array.isArray(flags.event) && flags.event.length === 0) delete flags.event
|
|
504
|
+
process.exit(logsCommand(deps, flags))
|
|
505
|
+
})
|
|
506
|
+
|
|
443
507
|
const config = program
|
|
444
508
|
.command('config')
|
|
445
509
|
.description('Show or change settings')
|
|
@@ -479,5 +543,22 @@ config
|
|
|
479
543
|
process.exit(await configSetCommand(deps, key, value, opts))
|
|
480
544
|
},
|
|
481
545
|
)
|
|
546
|
+
config
|
|
547
|
+
.command('unset <key>')
|
|
548
|
+
.description(
|
|
549
|
+
'Remove a configuration value so the next layer or shipped default applies (choose a layer interactively; unattended defaults machine-global and requires --yes)',
|
|
550
|
+
)
|
|
551
|
+
.option('--project', 'remove from the shared .notifai/config.toml instead')
|
|
552
|
+
.option('--local', 'remove from personal .notifai/config.local.toml')
|
|
553
|
+
.option('--session <id>', 'remove from one session')
|
|
554
|
+
.option('--yes', 'skip the confirmation gate')
|
|
555
|
+
.action(
|
|
556
|
+
async (
|
|
557
|
+
key: string,
|
|
558
|
+
opts: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
559
|
+
) => {
|
|
560
|
+
process.exit(await configUnsetCommand(deps, key, opts))
|
|
561
|
+
},
|
|
562
|
+
)
|
|
482
563
|
|
|
483
564
|
await program.parseAsync(process.argv)
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
|
|
|
30
30
|
export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
|
|
31
31
|
|
|
32
32
|
/** Bump when an installed generated file must be rewritten to remain functional. */
|
|
33
|
-
const OPENCODE_ADAPTER_VERSION =
|
|
33
|
+
const OPENCODE_ADAPTER_VERSION = 5
|
|
34
34
|
|
|
35
35
|
export function opencodePluginDir(
|
|
36
36
|
global: boolean,
|
|
@@ -51,8 +51,8 @@ export function opencodePluginPath(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface OpencodePluginOptions {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** The same stable user-level adapter every command-hook harness invokes. */
|
|
55
|
+
adapterPath: string
|
|
56
56
|
/** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
|
|
57
57
|
timeoutSeconds: number
|
|
58
58
|
}
|
|
@@ -60,23 +60,23 @@ export interface OpencodePluginOptions {
|
|
|
60
60
|
/**
|
|
61
61
|
* The generated module.
|
|
62
62
|
*
|
|
63
|
-
* Written as a string rather than compiled from a source file because
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* Written as a string rather than compiled from a source file because OpenCode
|
|
64
|
+
* loads a plugin module rather than a command definition. It embeds only the
|
|
65
|
+
* stable user-level adapter path; mutable runtime and CLI targets live behind
|
|
66
|
+
* that adapter exactly as they do for every other harness.
|
|
66
67
|
*
|
|
67
68
|
* Everything it does is build the hook envelope OpenCode's event gives it and
|
|
68
69
|
* hand that to the CLI on stdin. It deliberately fails open: an adapter error
|
|
69
70
|
* never changes OpenCode's own behavior.
|
|
70
71
|
*/
|
|
71
72
|
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
72
|
-
const {
|
|
73
|
+
const { adapterPath, timeoutSeconds } = options
|
|
73
74
|
return `${OPENCODE_PLUGIN_MARKER}
|
|
74
75
|
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
75
76
|
// the next install; change the CLI instead, which is where the logic lives.
|
|
76
77
|
import { spawn } from "node:child_process"
|
|
77
78
|
|
|
78
|
-
const
|
|
79
|
-
const SCRIPT = ${JSON.stringify(scriptPath)}
|
|
79
|
+
const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
80
80
|
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
81
81
|
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
82
82
|
|
|
@@ -88,7 +88,7 @@ function runHook(event, envelope) {
|
|
|
88
88
|
return new Promise((resolve) => {
|
|
89
89
|
let child
|
|
90
90
|
try {
|
|
91
|
-
child = spawn(
|
|
91
|
+
child = spawn(ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"], {
|
|
92
92
|
env: process.env,
|
|
93
93
|
shell: false,
|
|
94
94
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -216,17 +216,16 @@ export function isOurOpencodePlugin(contents: string): boolean {
|
|
|
216
216
|
*/
|
|
217
217
|
export function opencodePluginTarget(
|
|
218
218
|
contents: string,
|
|
219
|
-
): {
|
|
219
|
+
): { adapter: string; current: boolean; timeoutSeconds?: number } | null {
|
|
220
220
|
if (!isOurOpencodePlugin(contents)) return null
|
|
221
|
-
const
|
|
222
|
-
const
|
|
221
|
+
const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
|
|
222
|
+
const legacyScript = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
|
|
223
223
|
const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
|
|
224
224
|
const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
|
|
225
|
-
if (
|
|
225
|
+
if (adapter === undefined && legacyScript === undefined) return null
|
|
226
226
|
try {
|
|
227
227
|
return {
|
|
228
|
-
|
|
229
|
-
script: JSON.parse(script) as string,
|
|
228
|
+
adapter: JSON.parse(adapter ?? legacyScript!) as string,
|
|
230
229
|
current: version === OPENCODE_ADAPTER_VERSION,
|
|
231
230
|
...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
|
|
232
231
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { withFileLock } from './file-lock.js'
|
|
4
|
+
|
|
5
|
+
const workerMode = process.env['NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE']
|
|
6
|
+
const sleep = new Int32Array(new SharedArrayBuffer(4))
|
|
7
|
+
|
|
8
|
+
function waitFor(file: string): void {
|
|
9
|
+
const deadline = Date.now() + 15_000
|
|
10
|
+
while (!existsSync(file)) {
|
|
11
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
|
|
12
|
+
Atomics.wait(sleep, 0, 0, 2)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (workerMode === undefined) {
|
|
17
|
+
describe('SessionEnd log lock worker', () => {
|
|
18
|
+
it('stays dormant outside the CLI deadline regression', () => {
|
|
19
|
+
expect(workerMode).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
} else {
|
|
23
|
+
describe('SessionEnd log lock worker', () => {
|
|
24
|
+
it('holds a live bakery ticket until the parent releases it', () => {
|
|
25
|
+
withFileLock(
|
|
26
|
+
process.env['NOTIFAI_SESSION_END_LOG_LOCK_PATH']!,
|
|
27
|
+
() => {
|
|
28
|
+
writeFileSync(process.env['NOTIFAI_SESSION_END_LOG_LOCK_READY']!, workerMode)
|
|
29
|
+
waitFor(process.env['NOTIFAI_SESSION_END_LOG_LOCK_RELEASE']!)
|
|
30
|
+
},
|
|
31
|
+
{ waitMs: 15_000 },
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
package/src/skill.test.ts
CHANGED
|
@@ -34,6 +34,17 @@ describe('Notifai agent skill', () => {
|
|
|
34
34
|
}
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
it('tells the agent where to look when something did not happen', () => {
|
|
38
|
+
// The log's whole value is diagnostic, so the skill has to name the case
|
|
39
|
+
// that produces it — a question that was registered and never travelled.
|
|
40
|
+
expect(skill).toContain('notifai logs')
|
|
41
|
+
expect(skill).toContain('--request <id>')
|
|
42
|
+
expect(skill).toContain('hook.gate')
|
|
43
|
+
expect(skill).toContain('notifications-off')
|
|
44
|
+
// And that it is the user's data, on the user's machine.
|
|
45
|
+
expect(skill).toContain('stays on this machine')
|
|
46
|
+
})
|
|
47
|
+
|
|
37
48
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
38
49
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
39
50
|
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { CONFIG_KEYS, loadConfig } from '../config.js'
|
|
5
|
+
import { configInfo } from '../config-schema.js'
|
|
5
6
|
import { renderConfigExplain, renderConfigList, renderConfigPlain } from './config-view.js'
|
|
6
7
|
import { width } from './theme.js'
|
|
7
8
|
|
|
@@ -39,8 +40,9 @@ describe('renderConfigList', () => {
|
|
|
39
40
|
it('names every non-advanced key and explains it', () => {
|
|
40
41
|
const text = renderConfigList(config).join('\n')
|
|
41
42
|
for (const key of CONFIG_KEYS) {
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// Derived, not listed: a hardcoded set of advanced keys silently becomes
|
|
44
|
+
// an assertion about yesterday's key list the moment a key is added.
|
|
45
|
+
if (configInfo(key).advanced === true) continue
|
|
44
46
|
expect(text, key).toContain(key)
|
|
45
47
|
}
|
|
46
48
|
expect(text).toContain('Questions & presence')
|
package/src/ui/config-view.ts
CHANGED
|
@@ -116,6 +116,9 @@ export function renderConfigExplain(key: ConfigKey, config: CliConfig): string[]
|
|
|
116
116
|
lines.push('')
|
|
117
117
|
const example = info.example ?? (info.choices?.[0] ?? 'value')
|
|
118
118
|
lines.push(` ${style.dim('Change it')} ${style.code(`notifai config set ${key} ${example}`)}`)
|
|
119
|
+
if (entry.source !== 'default' && entry.source !== 'flag') {
|
|
120
|
+
lines.push(` ${style.dim('Inherit it')} ${style.code(`notifai config unset ${key}`)}`)
|
|
121
|
+
}
|
|
119
122
|
lines.push('')
|
|
120
123
|
return lines
|
|
121
124
|
}
|