@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,878 +0,0 @@
1
- import {
2
- CAPABILITIES_V1,
3
- validateDraft,
4
- type RoutableDevice,
5
- type SubmissionReceipt,
6
- } from '@raidiant/notifai-protocol'
7
- import { randomBytes } from 'node:crypto'
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
- import path from 'node:path'
10
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
11
- import { ensurePrivateDirectory } from './atomic-file.js'
12
- import { ApiCallError, NetworkError, type ApiClient } from './client.js'
13
- import { personalProjectConfigPath, type CliConfig } from './config.js'
14
- import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
15
- import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
16
- import {
17
- firstBlocker,
18
- questionRoutingReady,
19
- readinessJson,
20
- type Readiness,
21
- type ReadinessRefresh,
22
- type ReadinessState,
23
- } from './readiness.js'
24
- import { buildDraft, formatReceipt } from './send.js'
25
- import { loginCommand } from './commands-auth.js'
26
- import {
27
- EXIT,
28
- SETUP_COMMAND,
29
- UPDATE_CLI_COMMAND,
30
- authedClient,
31
- loadLoggedConfig,
32
- reportError,
33
- type CommandDeps,
34
- } from './commands-core.js'
35
- import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
36
- import { assessReadiness, remedyLine } from './commands-doctor.js'
37
- import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
38
- import {
39
- observedCompanionReceipt,
40
- readSetupProof,
41
- setupProofProject,
42
- setupProofApplies,
43
- setupProofIsStale,
44
- writeSetupProof,
45
- } from './commands-setup-proof.js'
46
- import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
47
- import { enableProject, projectBinding } from './project-enablement.js'
48
-
49
- // ---------------------------------------------------------------------------
50
- // init
51
- // ---------------------------------------------------------------------------
52
-
53
-
54
- /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
55
- export function projectSlugFrom(name: string): string {
56
- return inferredProjectSlugFrom(name) ?? 'project'
57
- }
58
-
59
- /** Project checkout vs this machine. Drives skill, hooks, and config together. */
60
- export type SetupScope = SkillScope
61
-
62
- export interface InitFlags {
63
- /** Emit the final shared readiness model and never prompt. */
64
- json?: boolean
65
- projectId?: string
66
- /**
67
- * Install the agent guidance skill. Tri-state on purpose:
68
- * true installs, false skips silently, and undefined means "offer it when a
69
- * human is present, do nothing when one is not" — an unattended run must
70
- * never spawn npx against the network by default.
71
- */
72
- skills?: boolean
73
- /**
74
- * One setup-scope for skill, hooks, and config. The unattended answer to
75
- * init's single project-vs-machine question. Pairing, devices, and the CLI
76
- * binary are not this flag.
77
- */
78
- setupScope?: SetupScope
79
- /** Alias of `--setup-scope` when installing the skill unattended. */
80
- skillsScope?: SkillScope
81
- /** Same tri-state, for the harness hooks. */
82
- hooks?: boolean
83
- }
84
-
85
-
86
- /** Long enough for a first controlled Companion install; keep-waiting extends another budget. */
87
- const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
88
- const DEVICE_BRIDGE_POLL_MS = 2_000
89
- const PROOF_TIMEOUT_MS = 30_000
90
- const PROOF_POLL_MS = 1_000
91
-
92
-
93
- function formatWaitBudget(milliseconds: number): string {
94
- const minutes = Math.round(milliseconds / 60_000)
95
- return minutes === 1 ? '1 minute' : `${minutes} minutes`
96
- }
97
-
98
-
99
- /**
100
- * The setup coordinator that observes each prerequisite and advances the ones
101
- * this build can perform.
102
- *
103
- * Idempotent by construction: every step first observes, then acts only on the
104
- * gap, so re-running is how you check the setup as much as how you create it.
105
- * With a human at a terminal it walks them through the missing pieces; run by
106
- * an agent it never prompts — each optional step is answered by a flag, and
107
- * whatever only the user can do (signing in, pairing a companion device) is
108
- * printed as the next human action. An agent runs every CLI command itself.
109
- */
110
- /**
111
- * Close a gap the CLI is allowed to close on its own, without asking.
112
- *
113
- * Only reached for `by: 'cli'` remedies, which by definition need no human, so
114
- * this stays silent about what it did — the re-assessment that follows reports
115
- * the new state, and narrating both is how a setup log becomes unreadable.
116
- *
117
- * `pending` means the action is real but its evidence has not arrived yet;
118
- * `failed` means the action itself could not be performed.
119
- */
120
- type GapCloseResult = 'closed' | 'pending' | 'failed'
121
-
122
- async function closeGap(
123
- deps: CommandDeps,
124
- state: ReadinessState,
125
- flags: InitFlags,
126
- ): Promise<GapCloseResult> {
127
- if (state.id === 'project') {
128
- if (flags.setupScope === undefined) return 'failed'
129
- const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
130
- const configPath =
131
- flags.setupScope === 'global'
132
- ? personalProjectConfigPath(deps.cwd, deps.env)
133
- : path.join(deps.cwd, '.notifai', 'config.toml')
134
- const existing = existsSync(configPath)
135
- ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
136
- : {}
137
- existing['project'] = slug
138
- if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
139
- else mkdirSync(path.dirname(configPath), { recursive: true })
140
- writeFileSync(configPath, `${stringifyToml(existing)}\n`)
141
- return 'closed'
142
- }
143
-
144
- if (state.id === 'project-enablement') {
145
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
146
- const binding = projectBinding(deps.cwd, deps.env, config.project.value)
147
- if (binding === null) return 'failed'
148
- enableProject(binding)
149
- return 'closed'
150
- }
151
-
152
- if (state.id === 'hooks') {
153
- const harnesses = await pickHarnessesToInstall(deps)
154
- if (harnesses === null || harnesses.length === 0) return 'failed'
155
- let ok = true
156
- const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
157
- for (const harness of harnesses) {
158
- if (hooksInstallCommand(deps, { harness, ...hookFlags, narrate: false }) !== EXIT.ok) ok = false
159
- }
160
- return ok ? 'closed' : 'failed'
161
- }
162
-
163
- if (state.id === 'skill') {
164
- if (deps.nativeSkills === undefined) {
165
- deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
166
- return 'failed'
167
- }
168
- // Refuse rather than reach for a mutable ref: installing the skill from a
169
- // moving branch is the one outcome the pin exists to prevent.
170
- if (SKILLS_SOURCE === null) {
171
- deps.io.err(
172
- 'Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.',
173
- )
174
- return 'failed'
175
- }
176
- const installScope = flags.setupScope ?? flags.skillsScope
177
- if (installScope === undefined) {
178
- deps.io.err(
179
- 'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
180
- )
181
- return 'failed'
182
- }
183
- const { installed } = await listScopedNotifaiSkills(deps)
184
- const extras = installed.filter((skill) => skill.scope !== installScope)
185
- for (const extra of extras) {
186
- const code = await deps.nativeSkills.remove({
187
- skill: 'notifai',
188
- scope: extra.scope,
189
- cwd: deps.cwd,
190
- env: deps.env,
191
- })
192
- if (code !== 0) {
193
- deps.io.err(
194
- `Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
195
- )
196
- return 'failed'
197
- }
198
- }
199
- deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
200
- const code = await deps.nativeSkills.add({
201
- source: SKILLS_SOURCE,
202
- skill: 'notifai',
203
- cwd: deps.cwd,
204
- env: deps.env,
205
- scope: installScope,
206
- })
207
- if (code !== 0) {
208
- deps.io.err('Skill installation failed — run it manually with:')
209
- deps.io.err(
210
- ` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
211
- installScope === 'global' ? ' --global' : ''
212
- } --yes`,
213
- )
214
- }
215
- return code === 0 ? 'closed' : 'failed'
216
- }
217
-
218
- if (state.id === 'proof') return await runSetupProof(deps)
219
-
220
- return 'failed'
221
- }
222
-
223
-
224
- function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
225
- if (devices.length === 0) {
226
- return 'Waiting for a Companion App to sign in and register…'
227
- }
228
- const denied = devices.find((device) => device.permission_status === 'denied')
229
- if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
230
- const undecided = devices.find((device) => device.permission_status === 'not_determined')
231
- if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
232
- return 'Waiting for a Companion device to become ready…'
233
- }
234
-
235
- /**
236
- * Observe the supported Device Installation path while the user finishes the
237
- * app-side work. The live bridge is the dashboard `/support` page (controlled
238
- * Companion installation steps). Interactive runs offer to open it so the user
239
- * never types the URL; non-interactive/agent paths only print plain text and
240
- * never wait on a prompt.
241
- *
242
- * The wait budget is stated up front. On expiry we say the *timer* expired —
243
- * not the setup — and offer another budget when a human is present. Agents
244
- * never hang: this whole path is gated on `io.interactive`, and keep-waiting
245
- * uses `confirm` which resolves the safe default when not interactive.
246
- */
247
- async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
248
- const remedy = state.remedy
249
- if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
250
-
251
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
252
- const authed = authedClient(deps, config)
253
- if (!authed) {
254
- deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
255
- return 'failed'
256
- }
257
-
258
- const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
259
- const supportUrl = supportPageUrl(authed.baseUrl)
260
- await deps.io.note?.(
261
- [
262
- state.detail,
263
- remedy.summary,
264
- `Install steps (no typing): ${supportUrl}`,
265
- `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
266
- ].join('\n'),
267
- 'Finish setup on your Companion device',
268
- )
269
-
270
- // Open the real support page so the user never has to type the URL. Decline
271
- // is fine — the URL remains in the note and in the Next: line if they leave.
272
- if (await deps.io.confirm('Open install instructions in your browser?', true)) {
273
- deps.io.openUrl(supportUrl)
274
- }
275
-
276
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
277
- deps.io.out(
278
- `OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
279
- )
280
- return 'pending'
281
- }
282
-
283
- const now = deps.now ?? Date.now
284
- const sleep =
285
- deps.sleep ??
286
- ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
287
- const spinner = await deps.io.spinner?.(
288
- `Waiting up to ${budgetLabel} for a Companion device…`,
289
- )
290
- let lastDevices: RoutableDevice[] = []
291
- let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
292
-
293
- for (;;) {
294
- while (now() < deadline) {
295
- try {
296
- const response = await authed.client.listDevices()
297
- const companionDevices = response.devices.filter(
298
- (device) => device.platform === 'ios' || device.platform === 'android',
299
- )
300
- lastDevices = companionDevices
301
- const ready = readyCompanionDevices(companionDevices)[0]
302
- if (ready) {
303
- spinner?.stop(`${ready.display_name} is ready to receive`)
304
- return 'closed'
305
- }
306
- spinner?.message(deviceBridgeMessage(companionDevices))
307
- } catch (err) {
308
- if (!(err instanceof NetworkError)) {
309
- spinner?.error('Could not check companion readiness')
310
- reportError(deps, err)
311
- return 'failed'
312
- }
313
- spinner?.message('Connection lost — still watching…')
314
- }
315
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
316
- }
317
-
318
- // Timer expired — setup did not. Offer another budget only when a human
319
- // can answer; confirm() already returns the fallback for agents, so this
320
- // never hangs unattended even if interactive were mis-set.
321
- spinner?.error(`The ${budgetLabel} wait timer expired`)
322
- deps.io.err(
323
- `The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
324
- )
325
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
326
- deps.io.err(
327
- `Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
328
- )
329
-
330
- const keepWaiting = await deps.io.confirm(
331
- `Keep waiting for another ${budgetLabel}?`,
332
- false,
333
- )
334
- if (!keepWaiting) {
335
- deps.io.out(
336
- 'Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.',
337
- )
338
- return 'pending'
339
- }
340
- spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`)
341
- deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
342
- }
343
- }
344
-
345
- function setupProofDraft(
346
- config: CliConfig,
347
- device: RoutableDevice,
348
- ): ReturnType<typeof buildDraft> {
349
- const project = config.project.value
350
- return buildDraft(config, {
351
- title: 'Notifai is ready',
352
- body:
353
- project === null
354
- ? 'This real notification completed setup verification.'
355
- : `This real notification completed setup verification for ${project}.`,
356
- kind: 'update',
357
- platform: device.platform,
358
- device: [device.device_id],
359
- sound: 'none',
360
- // Android has no caller-selected interruption level. Explicit null keeps an
361
- // Apple preference in config from leaking into this Android-only proof.
362
- level: device.platform === 'ios' ? 'passive' : null,
363
- collapseKey: 'notifai-setup-verification',
364
- })
365
- }
366
-
367
- async function submitSetupProof(
368
- deps: CommandDeps,
369
- client: ApiClient,
370
- config: CliConfig,
371
- device: RoutableDevice,
372
- ): Promise<SubmissionReceipt | null> {
373
- const build = setupProofDraft(config, device)
374
- if (!build.ok) {
375
- deps.io.err(`Could not build the setup verification notification: ${build.error}`)
376
- return null
377
- }
378
- const capabilities = CAPABILITIES_V1.describe(build.platform)
379
- if (!capabilities) {
380
- deps.io.err(`No capability contract is available for ${build.platform}.`)
381
- return null
382
- }
383
- const validation = validateDraft(build.draft, capabilities)
384
- if (!validation.ok) {
385
- for (const issue of validation.errors) {
386
- deps.io.err(`Setup verification ${issue.path}: ${issue.message}`)
387
- }
388
- return null
389
- }
390
- try {
391
- return await client.submit(
392
- {
393
- idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
394
- draft: build.draft,
395
- },
396
- config.wait_seconds.value,
397
- )
398
- } catch (err) {
399
- reportError(deps, err)
400
- return null
401
- }
402
- }
403
-
404
- /**
405
- * Send or resume one real setup probe, then wait for a Companion Receipt.
406
- * Provider Acceptance is intentionally insufficient: it proves a push provider
407
- * accepted the request, not that a companion process received it.
408
- */
409
- async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
410
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
411
- const authed = authedClient(deps, config)
412
- if (!authed) return 'failed'
413
-
414
- let devices: RoutableDevice[]
415
- try {
416
- devices = (await authed.client.listDevices()).devices
417
- } catch (err) {
418
- reportError(deps, err)
419
- return 'failed'
420
- }
421
- const candidates = readyCompanionDevices(devices)
422
- const project = setupProofProject(deps, config.project.value)
423
- const existing = readSetupProof(deps, project)
424
- const existingApplies = setupProofApplies(
425
- existing,
426
- project,
427
- candidates.map((device) => device.device_id),
428
- )
429
- const target = (existingApplies
430
- ? candidates.find((device) => device.device_id === existing.device_id)
431
- : undefined) ?? candidates[0]
432
- if (!target) {
433
- deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
434
- return 'pending'
435
- }
436
-
437
- let proof =
438
- existingApplies && existing.device_id === target.device_id
439
- ? existing
440
- : null
441
- const nowMs = (deps.now ?? Date.now)()
442
- if (proof !== null && setupProofIsStale(proof, nowMs)) {
443
- let keepStale = false
444
- try {
445
- const snapshot = await authed.client.evidence(proof.request_id)
446
- keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
447
- } catch {
448
- keepStale = false
449
- }
450
- if (!keepStale) {
451
- deps.io.out(
452
- `The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
453
- )
454
- proof = null
455
- }
456
- }
457
- if (proof === null) {
458
- const receipt = await submitSetupProof(deps, authed.client, config, target)
459
- if (receipt === null) return 'failed'
460
- if (receipt.overall === 'provider_rejected_all') {
461
- deps.io.err(formatReceipt(receipt))
462
- return 'failed'
463
- }
464
- proof = {
465
- request_id: receipt.request_id,
466
- device_id: target.device_id,
467
- project,
468
- started_at: new Date((deps.now ?? Date.now)()).toISOString(),
469
- }
470
- if (!writeSetupProof(deps, proof)) return 'failed'
471
- deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`)
472
- } else {
473
- deps.io.out(`Checking verification notification ${proof.request_id} again.`)
474
- }
475
-
476
- const now = deps.now ?? Date.now
477
- const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
478
- const deadline = now() + PROOF_TIMEOUT_MS
479
- const spinner = deps.io.interactive === true
480
- ? await deps.io.spinner?.(
481
- "Waiting for a Companion Receipt (the app's delivery confirmation)…",
482
- )
483
- : null
484
- let lastError: unknown = null
485
- let replacedMissingProof = false
486
-
487
- for (;;) {
488
- try {
489
- const snapshot = await authed.client.evidence(proof.request_id)
490
- const observed = observedCompanionReceipt(snapshot, proof.device_id)
491
- if (observed) {
492
- spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
493
- deps.io.out(
494
- `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`,
495
- )
496
- return 'closed'
497
- }
498
- lastError = null
499
- } catch (err) {
500
- lastError = err
501
- if (
502
- err instanceof ApiCallError &&
503
- err.code === 'not_found' &&
504
- !replacedMissingProof
505
- ) {
506
- const receipt = await submitSetupProof(deps, authed.client, config, target)
507
- if (receipt === null) return 'failed'
508
- if (receipt.overall === 'provider_rejected_all') {
509
- deps.io.err(formatReceipt(receipt))
510
- return 'failed'
511
- }
512
- proof = {
513
- request_id: receipt.request_id,
514
- device_id: target.device_id,
515
- project,
516
- started_at: new Date(now()).toISOString(),
517
- }
518
- if (!writeSetupProof(deps, proof)) return 'failed'
519
- replacedMissingProof = true
520
- lastError = null
521
- deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`)
522
- continue
523
- }
524
- if (!(err instanceof NetworkError)) {
525
- spinner?.error('Could not read Companion Receipt evidence')
526
- reportError(deps, err)
527
- return 'failed'
528
- }
529
- spinner?.message('Connection lost — still checking the same request…')
530
- }
531
-
532
- if (now() >= deadline) break
533
- await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
534
- }
535
-
536
- spinner?.stop('Delivery confirmation not observed yet')
537
- if (lastError instanceof NetworkError) deps.io.err(lastError.message)
538
- deps.io.out(
539
- `Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
540
- 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
541
- )
542
- return 'pending'
543
- }
544
-
545
- /** Closing a local gap cannot have changed the service, the keychain, or devices. */
546
- function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
547
- return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
548
- ? ['local']
549
- : undefined
550
- }
551
-
552
- /** Whether an optional gap should be closed, given flags and who is watching. */
553
- function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
554
- // Optional CLI updates are surfaced by doctor or when a missing feature is
555
- // relevant. Init does not turn them into setup work.
556
- if (state.id === 'contract') return Promise.resolve(false)
557
- // Naming the project is init's whole reason to touch the filesystem, costs
558
- // nothing, and is undone by editing one line — so it is done rather than
559
- // asked about, for a human and an agent alike.
560
- if (state.id === 'project') return Promise.resolve(true)
561
- if (state.id === 'project-enablement') return Promise.resolve(true)
562
- const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined
563
- if (explicit !== undefined) return Promise.resolve(explicit)
564
- // An agent is never asked, and never assumed into a change it did not
565
- // request: silence means no, and the summary says what was skipped.
566
- if (deps.io.interactive !== true) return Promise.resolve(false)
567
- const question =
568
- state.id === 'hooks'
569
- ? 'Install harness hooks, so questions reach your devices when you are away?'
570
- : 'Install/update the agent guidance skill through the native npx skills flow?'
571
- return deps.io.confirm(question, true)
572
- }
573
-
574
- /**
575
- * Setup as one step at a time.
576
- *
577
- * The old version ran five steps in a fixed order and ended with a list of
578
- * everything still outstanding. That is a report, and a report is the wrong
579
- * output here: someone handed five things to do does none of them, and the
580
- * order was the script's rather than the dependency graph's — it offered to
581
- * install hooks after a sign-in that had just failed.
582
- *
583
- * So this closes what it can, then surfaces exactly one thing, the first that
584
- * stands in the way. Re-running advances by one. Idempotence stops being a
585
- * property to preserve and becomes the mechanism: every decision is derived
586
- * from observed state, so a partial run, a second project, a fresh worktree
587
- * and a revoked credential are the same code path arriving at different
588
- * states rather than four branches to enumerate.
589
- */
590
- function isSetupScope(value: string | undefined): value is SetupScope {
591
- return value === 'project' || value === 'global'
592
- }
593
-
594
- async function resolveSetupScope(
595
- deps: CommandDeps,
596
- flags: InitFlags,
597
- ): Promise<SetupScope | undefined | 'usage'> {
598
- if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
599
- deps.io.err('Invalid setup scope. Choose `project` or `global`.')
600
- return 'usage'
601
- }
602
- if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
603
- deps.io.err('Invalid skill scope. Choose `project` or `global`.')
604
- return 'usage'
605
- }
606
- if (
607
- flags.setupScope !== undefined &&
608
- flags.skillsScope !== undefined &&
609
- flags.setupScope !== flags.skillsScope
610
- ) {
611
- deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
612
- return 'usage'
613
- }
614
- if (flags.skillsScope !== undefined && flags.skills !== true) {
615
- deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
616
- return 'usage'
617
- }
618
- const fromFlags = flags.setupScope ?? flags.skillsScope
619
- if (fromFlags !== undefined) return fromFlags
620
- if (flags.skills === false && flags.hooks === false) return undefined
621
- if (deps.io.interactive === true && deps.io.select) {
622
- const selected = await deps.io.select(
623
- 'Should this Notifai setup apply to this project only, or to every project on this machine?',
624
- [
625
- {
626
- value: 'project',
627
- label: 'This project',
628
- hint: 'skill, hooks, and shared config stay in this checkout',
629
- },
630
- {
631
- value: 'global',
632
- label: 'This machine',
633
- hint: 'skill and hooks follow you into every repo',
634
- },
635
- ],
636
- )
637
- if (selected === 'project' || selected === 'global') return selected
638
- deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
639
- return 'usage'
640
- }
641
- if (flags.skills === true || flags.hooks === true) {
642
- deps.io.err(
643
- 'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
644
- )
645
- return 'usage'
646
- }
647
- return undefined
648
- }
649
-
650
- export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
651
- const workingDeps: CommandDeps = flags.json === true
652
- ? {
653
- ...deps,
654
- io: {
655
- interactive: false,
656
- out: () => {},
657
- err: (line) => deps.io.err(line),
658
- confirm: async () => false,
659
- openUrl: (url) => deps.io.openUrl(url),
660
- },
661
- }
662
- : deps
663
- const setupScope = await resolveSetupScope(workingDeps, flags)
664
- if (setupScope === 'usage') return EXIT.usage
665
- const resolved: InitFlags = {
666
- ...flags,
667
- ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
668
- }
669
- await workingDeps.io.intro?.('Notifai setup')
670
-
671
- const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
672
- let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
673
- const reassess = (refresh?: readonly ReadinessRefresh[]) =>
674
- assessReadiness(workingDeps, {
675
- ...skillOpts,
676
- ...(flags.json === true ? { json: true } : {}),
677
- ...(refresh === undefined ? {} : { previous: readiness, refresh }),
678
- })
679
- let failed = false
680
- const attempted = new Set<string>()
681
-
682
- // Re-assess after every successful action. This is how a browser approval or
683
- // companion registration can unlock the next state while the user is still
684
- // here, without copying the dependency graph into a second setup script.
685
- for (;;) {
686
- let advanced = false
687
- let stop = false
688
-
689
- for (const state of readiness.states) {
690
- if (state.status === 'ready') continue
691
- if (state.status === 'unknown') {
692
- stop = true
693
- break
694
- }
695
-
696
- const remedy = state.remedy
697
- if (remedy === undefined || attempted.has(state.id)) {
698
- if (state.status === 'gap') stop = true
699
- if (stop) break
700
- continue
701
- }
702
-
703
- if (state.status === 'optional-gap') {
704
- if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
705
- if (resolved.setupScope === undefined && state.id === 'project') {
706
- stop = true
707
- break
708
- }
709
- attempted.add(state.id)
710
- const result = await closeGap(workingDeps, state, resolved)
711
- if (result === 'failed') failed = true
712
- if (result === 'failed' && state.status === 'optional-gap') {
713
- readiness = await reassess(refreshAfterClose(state.id))
714
- advanced = true
715
- break
716
- }
717
- if (result !== 'closed') {
718
- stop = true
719
- break
720
- }
721
- readiness = await reassess(refreshAfterClose(state.id))
722
- advanced = true
723
- break
724
- }
725
-
726
- if (remedy.by === 'cli') {
727
- if (resolved.setupScope === undefined && state.id === 'project') {
728
- stop = true
729
- break
730
- }
731
- attempted.add(state.id)
732
- const result = await closeGap(workingDeps, state, resolved)
733
- if (result === 'failed') failed = true
734
- if (result !== 'closed') {
735
- stop = true
736
- break
737
- }
738
- readiness = await reassess(refreshAfterClose(state.id))
739
- advanced = true
740
- break
741
- }
742
-
743
- // Its to launch, theirs to complete. Running `init` is the consent; announce
744
- // and open rather than re-asking. An agent never reaches this path.
745
- if (
746
- remedy.by === 'user-here' &&
747
- remedy.interactive === true &&
748
- workingDeps.io.interactive === true
749
- ) {
750
- attempted.add(state.id)
751
- workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
752
- if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
753
- failed = true
754
- stop = true
755
- break
756
- }
757
- readiness = await reassess()
758
- advanced = true
759
- break
760
- }
761
-
762
- if (
763
- state.id === 'devices' &&
764
- remedy.by === 'user-elsewhere' &&
765
- workingDeps.io.interactive === true
766
- ) {
767
- attempted.add(state.id)
768
- const result = await waitForReadyDevice(workingDeps, state)
769
- if (result === 'failed') failed = true
770
- if (result !== 'closed') {
771
- stop = true
772
- break
773
- }
774
- readiness = await reassess()
775
- advanced = true
776
- break
777
- }
778
-
779
- // A human-only remedy is the first blocker for an unattended agent.
780
- stop = true
781
- break
782
- }
783
-
784
- if (stop || !advanced) break
785
- }
786
-
787
- if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
788
- else await printInitClose(deps, readiness, resolved)
789
- const blocker = firstBlocker(readiness)
790
- if (blocker === null) return failed ? EXIT.failed : EXIT.ok
791
- return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
792
- }
793
-
794
- function isHookSubstate(id: string): boolean {
795
- return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
796
- }
797
-
798
- function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
799
- return readiness.states.filter((state) => {
800
- if (state.status !== 'optional-gap') return false
801
- if (isHookSubstate(state.id)) return false
802
- if (state.id === 'skill' && flags.skills === false) return false
803
- return true
804
- })
805
- }
806
-
807
- function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
808
- for (const state of leftovers) {
809
- deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
810
- }
811
- }
812
-
813
- async function printInitClose(
814
- deps: CommandDeps,
815
- readiness: Readiness,
816
- flags: InitFlags,
817
- ): Promise<void> {
818
- const blocker = firstBlocker(readiness)
819
- const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
820
- const questions = questionRoutingReady(readiness)
821
- const leftovers = leftoverOptionals(readiness, flags).filter(
822
- (state) => state.id !== 'contract',
823
- )
824
-
825
- if (blocker?.id === 'contract') {
826
- deps.io.out(blocker.detail)
827
- deps.io.out(UPDATE_CLI_COMMAND)
828
- return
829
- }
830
-
831
- if (deps.io.interactive === true) {
832
- if (blocker === null) {
833
- const lines = [
834
- canSend ? 'You can send notifications.' : 'You are signed in.',
835
- questions
836
- ? 'Questions will reach your devices.'
837
- : 'Questions are terminal-only until Question Routing is ready.',
838
- ]
839
- await deps.io.note?.(lines.join('\n'), 'Ready')
840
- printOptionalLeftovers(deps, leftovers)
841
- deps.io.out(
842
- questions
843
- ? 'All set. Agents in this project can notify you and ask you questions.'
844
- : 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
845
- )
846
- await deps.io.outro?.('All set ✨')
847
- return
848
- }
849
- await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
850
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
851
- deps.io.out(` ${remedyLine(blocker)}`)
852
- if (shouldRestateInit(blocker)) {
853
- deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
854
- }
855
- await deps.io.outro?.('One step remains (above)')
856
- return
857
- }
858
-
859
- if (blocker === null) {
860
- printOptionalLeftovers(deps, leftovers)
861
- deps.io.out(
862
- questions
863
- ? 'All set. Agents in this project can notify you and ask you questions.'
864
- : 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
865
- )
866
- return
867
- }
868
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
869
- deps.io.out(` ${remedyLine(blocker)}`)
870
- if (shouldRestateInit(blocker)) {
871
- deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
872
- }
873
- }
874
-
875
- function shouldRestateInit(blocker: ReadinessState): boolean {
876
- if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here') return false
877
- return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND
878
- }