@raidiant/notifai 7.0.2 → 9.0.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { describe, expect, it } from 'vitest'
5
- import { nativeSkills } from './native-skills.js'
5
+ import { SKILLS_INSTALLER_SPEC, nativeSkills, skillsAddArgv, skillsRemoveArgv } from './native-skills.js'
6
6
 
7
7
  function writeLock(file: string, ref: string): void {
8
8
  mkdirSync(path.dirname(file), { recursive: true })
@@ -22,6 +22,46 @@ function writeLock(file: string, ref: string): void {
22
22
  )
23
23
  }
24
24
 
25
+ describe('skillsAddArgv', () => {
26
+ it('pins the installer package to an exact reviewed version', () => {
27
+ const argv = skillsAddArgv({
28
+ source: 'Raidiant-io/notifai#v8.0.0',
29
+ skill: 'notifai',
30
+ cwd: '/tmp',
31
+ env: {},
32
+ })
33
+ expect(SKILLS_INSTALLER_SPEC).toMatch(/^skills@\d+\.\d+\.\d+$/)
34
+ expect(argv).toEqual([
35
+ '-y',
36
+ SKILLS_INSTALLER_SPEC,
37
+ 'add',
38
+ 'Raidiant-io/notifai#v8.0.0',
39
+ '--skill',
40
+ 'notifai',
41
+ ])
42
+ expect(argv).not.toContain('skills')
43
+ })
44
+
45
+ it('uninstalls one skill in the named scope without a prompt', () => {
46
+ expect(
47
+ skillsRemoveArgv({
48
+ skill: 'notifai',
49
+ scope: 'project',
50
+ cwd: '/tmp',
51
+ env: {},
52
+ }),
53
+ ).toEqual(['-y', SKILLS_INSTALLER_SPEC, 'remove', 'notifai', '--yes'])
54
+ expect(
55
+ skillsRemoveArgv({
56
+ skill: 'notifai',
57
+ scope: 'global',
58
+ cwd: '/tmp',
59
+ env: {},
60
+ }),
61
+ ).toEqual(['-y', SKILLS_INSTALLER_SPEC, 'remove', 'notifai', '--global', '--yes'])
62
+ })
63
+ })
64
+
25
65
  describe('nativeSkills.list', () => {
26
66
  it('reads the project lock file instead of spawning npx', async () => {
27
67
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-project-'))
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { accountHome, npxLaunch } from './platform.js'
5
5
 
6
+ /**
7
+ * Exact reviewed version of the external `skills` installer.
8
+ *
9
+ * The GitHub skill source stays the immutable CLI version tag. The installer
10
+ * program that fetches it must not float on `latest`.
11
+ */
12
+ export const SKILLS_INSTALLER_SPEC = 'skills@1.5.23'
13
+
6
14
  /** The two scopes offered by the skills installer. */
7
15
  export type SkillScope = 'project' | 'global'
8
16
 
@@ -30,9 +38,18 @@ export interface SkillsAddOptions {
30
38
  env: NodeJS.ProcessEnv
31
39
  }
32
40
 
41
+ export interface SkillsRemoveOptions {
42
+ skill: string
43
+ scope: SkillScope
44
+ cwd: string
45
+ env: NodeJS.ProcessEnv
46
+ }
47
+
33
48
  export interface NativeSkills {
34
49
  /** Launch the native interactive `npx skills add` flow. */
35
50
  add(options: SkillsAddOptions): Promise<number>
51
+ /** Uninstall one installer-managed skill in one scope. */
52
+ remove(options: SkillsRemoveOptions): Promise<number>
36
53
  /** Read installer-managed inventory from lock files. Does not spawn npx. */
37
54
  list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
38
55
  }
@@ -108,15 +125,32 @@ function run(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }):
108
125
  })
109
126
  }
110
127
 
