@raidiant/notifai 9.1.0 → 9.2.1
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 +14 -0
- package/README.md +5 -3
- package/dist/commands-auth.js +3 -2
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-devices.js +5 -1
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-harness-context.js +2 -2
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +2 -0
- package/dist/commands-hooks.js +15 -51
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +18 -13
- package/dist/commands-init.js.map +1 -1
- package/dist/hooks.d.ts +2 -14
- package/dist/hooks.js +2 -29
- package/dist/hooks.js.map +1 -1
- package/dist/opencode-plugin.js +21 -5
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/session-activation.d.ts +2 -2
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/package.json +1 -1
- package/src/commands-auth.ts +3 -2
- package/src/commands-devices.test.ts +15 -0
- package/src/commands-devices.ts +5 -1
- package/src/commands-harness-context.ts +2 -6
- package/src/commands-hooks.ts +17 -59
- package/src/commands-init.ts +24 -14
- package/src/commands.test.ts +91 -28
- package/src/hooks.test.ts +4 -130
- package/src/hooks.ts +2 -45
- package/src/install-hooks.test.ts +41 -16
- package/src/onboarding-docs.test.ts +25 -0
- package/src/opencode-plugin.ts +24 -5
- package/src/session-activation.ts +2 -2
- package/src/skill.test.ts +21 -11
|
@@ -98,18 +98,14 @@ export function resolveActiveHarness(
|
|
|
98
98
|
if (candidates.length === 1) return { active: first, contested: [] }
|
|
99
99
|
|
|
100
100
|
// Exact lifecycle state is machine-global and survives a command moving to
|
|
101
|
-
// another linked checkout. Prefer it before the checkout-local
|
|
101
|
+
// another linked checkout. Prefer it before the checkout-local
|
|
102
102
|
// pointer. A unique newest hook event proves which nested harness owns this
|
|
103
103
|
// shell; ties and missing evidence remain contested and fail closed.
|
|
104
104
|
const evidenced = candidates.flatMap((candidate) => {
|
|
105
105
|
if (candidate.sessionId === undefined) return []
|
|
106
106
|
const state = readSessionState(candidate.sessionId, env)
|
|
107
107
|
if (state.harness !== candidate.harness) return []
|
|
108
|
-
const activity = Math.max(
|
|
109
|
-
state.last_prompt_at ?? 0,
|
|
110
|
-
state.last_stop_at ?? 0,
|
|
111
|
-
state.activation_context_emitted_at ?? 0,
|
|
112
|
-
)
|
|
108
|
+
const activity = Math.max(state.last_prompt_at ?? 0, state.last_stop_at ?? 0)
|
|
113
109
|
return activity > 0 ? [{ candidate, activity }] : []
|
|
114
110
|
})
|
|
115
111
|
if (evidenced.length > 0) {
|
package/src/commands-hooks.ts
CHANGED
|
@@ -30,14 +30,13 @@ import {
|
|
|
30
30
|
} from './hook-adapter.js'
|
|
31
31
|
import {
|
|
32
32
|
clearAcknowledgementObligation,
|
|
33
|
-
claimPromptActivation,
|
|
34
33
|
claimCursorStopActivation,
|
|
35
34
|
confirmCursorStopActivation,
|
|
36
35
|
dropPendingQuestion,
|
|
37
36
|
handleSessionEnd,
|
|
38
37
|
handleStop,
|
|
39
38
|
handleUserPromptSubmit,
|
|
40
|
-
|
|
39
|
+
recordSessionStart,
|
|
41
40
|
parkForRetirement,
|
|
42
41
|
parseHookInput,
|
|
43
42
|
pruneAbandonedSessions,
|
|
@@ -254,14 +253,6 @@ export async function hookRunCommand(
|
|
|
254
253
|
const cwd = envelope.cwd ?? deps.cwd
|
|
255
254
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
256
255
|
logger.bind({ session: envelope.session_id ?? null })
|
|
257
|
-
const declaredSourcePid = declaredHookSourcePid(deps)
|
|
258
|
-
const activationOwnerPid =
|
|
259
|
-
harness === 'claude-code'
|
|
260
|
-
? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
|
|
261
|
-
: harness === 'codex'
|
|
262
|
-
? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
|
|
263
|
-
: undefined
|
|
264
|
-
|
|
265
256
|
// Session activation is local model context, not authenticated routing. It
|
|
266
257
|
// must survive the exact first-run states it exists to repair: no config,
|
|
267
258
|
// no credential, no Companion device, and no network.
|
|
@@ -285,22 +276,14 @@ export async function hookRunCommand(
|
|
|
285
276
|
deps.env,
|
|
286
277
|
)
|
|
287
278
|
if (stdout !== undefined) deps.io.out(stdout)
|
|
288
|
-
if (
|
|
289
|
-
stdout !== undefined &&
|
|
290
|
-
event === 'session-start' &&
|
|
291
|
-
activationOwnerPid !== undefined &&
|
|
292
|
-
envelope.session_id !== undefined
|
|
293
|
-
) {
|
|
279
|
+
if (event === 'session-start' && envelope.session_id !== undefined) {
|
|
294
280
|
try {
|
|
295
|
-
|
|
296
|
-
// later prompt may duplicate context, but it cannot suppress context
|
|
297
|
-
// that the host never received.
|
|
298
|
-
markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)
|
|
281
|
+
recordSessionStart(envelope.session_id, deps.env, harness, cwd)
|
|
299
282
|
} catch (err) {
|
|
300
283
|
logger.error('hook.end', {
|
|
301
284
|
hook: event,
|
|
302
|
-
outcome: '
|
|
303
|
-
reason: '
|
|
285
|
+
outcome: 'record-failed',
|
|
286
|
+
reason: 'session-state-failed',
|
|
304
287
|
...failureData(err),
|
|
305
288
|
})
|
|
306
289
|
}
|
|
@@ -313,33 +296,6 @@ export async function hookRunCommand(
|
|
|
313
296
|
return EXIT.ok
|
|
314
297
|
}
|
|
315
298
|
|
|
316
|
-
// SessionStart is the preferred model-visible activation seam. A few
|
|
317
|
-
// managed hosts regenerate older hook definitions that retain
|
|
318
|
-
// UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
|
|
319
|
-
// once per exact session as a compatibility fallback, before config/auth/
|
|
320
|
-
// device/network work, and stay silent when SessionStart already ran.
|
|
321
|
-
let promptActivationStdout: string | undefined
|
|
322
|
-
if (
|
|
323
|
-
event === 'user-prompt-submit' &&
|
|
324
|
-
(harness === 'claude-code' || harness === 'codex') &&
|
|
325
|
-
activationOwnerPid !== undefined &&
|
|
326
|
-
envelope.session_id !== undefined
|
|
327
|
-
) {
|
|
328
|
-
try {
|
|
329
|
-
if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
|
|
330
|
-
promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env)
|
|
331
|
-
if (promptActivationStdout !== undefined) deps.io.out(promptActivationStdout)
|
|
332
|
-
}
|
|
333
|
-
} catch (err) {
|
|
334
|
-
logger.error('hook.end', {
|
|
335
|
-
hook: event,
|
|
336
|
-
outcome: 'claim-failed',
|
|
337
|
-
reason: 'activation-state-failed',
|
|
338
|
-
...failureData(err),
|
|
339
|
-
})
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
299
|
// Cursor has a confirmed host bug in which sessionStart.additional_context
|
|
344
300
|
// is accepted but never reaches the model. A native Stop follow-up is the
|
|
345
301
|
// host's guaranteed model-visible channel. Claim it once per conversation,
|
|
@@ -443,7 +399,6 @@ export async function hookRunCommand(
|
|
|
443
399
|
hook: event,
|
|
444
400
|
outcome: 'failed',
|
|
445
401
|
reason: 'config-failed',
|
|
446
|
-
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
447
402
|
...failureData(err),
|
|
448
403
|
})
|
|
449
404
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
@@ -547,10 +502,7 @@ export async function hookRunCommand(
|
|
|
547
502
|
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
548
503
|
logger.info('hook.end', {
|
|
549
504
|
hook: event,
|
|
550
|
-
// Stop stdout takes over the turn. UserPromptSubmit stdout may instead
|
|
551
|
-
// add context to the user's own turn, which is explicitly non-decisive.
|
|
552
505
|
decided: outcome.decided ?? outcome.stdout !== undefined,
|
|
553
|
-
...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
|
|
554
506
|
...(notes.length === 0 ? {} : { notes }),
|
|
555
507
|
...outcome.log,
|
|
556
508
|
})
|
|
@@ -1640,6 +1592,8 @@ function parkClosedQuestion(
|
|
|
1640
1592
|
export interface HooksInstallFlags {
|
|
1641
1593
|
harness?: string
|
|
1642
1594
|
global?: boolean
|
|
1595
|
+
/** Init owns the final setup result and suppresses per-harness close narration. */
|
|
1596
|
+
narrate?: boolean
|
|
1643
1597
|
/** Test seam; production resolves the running CLI. */
|
|
1644
1598
|
execPath?: string
|
|
1645
1599
|
scriptPath?: string
|
|
@@ -1754,9 +1708,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1754
1708
|
return hooksInstallCommand(deps, { ...flags, global: true, harness })
|
|
1755
1709
|
}
|
|
1756
1710
|
const globalFile = globalInstallation?.file
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1711
|
+
if (flags.narrate !== false) {
|
|
1712
|
+
deps.io.out(
|
|
1713
|
+
`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1760
1716
|
return EXIT.ok
|
|
1761
1717
|
}
|
|
1762
1718
|
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
@@ -1779,6 +1735,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1779
1735
|
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1780
1736
|
platform: hookPlatform,
|
|
1781
1737
|
nodePath,
|
|
1738
|
+
...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
|
|
1782
1739
|
})
|
|
1783
1740
|
}
|
|
1784
1741
|
|
|
@@ -1803,7 +1760,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1803
1760
|
deps.io.err(String(err))
|
|
1804
1761
|
return EXIT.failed
|
|
1805
1762
|
}
|
|
1806
|
-
printHooksInstallClose(deps, harness, settingsTarget)
|
|
1763
|
+
if (flags.narrate !== false) printHooksInstallClose(deps, harness, settingsTarget)
|
|
1807
1764
|
return EXIT.ok
|
|
1808
1765
|
}
|
|
1809
1766
|
|
|
@@ -1854,7 +1811,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1854
1811
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
1855
1812
|
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
1856
1813
|
}
|
|
1857
|
-
printHooksInstallClose(deps, harness, installed.file)
|
|
1814
|
+
if (flags.narrate !== false) printHooksInstallClose(deps, harness, installed.file)
|
|
1858
1815
|
if (installed.foreignStopCount > 0) {
|
|
1859
1816
|
deps.io.out(
|
|
1860
1817
|
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
@@ -1901,6 +1858,7 @@ function installOpencodePlugin(
|
|
|
1901
1858
|
timeoutSeconds: number
|
|
1902
1859
|
platform?: NodeJS.Platform
|
|
1903
1860
|
nodePath?: string
|
|
1861
|
+
narrate?: boolean
|
|
1904
1862
|
},
|
|
1905
1863
|
): number {
|
|
1906
1864
|
try {
|
|
@@ -1922,7 +1880,7 @@ function installOpencodePlugin(
|
|
|
1922
1880
|
deps.io.err(String(err))
|
|
1923
1881
|
return EXIT.failed
|
|
1924
1882
|
}
|
|
1925
|
-
printHooksInstallClose(deps, 'opencode', file)
|
|
1883
|
+
if (options.narrate !== false) printHooksInstallClose(deps, 'opencode', file)
|
|
1926
1884
|
return EXIT.ok
|
|
1927
1885
|
}
|
|
1928
1886
|
|
package/src/commands-init.ts
CHANGED
|
@@ -13,7 +13,14 @@ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
|
13
13
|
import { personalProjectConfigPath, type CliConfig } from './config.js'
|
|
14
14
|
import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
|
|
15
15
|
import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
firstBlocker,
|
|
18
|
+
questionRoutingReady,
|
|
19
|
+
readinessJson,
|
|
20
|
+
type Readiness,
|
|
21
|
+
type ReadinessRefresh,
|
|
22
|
+
type ReadinessState,
|
|
23
|
+
} from './readiness.js'
|
|
17
24
|
import { buildDraft, formatReceipt } from './send.js'
|
|
18
25
|
import { loginCommand } from './commands-auth.js'
|
|
19
26
|
import {
|
|
@@ -116,6 +123,7 @@ async function closeGap(
|
|
|
116
123
|
flags: InitFlags,
|
|
117
124
|
): Promise<GapCloseResult> {
|
|
118
125
|
if (state.id === 'project') {
|
|
126
|
+
if (flags.setupScope === undefined) return 'failed'
|
|
119
127
|
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
120
128
|
const configPath =
|
|
121
129
|
flags.setupScope === 'global'
|
|
@@ -137,7 +145,7 @@ async function closeGap(
|
|
|
137
145
|
let ok = true
|
|
138
146
|
const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
|
|
139
147
|
for (const harness of harnesses) {
|
|
140
|
-
if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
|
|
148
|
+
if (hooksInstallCommand(deps, { harness, ...hookFlags, narrate: false }) !== EXIT.ok) ok = false
|
|
141
149
|
}
|
|
142
150
|
return ok ? 'closed' : 'failed'
|
|
143
151
|
}
|
|
@@ -619,9 +627,9 @@ async function resolveSetupScope(
|
|
|
619
627
|
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
620
628
|
return 'usage'
|
|
621
629
|
}
|
|
622
|
-
if (flags.skills === true) {
|
|
630
|
+
if (flags.skills === true || flags.hooks === true) {
|
|
623
631
|
deps.io.err(
|
|
624
|
-
'Unattended
|
|
632
|
+
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
625
633
|
)
|
|
626
634
|
return 'usage'
|
|
627
635
|
}
|
|
@@ -683,6 +691,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
683
691
|
|
|
684
692
|
if (state.status === 'optional-gap') {
|
|
685
693
|
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
694
|
+
if (resolved.setupScope === undefined && state.id === 'project') {
|
|
695
|
+
stop = true
|
|
696
|
+
break
|
|
697
|
+
}
|
|
686
698
|
attempted.add(state.id)
|
|
687
699
|
const result = await closeGap(workingDeps, state, resolved)
|
|
688
700
|
if (result === 'failed') failed = true
|
|
@@ -701,6 +713,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
701
713
|
}
|
|
702
714
|
|
|
703
715
|
if (remedy.by === 'cli') {
|
|
716
|
+
if (resolved.setupScope === undefined && state.id === 'project') {
|
|
717
|
+
stop = true
|
|
718
|
+
break
|
|
719
|
+
}
|
|
704
720
|
attempted.add(state.id)
|
|
705
721
|
const result = await closeGap(workingDeps, state, resolved)
|
|
706
722
|
if (result === 'failed') failed = true
|
|
@@ -783,12 +799,6 @@ function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly Readiness
|
|
|
783
799
|
}
|
|
784
800
|
}
|
|
785
801
|
|
|
786
|
-
function questionsWillRoute(readiness: Readiness): boolean {
|
|
787
|
-
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
788
|
-
const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
|
|
789
|
-
return hooks?.status === 'ready' && settings?.status !== 'gap'
|
|
790
|
-
}
|
|
791
|
-
|
|
792
802
|
async function printInitClose(
|
|
793
803
|
deps: CommandDeps,
|
|
794
804
|
readiness: Readiness,
|
|
@@ -796,7 +806,7 @@ async function printInitClose(
|
|
|
796
806
|
): Promise<void> {
|
|
797
807
|
const blocker = firstBlocker(readiness)
|
|
798
808
|
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
799
|
-
const questions =
|
|
809
|
+
const questions = questionRoutingReady(readiness)
|
|
800
810
|
const leftovers = leftoverOptionals(readiness, flags).filter(
|
|
801
811
|
(state) => state.id !== 'contract',
|
|
802
812
|
)
|
|
@@ -813,14 +823,14 @@ async function printInitClose(
|
|
|
813
823
|
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
814
824
|
questions
|
|
815
825
|
? 'Questions will reach your devices.'
|
|
816
|
-
: 'Questions
|
|
826
|
+
: 'Questions are terminal-only until Question Routing is ready.',
|
|
817
827
|
]
|
|
818
828
|
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
819
829
|
printOptionalLeftovers(deps, leftovers)
|
|
820
830
|
deps.io.out(
|
|
821
831
|
questions
|
|
822
832
|
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
823
|
-
: 'All set. Agents in this project can notify you. Questions
|
|
833
|
+
: 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
824
834
|
)
|
|
825
835
|
await deps.io.outro?.('All set ✨')
|
|
826
836
|
return
|
|
@@ -840,7 +850,7 @@ async function printInitClose(
|
|
|
840
850
|
deps.io.out(
|
|
841
851
|
questions
|
|
842
852
|
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
843
|
-
: 'All set. Agents in this project can notify you. Questions
|
|
853
|
+
: 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
844
854
|
)
|
|
845
855
|
return
|
|
846
856
|
}
|
package/src/commands.test.ts
CHANGED
|
@@ -3277,13 +3277,13 @@ describe('stable hook installation', () => {
|
|
|
3277
3277
|
).toBe(EXIT.ok)
|
|
3278
3278
|
const globalFile = settingsFile('opencode', true, cwd, env)
|
|
3279
3279
|
const current = readFileSync(globalFile, 'utf8')
|
|
3280
|
-
writeFileSync(globalFile, current.replace('const ADAPTER_VERSION =
|
|
3280
|
+
writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 12', 'const ADAPTER_VERSION = 11'))
|
|
3281
3281
|
|
|
3282
3282
|
io.outLines = []
|
|
3283
3283
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3284
3284
|
expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
|
|
3285
3285
|
const refreshed = readFileSync(globalFile, 'utf8')
|
|
3286
|
-
expect(refreshed).toContain('const ADAPTER_VERSION =
|
|
3286
|
+
expect(refreshed).toContain('const ADAPTER_VERSION = 12')
|
|
3287
3287
|
expect(refreshed).toContain('experimental.chat.system.transform')
|
|
3288
3288
|
expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
|
|
3289
3289
|
})
|
|
@@ -4202,7 +4202,7 @@ describe('init', () => {
|
|
|
4202
4202
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4203
4203
|
}
|
|
4204
4204
|
|
|
4205
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4205
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4206
4206
|
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
4207
4207
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
4208
4208
|
// Safe by default: without an explicit --skills opt-in, init only writes
|
|
@@ -4211,7 +4211,7 @@ describe('init', () => {
|
|
|
4211
4211
|
expect(io.outLines.join('\n')).not.toContain('All set.')
|
|
4212
4212
|
|
|
4213
4213
|
io.outLines = []
|
|
4214
|
-
expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
|
|
4214
|
+
expect(await initCommand(deps, { setupScope: 'project', skills: false })).toBe(EXIT.failed)
|
|
4215
4215
|
// Idempotent: the second run re-derives the same slug and does not reprint
|
|
4216
4216
|
// doctor's ready-state dump.
|
|
4217
4217
|
expect(io.outLines.join('\n')).toMatch(/^Next:/m)
|
|
@@ -4253,7 +4253,13 @@ describe('init', () => {
|
|
|
4253
4253
|
env: isolatedEnv(cwd),
|
|
4254
4254
|
}
|
|
4255
4255
|
|
|
4256
|
-
expect(
|
|
4256
|
+
expect(
|
|
4257
|
+
await initCommand(deps, {
|
|
4258
|
+
hooks: false,
|
|
4259
|
+
setupScope: 'project',
|
|
4260
|
+
skills: false,
|
|
4261
|
+
}),
|
|
4262
|
+
).toBe(EXIT.failed)
|
|
4257
4263
|
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
4258
4264
|
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
4259
4265
|
expect(io.outros).toEqual([])
|
|
@@ -4264,10 +4270,16 @@ describe('init', () => {
|
|
|
4264
4270
|
io.errLines = []
|
|
4265
4271
|
io.outros = []
|
|
4266
4272
|
|
|
4267
|
-
expect(
|
|
4273
|
+
expect(
|
|
4274
|
+
await initCommand(deps, {
|
|
4275
|
+
hooks: false,
|
|
4276
|
+
setupScope: 'project',
|
|
4277
|
+
skills: false,
|
|
4278
|
+
}),
|
|
4279
|
+
).toBe(EXIT.ok)
|
|
4268
4280
|
expect(submissions).toBe(1)
|
|
4269
4281
|
expect(io.outLines).toContain(
|
|
4270
|
-
'All set. Agents in this project can notify you. Questions
|
|
4282
|
+
'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
4271
4283
|
)
|
|
4272
4284
|
expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
|
|
4273
4285
|
expect(io.errLines).toEqual([])
|
|
@@ -4302,22 +4314,31 @@ describe('init', () => {
|
|
|
4302
4314
|
const io = new CapturedIo()
|
|
4303
4315
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4304
4316
|
|
|
4305
|
-
expect(
|
|
4317
|
+
expect(
|
|
4318
|
+
await initCommand(deps, {
|
|
4319
|
+
projectId: 'Custom Name',
|
|
4320
|
+
setupScope: 'project',
|
|
4321
|
+
skills: false,
|
|
4322
|
+
}),
|
|
4323
|
+
).toBe(EXIT.failed)
|
|
4306
4324
|
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain(
|
|
4307
4325
|
'project = "custom-name"',
|
|
4308
4326
|
)
|
|
4309
4327
|
})
|
|
4310
4328
|
|
|
4311
|
-
it('
|
|
4312
|
-
// An agent's init must not reach for npx or a prompt.
|
|
4329
|
+
it('does not let an unattended readiness pass silently choose project scope', async () => {
|
|
4313
4330
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
|
|
4314
4331
|
const io = new CapturedIo()
|
|
4315
4332
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4316
4333
|
|
|
4317
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4334
|
+
expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
|
|
4335
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
|
|
4336
|
+
const result = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4337
|
+
states: Array<{ id: string; status: string }>
|
|
4338
|
+
}
|
|
4339
|
+
expect(result.states.find((state) => state.id === 'project')).toMatchObject({
|
|
4340
|
+
status: 'optional-gap',
|
|
4341
|
+
})
|
|
4321
4342
|
expect(io.errLines).toEqual([])
|
|
4322
4343
|
})
|
|
4323
4344
|
|
|
@@ -4765,7 +4786,7 @@ describe('init', () => {
|
|
|
4765
4786
|
list: async () => ({ skills: [] }),
|
|
4766
4787
|
}
|
|
4767
4788
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
4768
|
-
await initCommand(deps, { hooks: true, skills: false })
|
|
4789
|
+
await initCommand(deps, { hooks: true, setupScope: 'project', skills: false })
|
|
4769
4790
|
const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
|
|
4770
4791
|
(installation) => installation.harness,
|
|
4771
4792
|
)
|
|
@@ -4773,8 +4794,9 @@ describe('init', () => {
|
|
|
4773
4794
|
expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
|
|
4774
4795
|
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
4775
4796
|
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
|
|
4776
|
-
expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
|
|
4777
|
-
expect(io.outLines.join('\n')).toContain('Installed codex hooks')
|
|
4797
|
+
expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
|
|
4798
|
+
expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
|
|
4799
|
+
expect(io.outLines.join('\n')).toContain('Next: Codex hook trust')
|
|
4778
4800
|
})
|
|
4779
4801
|
|
|
4780
4802
|
it('lets a human keep a subset of the detected harnesses', async () => {
|
|
@@ -4825,7 +4847,7 @@ describe('init', () => {
|
|
|
4825
4847
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4826
4848
|
}
|
|
4827
4849
|
|
|
4828
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4850
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4829
4851
|
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
4830
4852
|
})
|
|
4831
4853
|
|
|
@@ -4854,7 +4876,7 @@ describe('init', () => {
|
|
|
4854
4876
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4855
4877
|
}
|
|
4856
4878
|
|
|
4857
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4879
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
4858
4880
|
expect(asked.some((q) => q.includes('Sign in'))).toBe(false)
|
|
4859
4881
|
const out = io.outLines.join('\n')
|
|
4860
4882
|
expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
@@ -4949,7 +4971,7 @@ describe('init', () => {
|
|
|
4949
4971
|
} satisfies NativeSkills,
|
|
4950
4972
|
}
|
|
4951
4973
|
|
|
4952
|
-
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
4974
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.ok)
|
|
4953
4975
|
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/hooks/)]))
|
|
4954
4976
|
expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/skill/)]))
|
|
4955
4977
|
const out = io.outLines.join('\n')
|
|
@@ -5026,7 +5048,13 @@ describe('init', () => {
|
|
|
5026
5048
|
},
|
|
5027
5049
|
}
|
|
5028
5050
|
|
|
5029
|
-
expect(
|
|
5051
|
+
expect(
|
|
5052
|
+
await initCommand(deps, {
|
|
5053
|
+
hooks: false,
|
|
5054
|
+
setupScope: 'project',
|
|
5055
|
+
skills: false,
|
|
5056
|
+
}),
|
|
5057
|
+
).toBe(EXIT.ok)
|
|
5030
5058
|
expect(io.prompts).toEqual([
|
|
5031
5059
|
'Open install instructions in your browser?',
|
|
5032
5060
|
'Wait here while you finish that on your device?',
|
|
@@ -5049,7 +5077,13 @@ describe('init', () => {
|
|
|
5049
5077
|
io.outLines = []
|
|
5050
5078
|
io.prompts = []
|
|
5051
5079
|
io.openedUrls = []
|
|
5052
|
-
expect(
|
|
5080
|
+
expect(
|
|
5081
|
+
await initCommand(deps, {
|
|
5082
|
+
hooks: false,
|
|
5083
|
+
setupScope: 'project',
|
|
5084
|
+
skills: false,
|
|
5085
|
+
}),
|
|
5086
|
+
).toBe(EXIT.ok)
|
|
5053
5087
|
expect(submitCalls).toBe(1)
|
|
5054
5088
|
expect(io.prompts).toEqual([])
|
|
5055
5089
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
@@ -5094,7 +5128,13 @@ describe('init', () => {
|
|
|
5094
5128
|
},
|
|
5095
5129
|
}
|
|
5096
5130
|
|
|
5097
|
-
expect(
|
|
5131
|
+
expect(
|
|
5132
|
+
await initCommand(deps, {
|
|
5133
|
+
hooks: false,
|
|
5134
|
+
setupScope: 'project',
|
|
5135
|
+
skills: false,
|
|
5136
|
+
}),
|
|
5137
|
+
).toBe(EXIT.ok)
|
|
5098
5138
|
expect(io.errLines.join('\n')).toContain('wait timer expired')
|
|
5099
5139
|
expect(io.errLines.join('\n')).toMatch(/setup is not finished, only this wait/)
|
|
5100
5140
|
expect(io.prompts.some((q) => q.includes('Keep waiting'))).toBe(true)
|
|
@@ -5130,7 +5170,13 @@ describe('init', () => {
|
|
|
5130
5170
|
env: isolatedEnv(cwd),
|
|
5131
5171
|
}
|
|
5132
5172
|
|
|
5133
|
-
expect(
|
|
5173
|
+
expect(
|
|
5174
|
+
await initCommand(deps, {
|
|
5175
|
+
hooks: false,
|
|
5176
|
+
setupScope: 'project',
|
|
5177
|
+
skills: false,
|
|
5178
|
+
}),
|
|
5179
|
+
).toBe(EXIT.failed)
|
|
5134
5180
|
const out = io.outLines.join('\n')
|
|
5135
5181
|
expect(out).toContain('Next: Your devices')
|
|
5136
5182
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
@@ -5178,7 +5224,13 @@ describe('init', () => {
|
|
|
5178
5224
|
},
|
|
5179
5225
|
}
|
|
5180
5226
|
|
|
5181
|
-
expect(
|
|
5227
|
+
expect(
|
|
5228
|
+
await initCommand(deps, {
|
|
5229
|
+
hooks: false,
|
|
5230
|
+
setupScope: 'project',
|
|
5231
|
+
skills: false,
|
|
5232
|
+
}),
|
|
5233
|
+
).toBe(EXIT.failed)
|
|
5182
5234
|
expect(submitCalls).toBe(1)
|
|
5183
5235
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
5184
5236
|
expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
|
|
@@ -5266,7 +5318,13 @@ describe('init', () => {
|
|
|
5266
5318
|
env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
|
|
5267
5319
|
}
|
|
5268
5320
|
|
|
5269
|
-
await expect(
|
|
5321
|
+
await expect(
|
|
5322
|
+
initCommand(deps, {
|
|
5323
|
+
hooks: false,
|
|
5324
|
+
setupScope: 'project',
|
|
5325
|
+
skills: false,
|
|
5326
|
+
}),
|
|
5327
|
+
).resolves.toBe(EXIT.failed)
|
|
5270
5328
|
expect(submitCalls).toBe(1)
|
|
5271
5329
|
expect(io.errLines.join('\n')).toContain('Could not save setup proof req_unwritable')
|
|
5272
5330
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
@@ -5418,7 +5476,12 @@ describe('init', () => {
|
|
|
5418
5476
|
env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
|
|
5419
5477
|
}
|
|
5420
5478
|
expect(
|
|
5421
|
-
await initCommand(deps, {
|
|
5479
|
+
await initCommand(deps, {
|
|
5480
|
+
projectId: 'shared-project',
|
|
5481
|
+
hooks: false,
|
|
5482
|
+
setupScope: 'project',
|
|
5483
|
+
skills: false,
|
|
5484
|
+
}),
|
|
5422
5485
|
).toBe(EXIT.ok)
|
|
5423
5486
|
}
|
|
5424
5487
|
|
|
@@ -5500,7 +5563,7 @@ describe('readiness assessment cost', () => {
|
|
|
5500
5563
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
5501
5564
|
}
|
|
5502
5565
|
|
|
5503
|
-
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
5566
|
+
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
5504
5567
|
expect(healthCalls).toBe(1)
|
|
5505
5568
|
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project =')
|
|
5506
5569
|
})
|