@raidiant/notifai 10.1.1 → 10.1.3

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.
Files changed (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
package/src/ui/banner.ts DELETED
@@ -1,95 +0,0 @@
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
- }
@@ -1,90 +0,0 @@
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 { configInfo } from '../config-schema.js'
6
- import { renderConfigExplain, renderConfigList, renderConfigPlain } from './config-view.js'
7
- import { width } from './theme.js'
8
-
9
- const config = loadConfig({
10
- cwd: os.tmpdir(),
11
- env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-config-view-tests') },
12
- })
13
-
14
- describe('renderConfigPlain', () => {
15
- it('is the flat form scripts already parse', () => {
16
- // This is the agent-facing shape and the reason the pretty renderer is
17
- // gated on a human terminal. Restyling it would be a breaking change for
18
- // an audience that cannot see the styling.
19
- const lines = renderConfigPlain(config, false)
20
- expect(lines).toHaveLength(CONFIG_KEYS.length)
21
- expect(lines[0]).toBe(`wait_seconds = ${JSON.stringify(config.wait_seconds.value)}`)
22
- for (const line of lines) expect(line).toMatch(/^[a-z_]+ = /)
23
- })
24
-
25
- it('appends the raw source tag under --explain', () => {
26
- const lines = renderConfigPlain(config, true)
27
- expect(lines[0]).toBe(
28
- `wait_seconds = ${JSON.stringify(config.wait_seconds.value)} [${config.wait_seconds.source}]`,
29
- )
30
- })
31
-
32
- it('carries no colour, so a pipe gets no escape bytes', () => {
33
- for (const line of renderConfigPlain(config, true)) {
34
- expect(line).toBe(line.replace(/\[[0-9;]*m/g, ''))
35
- }
36
- })
37
- })
38
-
39
- describe('renderConfigList', () => {
40
- it('names every non-advanced key and explains it', () => {
41
- const text = renderConfigList(config).join('\n')
42
- for (const key of CONFIG_KEYS) {
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
46
- expect(text, key).toContain(key)
47
- }
48
- expect(text).toContain('Questions')
49
- expect(text).toContain('notifai config explain')
50
- })
51
-
52
- it('reveals advanced keys only when asked', () => {
53
- expect(renderConfigList(config).join('\n')).not.toContain('collapse_key')
54
- expect(renderConfigList(config, { showAdvanced: true }).join('\n')).toContain('collapse_key')
55
- expect(renderConfigList(config, { showAdvanced: true }).join('\n')).not.toContain('base_url')
56
- })
57
-
58
- it('fits the terminal, so no row wraps into the next', () => {
59
- const columns = process.stdout.columns
60
- Object.defineProperty(process.stdout, 'columns', { value: 80, configurable: true })
61
- try {
62
- for (const line of renderConfigList(config, { showAdvanced: true })) {
63
- expect(width(line), line).toBeLessThanOrEqual(80)
64
- }
65
- } finally {
66
- Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true })
67
- }
68
- })
69
- })
70
-
71
- describe('renderConfigExplain', () => {
72
- it('answers what it is, what it is now, and what it accepts', () => {
73
- const text = renderConfigExplain('ask_notifications', config).join('\n')
74
- expect(text).toContain('ask_notifications')
75
- expect(text).toContain('Send questions to my devices')
76
- expect(text).toContain('Accepts')
77
- expect(text).toContain('true or false')
78
- // The copyable next step; the old output stopped at the value.
79
- expect(text).toContain('notifai config set ask_notifications')
80
- })
81
-
82
- it('lists what each choice of an enum means', () => {
83
- const text = renderConfigExplain('sound', config).join('\n')
84
- for (const choice of ['default', 'done', 'attention', 'alert', 'none']) {
85
- expect(text, choice).toContain(choice)
86
- }
87
- expect(text).toContain('the completion chime')
88
- expect(text).toContain('Device default')
89
- })
90
- })
@@ -1,138 +0,0 @@
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
- const command = 'notifai config explain <key>'
68
- const help = `${glyph.arrow} ${command} for what one of these means and how to change it.`
69
- for (const line of wrap(help, columns)) {
70
- lines.push(style.dim(line.replace(command, style.code(command))))
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
- if (entry.source !== 'default' && entry.source !== 'flag') {
120
- lines.push(` ${style.dim('Inherit it')} ${style.code(`notifai config unset ${key}`)}`)
121
- }
122
- lines.push('')
123
- return lines
124
- }
125
-
126
- /** The flat form, unchanged, for anything that is not a person at a terminal. */
127
- export function renderConfigPlain(config: CliConfig, explain: boolean): string[] {
128
- return CONFIG_KEYS.map((key) => {
129
- const entry = config[key]
130
- const provenance = explain ? ` [${entry.source}]` : ''
131
- return `${key} = ${JSON.stringify(entry.value)}${provenance}`
132
- })
133
- }
134
-
135
- /** Longest visible line, for callers that need to size a rule or a box. */
136
- export function widestLine(lines: readonly string[]): number {
137
- return Math.max(0, ...lines.map((line) => width(line)))
138
- }
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { rootHelpFooter } from './help.js'
3
-
4
- function stripAnsi(text: string): string {
5
- return text.replace(/\u001B\[[0-9;]*m/g, '')
6
- }
7
-
8
- describe('rootHelpFooter', () => {
9
- it('leads first-run with init, not the interactive app', () => {
10
- const footer = stripAnsi(rootHelpFooter())
11
- const start = footer.slice(footer.indexOf('Start here'))
12
- const firstCommand = start.match(/notifai(?: init)?/)?.[0]
13
- expect(firstCommand).toBe('notifai init')
14
- expect(start.indexOf('notifai init')).toBeLessThan(start.search(/notifai\s{2,}/))
15
- })
16
- })
package/src/ui/help.ts DELETED
@@ -1,128 +0,0 @@
1
- /**
2
- * Help that answers "what can I do here", not "what strings does argv accept".
3
- *
4
- * The old help listed seventeen commands in one flat column, with the one a new
5
- * user needs (`init`) sitting between a platform capability dump and
6
- * an entry point marked "Internal:". Everything was equally prominent, which is
7
- * the same as nothing being prominent.
8
- *
9
- * Grouping and colour are Commander's own (`commandsGroup`, `optionsGroup`, and
10
- * the `styleX` hooks it calls when the output has colours); this module only
11
- * supplies the palette and the group vocabulary. Commander turns its styling
12
- * off by itself when the output is not a terminal, so piped help stays plain
13
- * without a second code path.
14
- */
15
- import { Help, type Command, type HelpConfiguration, type Option } from 'commander'
16
- import { glyph, style } from './theme.js'
17
-
18
- /**
19
- * Group headings, in the order a reader meets them.
20
- *
21
- * Ordering is by who needs it and when: first run, then daily use, then the
22
- * commands agents drive, then the things touched once or never. Commander
23
- * renders groups in first-use order, so these constants are also the layout.
24
- */
25
- export const GROUP = {
26
- start: 'Getting started:',
27
- daily: 'Everyday:',
28
- agent: 'For agents and scripts:',
29
- advanced: 'Account and wiring:',
30
- } as const
31
-
32
- /** Option groups for `send`, which carries thirty flags. */
33
- export const SEND_GROUP = {
34
- content: 'What it says:',
35
- routing: 'Where it goes:',
36
- presentation: 'How it arrives:',
37
- reply: 'Asking for an answer:',
38
- advanced: 'Advanced:',
39
- } as const
40
-
41
- /** Group order, independent of the order commands happen to be registered in. */
42
- const GROUP_ORDER: readonly string[] = [
43
- GROUP.start,
44
- GROUP.daily,
45
- GROUP.agent,
46
- GROUP.advanced,
47
- ]
48
-
49
- function groupRank(heading: string): number {
50
- const index = GROUP_ORDER.indexOf(heading)
51
- // Anything not named above keeps its position relative to its peers, because
52
- // the sort is stable — which is what lets the same override run over option
53
- // groups without needing to know their headings.
54
- return index === -1 ? GROUP_ORDER.length : index
55
- }
56
-
57
- export const helpConfiguration: HelpConfiguration = {
58
- // Groups already carry the intended order; alphabetising would scatter the
59
- // dependency between `login` and everything that needs a credential.
60
- sortSubcommands: false,
61
- sortOptions: false,
62
- showGlobalOptions: false,
63
-
64
- /**
65
- * Commander seeds group order from the raw registration order, which ties
66
- * how the help reads to where a `program.command(…)` block happens to sit in
67
- * the file — move one and the help silently reorders. Sorting the finished
68
- * grouping makes `GROUP_ORDER` the single place that decision is made.
69
- *
70
- * The same override runs over option groups, whose headings are not in
71
- * `GROUP_ORDER` at all. They all rank equal, and `sort` is stable, so they
72
- * keep the order they were declared in.
73
- */
74
- groupItems<T extends Command | Option>(
75
- this: Help,
76
- unsortedItems: T[],
77
- visibleItems: T[],
78
- getGroup: (item: T) => string,
79
- ): Map<string, T[]> {
80
- // `.call` cannot carry the method's own generic through, so the signature
81
- // is restated once here instead of widening every caller to
82
- // `Command | Option`.
83
- const base = Help.prototype.groupItems as unknown as (
84
- this: Help,
85
- unsorted: T[],
86
- visible: T[],
87
- group: (item: T) => string,
88
- ) => Map<string, T[]>
89
- const grouped = base.call(this, unsortedItems, visibleItems, getGroup)
90
- return new Map([...grouped.entries()].sort((a, b) => groupRank(a[0]) - groupRank(b[0])))
91
- },
92
-
93
- styleTitle: (title: string): string => style.heading(title),
94
- styleCommandText: (text: string): string => style.accent(text),
95
- styleSubcommandTerm: (text: string): string => style.code(text),
96
- styleOptionTerm: (text: string): string => style.code(text),
97
- styleArgumentTerm: (text: string): string => style.code(text),
98
- styleDescriptionText: (text: string): string => text,
99
- styleUsage: (text: string): string => text,
100
- }
101
-
102
- /**
103
- * Footer for the root help.
104
- *
105
- * First-run is `notifai init`. The interactive app is what a human opens
106
- * later, once setup is no longer the thing in the way.
107
- */
108
- export function rootHelpFooter(): string {
109
- const lines = [
110
- '',
111
- style.heading('Start here'),
112
- ` ${style.code('notifai init')} ${style.dim(`${glyph.arrow} set this project up, step by step`)}`,
113
- ` ${style.code('notifai')} ${style.dim(
114
- `${glyph.arrow} after setup: status, settings, devices, test sends`,
115
- )}`,
116
- '',
117
- style.heading('First call, for an agent'),
118
- ` ${style.code('notifai send --kind done --title "All 42 tests passed" --body "Finished in 3m 10s. Next: review the release candidate."')}`,
119
- ` ${style.code('notifai ask "Deploy migration 0007 to production?" --choice Deploy --choice Hold')}`,
120
- ` ${style.dim(`${glyph.arrow} ${style.code('ask')} needs question routing installed; ${style.code('notifai doctor --json')} says whether it is.`)}`,
121
- '',
122
- style.dim(
123
- `Every command takes ${style.code('--help')}. Where a command has machine-readable output it takes ${style.code('--json')}.`,
124
- ),
125
- style.dim(`Settings are explained by ${style.code('notifai config show')} and ${style.code('notifai config explain <key>')}.`),
126
- ]
127
- return lines.join('\n')
128
- }
@@ -1,92 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { card, clip, pad, width, wrap } from './theme.js'
3
-
4
- /**
5
- * These run with colour off — vitest's stdout is not a TTY, so `picocolors`
6
- * resolves to identity functions. That is the interesting case: it is the one
7
- * every agent, pipe and CI log gets, and the one where a layout bug is
8
- * invisible to a human developer reading a colourful terminal.
9
- */
10
-
11
- const RED = ''
12
- const RESET = ''
13
-
14
- describe('width', () => {
15
- it('counts printable characters, not escape bytes', () => {
16
- expect(width('hello')).toBe(5)
17
- expect(width(`${RED}hello${RESET}`)).toBe(5)
18
- })
19
- })
20
-
21
- describe('pad', () => {
22
- it('pads by visible width so colour cannot break a column', () => {
23
- expect(width(pad(`${RED}ab${RESET}`, 6))).toBe(6)
24
- expect(pad('ab', 4)).toBe('ab ')
25
- })
26
-
27
- it('never truncates when the text already exceeds the column', () => {
28
- expect(pad('abcdef', 3)).toBe('abcdef')
29
- })
30
- })
31
-
32
- describe('wrap', () => {
33
- it('breaks on whitespace within the limit', () => {
34
- expect(wrap('one two three four', 9)).toEqual(['one two', 'three', 'four'])
35
- })
36
-
37
- it('returns bare lines, leaving indentation to the caller', () => {
38
- // The regression this guards: wrap used to indent continuation lines
39
- // itself while callers indented every line, so each wrapped paragraph
40
- // stepped further right down the page.
41
- for (const line of wrap('alpha beta gamma delta', 11)) {
42
- expect(line.startsWith(' ')).toBe(false)
43
- }
44
- })
45
-
46
- it('keeps a word that is longer than the limit rather than losing it', () => {
47
- expect(wrap('supercalifragilistic', 5)).toEqual(['supercalifragilistic'])
48
- })
49
- })
50
-
51
- describe('clip', () => {
52
- it('leaves text that already fits', () => {
53
- expect(clip('short', 10)).toBe('short')
54
- })
55
-
56
- it('truncates by visible width and never mid-escape', () => {
57
- const clipped = clip(`${RED}abcdefghij${RESET}`, 5)
58
- expect(width(clipped)).toBeLessThanOrEqual(5)
59
- // A slice by String.length would cut through `` and spill the
60
- // raw bytes into the terminal.
61
- expect(clipped).toContain(RED)
62
- expect(clipped.endsWith(RESET)).toBe(true)
63
- })
64
- })
65
-
66
- describe('card', () => {
67
- it('draws a closed box whose rows all share one width', () => {
68
- const lines = card('Status', ['a', 'bbbbbbbbbb'], 40)
69
- const widths = new Set(lines.map((line) => width(line)))
70
- expect(widths.size).toBe(1)
71
- expect(lines[0]).toContain('Status')
72
- })
73
-
74
- it('measures rows by visible width, so colour does not skew the border', () => {
75
- const plain = card('T', ['abcdef'], 40)
76
- const coloured = card('T', [`${RED}abcdef${RESET}`], 40)
77
- expect(plain.map(width)).toEqual(coloured.map(width))
78
- })
79
-
80
- it('survives a terminal that reports no usable width', () => {
81
- // A pty reporting zero columns made the obvious library call compute a
82
- // negative pad and throw, taking the whole interactive app with it. A
83
- // status card may degrade; it must never be the thing that crashes.
84
- expect(() => card('Status', ['something reasonably long here'], 0)).not.toThrow()
85
- expect(card('Status', ['x'], 0).length).toBe(3)
86
- })
87
-
88
- it('clips a row too wide for the terminal instead of wrapping it', () => {
89
- const lines = card('T', ['x'.repeat(200)], 40)
90
- for (const line of lines) expect(width(line)).toBeLessThanOrEqual(40)
91
- })
92
- })