128
+ /** argv for `npx`, including the pinned installer spec. */
129
+ export function skillsAddArgv(options: SkillsAddOptions): string[] {
130
+ const args = ['-y', SKILLS_INSTALLER_SPEC, 'add', options.source, '--skill', options.skill]
131
+ if (options.scope === 'global') args.push('--global')
132
+ // An explicit scope is the unattended contract. Native `--yes` keeps all
133
+ // remaining installer prompts non-interactive after the scope is chosen.
134
+ if (options.scope !== undefined) args.push('--yes')
135
+ return args
136
+ }
137
+
138
+ /** argv for uninstalling one skill in one installer scope. */
139
+ export function skillsRemoveArgv(options: SkillsRemoveOptions): string[] {
140
+ const args = ['-y', SKILLS_INSTALLER_SPEC, 'remove', options.skill]
141
+ if (options.scope === 'global') args.push('--global')
142
+ args.push('--yes')
143
+ return args
144
+ }
145
+
111
146
  /** The only process/filesystem adapter Notifai needs for the external installer. */
112
147
  export const nativeSkills: NativeSkills = {
113
148
  async add(options) {
114
- const args = ['-y', 'skills', 'add', options.source, '--skill', options.skill]
115
- if (options.scope === 'global') args.push('--global')
116
- // An explicit scope is the unattended contract. Native `--yes` keeps all
117
- // remaining installer prompts non-interactive after the scope is chosen.
118
- if (options.scope !== undefined) args.push('--yes')
119
- return run(args, { cwd: options.cwd, env: options.env })
149
+ return run(skillsAddArgv(options), { cwd: options.cwd, env: options.env })
150
+ },
151
+
152
+ async remove(options) {
153
+ return run(skillsRemoveArgv(options), { cwd: options.cwd, env: options.env })
120
154
  },
121
155
 
122
156
  async list(scope, cwd, env) {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
3
3
  import { configHome } from './install-hooks.js'
4
+ import { SESSION_ACTIVATION_CONTEXT } from './session-activation.js'
4
5
 
5
6
  /**
6
7
  * The OpenCode adapter.
@@ -31,7 +32,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
31
32
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
32
33
 
33
34
  /** Bump when an installed generated file must be rewritten to remain functional. */
34
- const OPENCODE_ADAPTER_VERSION = 8
35
+ const OPENCODE_ADAPTER_VERSION = 10
35
36
 
36
37
  export function opencodePluginDir(
37
38
  global: boolean,
@@ -103,6 +104,7 @@ import { spawn } from "node:child_process"
103
104
  ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
104
105
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
105
106
  const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
107
+ const SESSION_ACTIVATION_CONTEXT = ${JSON.stringify(SESSION_ACTIVATION_CONTEXT)}
106
108
  const PENDING_SESSION_TITLE =
107
109
  /^New session(?: *- *[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z)?$/i
108
110
 
@@ -158,6 +160,17 @@ export const NotifAIPlugin = async ({ directory, client }) => {
158
160
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
159
161
 
160
162
  return {
163
+ /** Model-visible activation, independent of CLI setup and delivery state. */
164
+ "experimental.chat.system.transform": async (input, output) => {
165
+ const sessionID = input?.sessionID
166
+ if (typeof sessionID !== "string" || sessionID.length === 0) return
167
+ if (output.system.length === 0) {
168
+ output.system.push(SESSION_ACTIVATION_CONTEXT)
169
+ } else {
170
+ output.system[0] = output.system[0] + "\\n\\n" + SESSION_ACTIVATION_CONTEXT
171
+ }
172
+ },
173
+
161
174
  /** Exact active-harness identity and first-party title for agent shell commands. */
162
175
  "shell.env": async (input, output) => {
163
176
  output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
@@ -0,0 +1,237 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { buildProgram, type ProgramRunners } from './program.js'
6
+ import type { CommandDeps } from './commands.js'
7
+
8
+ /**
9
+ * The layer these tests own is the argv boundary: what a real command line
10
+ * turns into by the time a command implementation receives it. Collector
11
+ * defaults, `--no-wait` disentangling, `--body-file` reading — none of that is
12
+ * visible to the unit tests on the commands themselves, and it used to be
13
+ * covered by nothing but reading `program.ts` with human eyes.
14
+ */
15
+
16
+ class ExitSentinel extends Error {
17
+ constructor(readonly code: number) {
18
+ super(`exit ${code}`)
19
+ }
20
+ }
21
+
22
+ function makeDeps(): CommandDeps & { outLines: string[]; errLines: string[] } {
23
+ const outLines: string[] = []
24
+ const errLines: string[] = []
25
+ return {
26
+ io: {
27
+ out: (line: string) => outLines.push(line),
28
+ err: (line: string) => errLines.push(line),
29
+ confirm: async () => false,
30
+ openUrl: () => {},
31
+ },
32
+ store: {
33
+ load: () => null,
34
+ save: () => {},
35
+ clear: () => {},
36
+ describe: () => 'test credential store',
37
+ },
38
+ env: {},
39
+ cwd: os.tmpdir(),
40
+ outLines,
41
+ errLines,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Parse a real command line and capture what would have been dispatched.
47
+ * `exit` throws so post-exit code cannot run — the same property
48
+ * `process.exit` gives production.
49
+ */
50
+ async function parse(
51
+ argv: string[],
52
+ runners: Partial<ProgramRunners>,
53
+ deps = makeDeps(),
54
+ ): Promise<{ exitCode: number | undefined; deps: ReturnType<typeof makeDeps> }> {
55
+ let exitCode: number | undefined
56
+ const program = buildProgram(deps, {
57
+ runners,
58
+ exit: (code: number) => {
59
+ exitCode = code
60
+ throw new ExitSentinel(code)
61
+ },
62
+ })
63
+ try {
64
+ await program.parseAsync(['node', 'notifai', ...argv])
65
+ } catch (err) {
66
+ if (!(err instanceof ExitSentinel)) throw err
67
+ }
68
+ return { exitCode, deps }
69
+ }
70
+
71
+ describe('program argv parsing', () => {
72
+ it('maps a full send command line onto sendCommand flags', async () => {
73
+ let seen: Record<string, unknown> | undefined
74
+ const { exitCode } = await parse(
75
+ [
76
+ 'send',
77
+ '--kind', 'done',
78
+ '--title', 'Build finished',
79
+ '--body', 'All green.',
80
+ '--choice', 'Ship it',
81
+ '--choice', 'Hold',
82
+ '--multi',
83
+ '--reply',
84
+ '--reply-timeout', '30',
85
+ ],
86
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
87
+ )
88
+ expect(exitCode).toBe(0)
89
+ expect(seen).toMatchObject({
90
+ kind: 'done',
91
+ title: 'Build finished',
92
+ body: 'All green.',
93
+ choice: ['Ship it', 'Hold'],
94
+ multi: true,
95
+ reply: true,
96
+ replyTimeout: 30,
97
+ noWait: false,
98
+ })
99
+ })
100
+
101
+ it('drops empty collector defaults so "not passed" stays absent', async () => {
102
+ let seen: Record<string, unknown> | undefined
103
+ await parse(
104
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B'],
105
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
106
+ )
107
+ expect(seen).toBeDefined()
108
+ expect(seen).not.toHaveProperty('choice')
109
+ expect(seen).not.toHaveProperty('image')
110
+ expect(seen).not.toHaveProperty('imageAlt')
111
+ expect(seen).not.toHaveProperty('wait')
112
+ })
113
+
114
+ it('maps init --setup-scope onto the command flags', async () => {
115
+ let seen: Record<string, unknown> | undefined
116
+ const { exitCode } = await parse(
117
+ ['init', '--skills', '--setup-scope', 'global', '--no-hooks'],
118
+ { init: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['init'] },
119
+ )
120
+ expect(exitCode).toBe(0)
121
+ expect(seen).toMatchObject({ skills: true, setupScope: 'global', hooks: false })
122
+ })
123
+
124
+ it('disentangles --no-wait and --wait from their shared commander flag', async () => {
125
+ const capture = (sink: Record<string, unknown>[]): ProgramRunners['send'] =>
126
+ (async (_deps, flags) => (sink.push(flags as Record<string, unknown>), 0)) as ProgramRunners['send']
127
+ const seen: Record<string, unknown>[] = []
128
+ await parse(['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--no-wait'], { send: capture(seen) })
129
+ await parse(['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--wait', '5'], { send: capture(seen) })
130
+ expect(seen[0]).toMatchObject({ noWait: true })
131
+ expect(seen[0]).not.toHaveProperty('wait')
132
+ expect(seen[1]).toMatchObject({ noWait: false, wait: 5 })
133
+ })
134
+
135
+ it('reads --body-file and refuses it alongside --body', async () => {
136
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-program-test-'))
137
+ const file = path.join(dir, 'body.md')
138
+ writeFileSync(file, 'From a file.\n')
139
+ let seen: Record<string, unknown> | undefined
140
+ const send = (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send']
141
+
142
+ const ok = await parse(['send', '--kind', 'update', '--title', 'T', '--body-file', file], { send })
143
+ expect(ok.exitCode).toBe(0)
144
+ expect(seen).toMatchObject({ body: 'From a file.\n' })
145
+ expect(seen).not.toHaveProperty('bodyFile')
146
+
147
+ const both = await parse(
148
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--body-file', file],
149
+ { send },
150
+ )
151
+ expect(both.exitCode).toBe(2)
152
+ expect(both.deps.errLines.join('\n')).toContain('either --body or --body-file')
153
+
154
+ const missing = await parse(
155
+ ['send', '--kind', 'update', '--title', 'T', '--body-file', path.join(dir, 'absent.md')],
156
+ { send },
157
+ )
158
+ expect(missing.exitCode).toBe(2)
159
+ expect(missing.deps.errLines.join('\n')).toContain('Could not read')
160
+ })
161
+
162
+ it('builds ask flags without empty collectors and passes the question through', async () => {
163
+ let seenQuestion: string | undefined
164
+ let seenFlags: Record<string, unknown> | undefined
165
+ const ask = (async (_deps, question, flags) => {
166
+ seenQuestion = question
167
+ seenFlags = flags as Record<string, unknown>
168
+ return 0
169
+ }) as ProgramRunners['ask']
170
+
171
+ await parse(
172
+ ['ask', 'Deploy where?', '--choice', 'Staging', '--choice', 'Production', '--session-label', 'release run', '--json'],
173
+ { ask },
174
+ )
175
+ expect(seenQuestion).toBe('Deploy where?')
176
+ expect(seenFlags).toEqual({
177
+ choice: ['Staging', 'Production'],
178
+ sessionLabel: 'release run',
179
+ json: true,
180
+ })
181
+
182
+ await parse(['ask', 'Free text?'], { ask })
183
+ expect(seenFlags).toEqual({})
184
+ })
185
+
186
+ it('rejects --no-block as unknown: the flag is gone, not tombstoned', async () => {
187
+ const { exitCode, deps } = await parse(
188
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--no-block'],
189
+ { send: (async () => 0) as ProgramRunners['send'] },
190
+ )
191
+ expect(exitCode).toBe(2)
192
+ expect(deps.errLines).toHaveLength(0)
193
+ })
194
+
195
+ it('names an unknown command and exits 2', async () => {
196
+ const { exitCode, deps } = await parse(['sendd'], {})
197
+ expect(exitCode).toBe(2)
198
+ expect(deps.errLines.join('\n')).toContain('Unknown command "sendd"')
199
+ expect(deps.errLines.join('\n')).toContain('send')
200
+ })
201
+
202
+ it('rejects send --event now that the public event field is gone', async () => {
203
+ const { exitCode, deps } = await parse(
204
+ ['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--event', 'tests_passed'],
205
+ { send: (async () => 0) as ProgramRunners['send'] },
206
+ )
207
+ expect(exitCode).toBe(2)
208
+ expect(deps.errLines).toHaveLength(0)
209
+ })
210
+
211
+ it('drops the empty --event collector for logs', async () => {
212
+ let seen: Record<string, unknown> | undefined
213
+ const logs = ((_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['logs']
214
+ await parse(['logs'], { logs })
215
+ expect(seen).not.toHaveProperty('event')
216
+ await parse(['logs', '--event', 'send.submitted'], { logs })
217
+ expect(seen).toMatchObject({ event: ['send.submitted'] })
218
+ })
219
+
220
+ it('passes close --pending without inventing a request id', async () => {
221
+ let seenId: string | undefined
222
+ let seenFlags: Record<string, unknown> | undefined
223
+ const close = (async (_deps, requestId, flags) => {
224
+ seenId = requestId
225
+ seenFlags = flags as Record<string, unknown>
226
+ return 0
227
+ }) as ProgramRunners['close']
228
+
229
+ await parse(['close', '--pending', '--json'], { close })
230
+ expect(seenId).toBeUndefined()
231
+ expect(seenFlags).toEqual({ pending: true, json: true })
232
+
233
+ await parse(['close', 'req_abc'], { close })
234
+ expect(seenId).toBe('req_abc')
235
+ expect(seenFlags).toEqual({})
236
+ })
237
+ })