@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.
- package/CHANGELOG.md +37 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +9 -2
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/commands-init.ts
DELETED
|
@@ -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
|
-
}
|