@raidiant/notifai 0.1.7 → 0.1.8
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/README.md +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/src/commands.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
13
13
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
14
14
|
import {
|
|
15
15
|
askCommand,
|
|
16
|
+
accessStatusCommand,
|
|
16
17
|
assessReadiness,
|
|
17
18
|
capabilitiesCommand,
|
|
18
19
|
configSetCommand,
|
|
@@ -34,10 +35,13 @@ import {
|
|
|
34
35
|
type CommandSpinner,
|
|
35
36
|
} from './commands.js'
|
|
36
37
|
import { applyPlan, buildHookConfig } from './install-hooks.js'
|
|
38
|
+
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
39
|
+
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
37
40
|
|
|
38
41
|
class CapturedIo implements CommandIo {
|
|
39
42
|
outLines: string[] = []
|
|
40
43
|
errLines: string[] = []
|
|
44
|
+
openedUrls: string[] = []
|
|
41
45
|
|
|
42
46
|
out(line: string) {
|
|
43
47
|
this.outLines.push(line)
|
|
@@ -51,12 +55,16 @@ class CapturedIo implements CommandIo {
|
|
|
51
55
|
return false
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
openUrl() {
|
|
58
|
+
openUrl(url: string) {
|
|
59
|
+
this.openedUrls.push(url)
|
|
60
|
+
}
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
class InteractiveIo extends CapturedIo {
|
|
58
64
|
interactive = true
|
|
59
65
|
selectAnswer: string | null = 'global'
|
|
66
|
+
/** When set, answers are consumed in order; falls back to confirmAnswer. */
|
|
67
|
+
confirmAnswers: boolean[] | null = null
|
|
60
68
|
confirmAnswer = true
|
|
61
69
|
prompts: string[] = []
|
|
62
70
|
notes: { message: string; title?: string }[] = []
|
|
@@ -67,6 +75,9 @@ class InteractiveIo extends CapturedIo {
|
|
|
67
75
|
|
|
68
76
|
override async confirm(question: string) {
|
|
69
77
|
this.prompts.push(question)
|
|
78
|
+
if (this.confirmAnswers !== null && this.confirmAnswers.length > 0) {
|
|
79
|
+
return this.confirmAnswers.shift()!
|
|
80
|
+
}
|
|
70
81
|
return this.confirmAnswer
|
|
71
82
|
}
|
|
72
83
|
|
|
@@ -163,6 +174,23 @@ function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
|
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
describe('command contracts', () => {
|
|
177
|
+
it('shows an actionable no-plan access state', async () => {
|
|
178
|
+
const io = new CapturedIo()
|
|
179
|
+
const client = {
|
|
180
|
+
accessStatus: async () => ({
|
|
181
|
+
status: 'no_active_plan',
|
|
182
|
+
reason: 'no_active_grant',
|
|
183
|
+
expires_at: null,
|
|
184
|
+
}),
|
|
185
|
+
} as unknown as ApiClient
|
|
186
|
+
|
|
187
|
+
expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
|
|
188
|
+
expect(io.outLines).toEqual([
|
|
189
|
+
'No active plan or temporary Alpha access for this account.',
|
|
190
|
+
'next: Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
|
|
166
194
|
it('renders capability field paths instead of array indexes', async () => {
|
|
167
195
|
const io = new CapturedIo()
|
|
168
196
|
const document: CapabilityDocument = {
|
|
@@ -217,9 +245,91 @@ describe('command contracts', () => {
|
|
|
217
245
|
},
|
|
218
246
|
} as unknown as ApiClient
|
|
219
247
|
|
|
220
|
-
expect(
|
|
248
|
+
expect(
|
|
249
|
+
await sendCommand(makeDeps(io, client), {
|
|
250
|
+
title: 'T',
|
|
251
|
+
body: 'B',
|
|
252
|
+
project: 'Invalid Project!',
|
|
253
|
+
}),
|
|
254
|
+
).toBe(EXIT.usage)
|
|
221
255
|
expect(submitCalls).toBe(0)
|
|
222
|
-
expect(io.errLines.join('\n')).toContain('
|
|
256
|
+
expect(io.errLines.join('\n')).toContain('project')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('rejects done plus reply before authentication or submission', async () => {
|
|
260
|
+
const io = new CapturedIo()
|
|
261
|
+
let submitCalls = 0
|
|
262
|
+
const client = {
|
|
263
|
+
submit: async () => {
|
|
264
|
+
submitCalls += 1
|
|
265
|
+
return receipt
|
|
266
|
+
},
|
|
267
|
+
} as unknown as ApiClient
|
|
268
|
+
|
|
269
|
+
expect(
|
|
270
|
+
await sendCommand(makeDeps(io, client), {
|
|
271
|
+
title: 'Done',
|
|
272
|
+
body: 'Answer?',
|
|
273
|
+
kind: 'done',
|
|
274
|
+
reply: true,
|
|
275
|
+
}),
|
|
276
|
+
).toBe(EXIT.usage)
|
|
277
|
+
expect(submitCalls).toBe(0)
|
|
278
|
+
expect(io.errLines.join('\n')).toContain('--kind done cannot be combined with --reply')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it.each([
|
|
282
|
+
{
|
|
283
|
+
flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
284
|
+
warning: /titles work best around 40/i,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
flags: { title: 'Details', body: '**bold Markdown**' },
|
|
288
|
+
warning: /--body looks like Markdown.*--detail/i,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
flags: { title: 'Done · build', body: 'All green.' },
|
|
292
|
+
warning: /Use --kind done/i,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
flags: { title: 'Failed · build', body: 'One integration test failed.' },
|
|
296
|
+
warning: /Use --kind done/i,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
300
|
+
warning: /longer than 72 hours/i,
|
|
301
|
+
},
|
|
302
|
+
])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
|
|
303
|
+
const io = new CapturedIo()
|
|
304
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
305
|
+
|
|
306
|
+
expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
|
|
307
|
+
expect(io.errLines.join('\n')).toMatch(warning)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('warns when a collapse key comes from machine-global config', async () => {
|
|
311
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-global-collapse-'))
|
|
312
|
+
const configDir = path.join(root, 'notifai')
|
|
313
|
+
mkdirSync(configDir, { recursive: true })
|
|
314
|
+
writeFileSync(path.join(configDir, 'config.toml'), 'collapse_key = "global-key"\n')
|
|
315
|
+
const io = new CapturedIo()
|
|
316
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
317
|
+
const deps = {
|
|
318
|
+
...makeDeps(io, client),
|
|
319
|
+
env: { XDG_CONFIG_HOME: root },
|
|
320
|
+
cwd: root,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
|
|
324
|
+
expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('removes unmanaged provider fields from the public send flags', () => {
|
|
328
|
+
const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
|
|
329
|
+
expect(source).not.toContain(".option('--badge")
|
|
330
|
+
expect(source).not.toContain(".option('--relevance")
|
|
331
|
+
expect(source).not.toContain(".option('--target-content-id")
|
|
332
|
+
expect(source).toContain('Kind profiles')
|
|
223
333
|
})
|
|
224
334
|
|
|
225
335
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -431,12 +541,13 @@ describe('command contracts', () => {
|
|
|
431
541
|
expect(sleeps).toEqual([250])
|
|
432
542
|
})
|
|
433
543
|
|
|
434
|
-
it('
|
|
544
|
+
it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
|
|
435
545
|
const io = new CapturedIo()
|
|
436
546
|
let now = 0
|
|
437
547
|
const client = {
|
|
438
548
|
submit: async () => receipt,
|
|
439
549
|
replies: async (_requestId: string, options: { waitSeconds: number }) => {
|
|
550
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
|
|
440
551
|
now += options.waitSeconds * 1_000
|
|
441
552
|
return replyResponse()
|
|
442
553
|
},
|
|
@@ -452,13 +563,20 @@ describe('command contracts', () => {
|
|
|
452
563
|
json: true,
|
|
453
564
|
}),
|
|
454
565
|
).toBe(EXIT.noReply)
|
|
455
|
-
expect(io.outLines).toHaveLength(
|
|
566
|
+
expect(io.outLines).toHaveLength(2)
|
|
456
567
|
// `degraded` is part of the shape on every reply wait, not only when it is
|
|
457
568
|
// true: an agent must be able to read it without knowing it might be absent.
|
|
458
|
-
expect(JSON.parse(io.outLines[
|
|
569
|
+
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
570
|
+
type: 'reply_result',
|
|
571
|
+
request_id: receipt.request_id,
|
|
572
|
+
replies: [],
|
|
573
|
+
degraded: false,
|
|
574
|
+
})
|
|
575
|
+
expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
|
|
576
|
+
expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
|
|
459
577
|
})
|
|
460
578
|
|
|
461
|
-
it('prints the
|
|
579
|
+
it('prints the receipt and reply result as two NDJSON records when answered', async () => {
|
|
462
580
|
const io = new CapturedIo()
|
|
463
581
|
const client = {
|
|
464
582
|
submit: async () => receipt,
|
|
@@ -474,9 +592,11 @@ describe('command contracts', () => {
|
|
|
474
592
|
json: true,
|
|
475
593
|
}),
|
|
476
594
|
).toBe(EXIT.ok)
|
|
477
|
-
expect(io.outLines).toHaveLength(
|
|
478
|
-
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
479
|
-
|
|
595
|
+
expect(io.outLines).toHaveLength(2)
|
|
596
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
|
|
597
|
+
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
598
|
+
type: 'reply_result',
|
|
599
|
+
request_id: receipt.request_id,
|
|
480
600
|
replies: [reply],
|
|
481
601
|
degraded: false,
|
|
482
602
|
})
|
|
@@ -496,6 +616,30 @@ describe('command contracts', () => {
|
|
|
496
616
|
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
497
617
|
expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
|
|
498
618
|
})
|
|
619
|
+
|
|
620
|
+
it('resolves replies --pending through the project session pointer and prints its id', async () => {
|
|
621
|
+
const io = new CapturedIo()
|
|
622
|
+
const client = {
|
|
623
|
+
replies: async () => replyResponse([reply]),
|
|
624
|
+
} as unknown as ApiClient
|
|
625
|
+
const deps = makeDeps(io, client)
|
|
626
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-replies-'))
|
|
627
|
+
deps.cwd = root
|
|
628
|
+
deps.env = {
|
|
629
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
630
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
631
|
+
}
|
|
632
|
+
writeSessionState('pending-session', deps.env, {
|
|
633
|
+
pending: { question: 'Deploy?', request_id: receipt.request_id },
|
|
634
|
+
})
|
|
635
|
+
writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
|
|
636
|
+
|
|
637
|
+
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
638
|
+
expect(io.outLines).toEqual([
|
|
639
|
+
`pending request ${receipt.request_id}`,
|
|
640
|
+
'reply from iPhone: yes, after the migration',
|
|
641
|
+
])
|
|
642
|
+
})
|
|
499
643
|
})
|
|
500
644
|
|
|
501
645
|
describe('delivery evidence status', () => {
|
|
@@ -549,7 +693,7 @@ describe('delivery evidence status', () => {
|
|
|
549
693
|
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
550
694
|
const said = io.outLines.join('\n')
|
|
551
695
|
expect(said).toContain('Provider Acceptance: accepted')
|
|
552
|
-
expect(said).toContain(
|
|
696
|
+
expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
|
|
553
697
|
expect(said).toContain('not a failure')
|
|
554
698
|
expect(said).toContain('attempt_started')
|
|
555
699
|
})
|
|
@@ -567,7 +711,7 @@ describe('delivery evidence status', () => {
|
|
|
567
711
|
|
|
568
712
|
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
569
713
|
const said = io.outLines.join('\n')
|
|
570
|
-
expect(said).toContain(
|
|
714
|
+
expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
|
|
571
715
|
expect(said).toContain('11m 27s after Provider Acceptance')
|
|
572
716
|
})
|
|
573
717
|
})
|
|
@@ -576,7 +720,7 @@ describe('Cursor hook commands', () => {
|
|
|
576
720
|
const execPath = '/usr/local/bin/node'
|
|
577
721
|
const scriptPath = '/opt/notifai/dist/main.js'
|
|
578
722
|
|
|
579
|
-
it('installs native Cursor hooks with
|
|
723
|
+
it('installs native Cursor hooks with bounded chained answer continuations', () => {
|
|
580
724
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
581
725
|
const io = new CapturedIo()
|
|
582
726
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
@@ -602,7 +746,7 @@ describe('Cursor hook commands', () => {
|
|
|
602
746
|
)
|
|
603
747
|
expect(installed.hooks['stop']?.[0]).toMatchObject({
|
|
604
748
|
command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
|
|
605
|
-
loop_limit:
|
|
749
|
+
loop_limit: 3,
|
|
606
750
|
})
|
|
607
751
|
})
|
|
608
752
|
|
|
@@ -638,7 +782,7 @@ describe('Cursor hook commands', () => {
|
|
|
638
782
|
expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
|
|
639
783
|
})
|
|
640
784
|
|
|
641
|
-
it('uninstalls only
|
|
785
|
+
it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
|
|
642
786
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-uninstall-'))
|
|
643
787
|
const io = new CapturedIo()
|
|
644
788
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
@@ -714,7 +858,7 @@ describe('harness activation guidance', () => {
|
|
|
714
858
|
)
|
|
715
859
|
|
|
716
860
|
expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
|
|
717
|
-
expect(io.outLines.join('\n')).toContain('
|
|
861
|
+
expect(io.outLines.join('\n')).toContain('next user prompt')
|
|
718
862
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
719
863
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
720
864
|
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
@@ -735,7 +879,7 @@ describe('harness activation guidance', () => {
|
|
|
735
879
|
describe('projectSlugFrom', () => {
|
|
736
880
|
it('canonicalizes directory names into contract-valid slugs', () => {
|
|
737
881
|
expect(projectSlugFrom('My App')).toBe('my-app')
|
|
738
|
-
expect(projectSlugFrom('
|
|
882
|
+
expect(projectSlugFrom('Notifai')).toBe('notifai')
|
|
739
883
|
expect(projectSlugFrom('--weird__Name.2')).toBe('weird__name.2')
|
|
740
884
|
expect(projectSlugFrom('!!!')).toBe('project')
|
|
741
885
|
})
|
|
@@ -777,7 +921,7 @@ describe('interactive command UX', () => {
|
|
|
777
921
|
}
|
|
778
922
|
|
|
779
923
|
expect(await loginCommand(deps, { name: 'workstation', open: false })).toBe(EXIT.ok)
|
|
780
|
-
expect(io.intros).toEqual(['
|
|
924
|
+
expect(io.intros).toEqual(['Notifai sign in'])
|
|
781
925
|
expect(io.notes).toEqual([
|
|
782
926
|
{
|
|
783
927
|
title: 'Approve this machine',
|
|
@@ -785,8 +929,8 @@ describe('interactive command UX', () => {
|
|
|
785
929
|
},
|
|
786
930
|
])
|
|
787
931
|
expect(io.spinnerEvents).toEqual([
|
|
788
|
-
'start:Waiting for approval…',
|
|
789
|
-
'message:Waiting for approval…',
|
|
932
|
+
'start:Waiting for approval… code ABCD-EFGH · 10s left',
|
|
933
|
+
'message:Waiting for approval… code ABCD-EFGH · 9s left',
|
|
790
934
|
'stop:Machine "workstation" approved',
|
|
791
935
|
])
|
|
792
936
|
expect(io.outLines).toEqual([])
|
|
@@ -899,7 +1043,7 @@ describe('interactive command UX', () => {
|
|
|
899
1043
|
}
|
|
900
1044
|
|
|
901
1045
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
902
|
-
expect(io.intros).toEqual(['
|
|
1046
|
+
expect(io.intros).toEqual(['Notifai doctor'])
|
|
903
1047
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
904
1048
|
expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
|
|
905
1049
|
expect(io.outLines).toEqual([])
|
|
@@ -995,6 +1139,47 @@ describe('init', () => {
|
|
|
995
1139
|
}
|
|
996
1140
|
}
|
|
997
1141
|
|
|
1142
|
+
function managedSkill(scope: SkillScope, cwd: string): NativeSkill {
|
|
1143
|
+
return {
|
|
1144
|
+
name: 'notifai',
|
|
1145
|
+
scope,
|
|
1146
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
1147
|
+
source: 'Raidiant-io/notifai',
|
|
1148
|
+
sourceType: 'github',
|
|
1149
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1150
|
+
ref: 'v0.1.8',
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function setupReadyDeps(
|
|
1155
|
+
io: CapturedIo,
|
|
1156
|
+
cwd: string,
|
|
1157
|
+
nativeSkills: NativeSkills,
|
|
1158
|
+
calls: { submit: number },
|
|
1159
|
+
): CommandDeps {
|
|
1160
|
+
const client = {
|
|
1161
|
+
health: async () => true,
|
|
1162
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1163
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
1164
|
+
submit: async () => {
|
|
1165
|
+
calls.submit += 1
|
|
1166
|
+
return setupReceipt()
|
|
1167
|
+
},
|
|
1168
|
+
evidence: async () =>
|
|
1169
|
+
setupEvidence('req_setup', {
|
|
1170
|
+
state: 'observed',
|
|
1171
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
1172
|
+
latency_ms: 1_000,
|
|
1173
|
+
}),
|
|
1174
|
+
} as unknown as ApiClient
|
|
1175
|
+
return {
|
|
1176
|
+
...makeDeps(io, client),
|
|
1177
|
+
cwd,
|
|
1178
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1179
|
+
nativeSkills,
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
998
1183
|
it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
|
|
999
1184
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
|
|
1000
1185
|
const io = new CapturedIo()
|
|
@@ -1033,6 +1218,7 @@ describe('init', () => {
|
|
|
1033
1218
|
const out = io.outLines.join('\n')
|
|
1034
1219
|
expect(out).toContain('Next: This machine')
|
|
1035
1220
|
expect(out).toContain('notifai login')
|
|
1221
|
+
expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
|
|
1036
1222
|
// The device gap is real and downstream; it must stay hidden until the
|
|
1037
1223
|
// sign-in that would let anyone actually check it has happened.
|
|
1038
1224
|
expect(out).not.toContain('companion app')
|
|
@@ -1064,45 +1250,201 @@ describe('init', () => {
|
|
|
1064
1250
|
})
|
|
1065
1251
|
|
|
1066
1252
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1067
|
-
expect(SKILLS_SOURCE).toBe('
|
|
1253
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.1.8')
|
|
1068
1254
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1069
1255
|
})
|
|
1070
1256
|
|
|
1071
1257
|
it('recognizes a skill installed from the exact immutable release', async () => {
|
|
1072
1258
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-pinned-skill-'))
|
|
1073
|
-
mkdirSync(path.join(cwd, '.agents', 'skills', 'notifai'), { recursive: true })
|
|
1074
|
-
writeFileSync(path.join(cwd, '.agents', 'skills', 'notifai', 'SKILL.md'), '# notifai\n')
|
|
1075
|
-
writeFileSync(
|
|
1076
|
-
path.join(cwd, 'skills-lock.json'),
|
|
1077
|
-
`${JSON.stringify(
|
|
1078
|
-
{
|
|
1079
|
-
version: 1,
|
|
1080
|
-
skills: {
|
|
1081
|
-
notifai: {
|
|
1082
|
-
source: 'RafaelVidaurre/notifai',
|
|
1083
|
-
ref: 'v0.1.7',
|
|
1084
|
-
sourceType: 'github',
|
|
1085
|
-
skillPath: 'skills/notifai/SKILL.md',
|
|
1086
|
-
},
|
|
1087
|
-
},
|
|
1088
|
-
},
|
|
1089
|
-
null,
|
|
1090
|
-
2,
|
|
1091
|
-
)}\n`,
|
|
1092
|
-
)
|
|
1093
1259
|
const io = new CapturedIo()
|
|
1094
1260
|
const client = {
|
|
1095
1261
|
health: async () => true,
|
|
1096
1262
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1097
1263
|
listDevices: async () => ({ devices: [] }),
|
|
1098
1264
|
} as unknown as ApiClient
|
|
1099
|
-
const
|
|
1265
|
+
const nativeSkills: NativeSkills = {
|
|
1266
|
+
add: async () => 0,
|
|
1267
|
+
list: async (scope) => ({
|
|
1268
|
+
skills: [
|
|
1269
|
+
{
|
|
1270
|
+
name: 'notifai',
|
|
1271
|
+
scope,
|
|
1272
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
1273
|
+
source: 'Raidiant-io/notifai',
|
|
1274
|
+
sourceType: 'github',
|
|
1275
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1276
|
+
ref: 'v0.1.8',
|
|
1277
|
+
},
|
|
1278
|
+
],
|
|
1279
|
+
}),
|
|
1280
|
+
}
|
|
1281
|
+
const readiness = await assessReadiness(
|
|
1282
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
1283
|
+
{ skillScope: 'project' },
|
|
1284
|
+
)
|
|
1100
1285
|
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
1101
1286
|
status: 'ready',
|
|
1102
|
-
detail: `installed from ${SKILLS_SOURCE} in
|
|
1287
|
+
detail: `installed from ${SKILLS_SOURCE} in the project scope`,
|
|
1103
1288
|
})
|
|
1104
1289
|
})
|
|
1105
1290
|
|
|
1291
|
+
it.each(['project', 'global'] as const)(
|
|
1292
|
+
'recognizes native installer provenance in the selected %s scope',
|
|
1293
|
+
async (scope) => {
|
|
1294
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-managed-${scope}-`))
|
|
1295
|
+
const io = new CapturedIo()
|
|
1296
|
+
const client = {
|
|
1297
|
+
health: async () => true,
|
|
1298
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1299
|
+
listDevices: async () => ({ devices: [] }),
|
|
1300
|
+
} as unknown as ApiClient
|
|
1301
|
+
const calls: SkillScope[] = []
|
|
1302
|
+
const nativeSkills: NativeSkills = {
|
|
1303
|
+
add: async () => 0,
|
|
1304
|
+
list: async (selected) => {
|
|
1305
|
+
calls.push(selected)
|
|
1306
|
+
return { skills: [managedSkill(selected, cwd)] }
|
|
1307
|
+
},
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const readiness = await assessReadiness(
|
|
1311
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
1312
|
+
{ skillScope: scope },
|
|
1313
|
+
)
|
|
1314
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
1315
|
+
status: 'ready',
|
|
1316
|
+
detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
|
|
1317
|
+
})
|
|
1318
|
+
expect(calls).toEqual([scope])
|
|
1319
|
+
},
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
it.each(['project', 'global'] as const)(
|
|
1323
|
+
'does not trust unmanaged same-path content in the %s scope',
|
|
1324
|
+
async (scope) => {
|
|
1325
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
|
|
1326
|
+
const io = new CapturedIo()
|
|
1327
|
+
const client = {
|
|
1328
|
+
health: async () => true,
|
|
1329
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1330
|
+
listDevices: async () => ({ devices: [] }),
|
|
1331
|
+
} as unknown as ApiClient
|
|
1332
|
+
const nativeSkills: NativeSkills = {
|
|
1333
|
+
add: async () => 0,
|
|
1334
|
+
list: async (selected) => ({
|
|
1335
|
+
skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
|
|
1336
|
+
}),
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const readiness = await assessReadiness(
|
|
1340
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
1341
|
+
{ skillScope: scope },
|
|
1342
|
+
)
|
|
1343
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
1344
|
+
status: 'optional-gap',
|
|
1345
|
+
detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
|
|
1346
|
+
})
|
|
1347
|
+
},
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
it('requires an explicit skill scope before unattended installation', async () => {
|
|
1351
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
|
|
1352
|
+
const io = new CapturedIo()
|
|
1353
|
+
let addCalls = 0
|
|
1354
|
+
const nativeSkills: NativeSkills = {
|
|
1355
|
+
add: async () => {
|
|
1356
|
+
addCalls += 1
|
|
1357
|
+
return 0
|
|
1358
|
+
},
|
|
1359
|
+
list: async () => ({ skills: [] }),
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
expect(await initCommand({ ...makeDeps(io, {} as ApiClient), cwd, nativeSkills }, { skills: true })).toBe(
|
|
1363
|
+
EXIT.usage,
|
|
1364
|
+
)
|
|
1365
|
+
expect(addCalls).toBe(0)
|
|
1366
|
+
expect(io.errLines.join('\n')).toContain('--skills-scope project')
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
it('rejects an invalid unattended skill scope instead of guessing', async () => {
|
|
1370
|
+
const io = new CapturedIo()
|
|
1371
|
+
expect(
|
|
1372
|
+
await initCommand(
|
|
1373
|
+
{ ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-skill-invalid-')) },
|
|
1374
|
+
{ skills: true, skillsScope: 'machine' as SkillScope },
|
|
1375
|
+
),
|
|
1376
|
+
).toBe(EXIT.usage)
|
|
1377
|
+
expect(io.errLines.join('\n')).toContain('Choose `project` or `global`')
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
it.each(['project', 'global'] as const)(
|
|
1381
|
+
'passes an unattended %s choice to the native installer and continues setup',
|
|
1382
|
+
async (scope) => {
|
|
1383
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-skill-${scope}-`))
|
|
1384
|
+
const io = new CapturedIo()
|
|
1385
|
+
const calls: { submit: number } = { submit: 0 }
|
|
1386
|
+
let installed = false
|
|
1387
|
+
let receivedScope: SkillScope | undefined
|
|
1388
|
+
const nativeSkills: NativeSkills = {
|
|
1389
|
+
add: async (options) => {
|
|
1390
|
+
receivedScope = options.scope
|
|
1391
|
+
installed = true
|
|
1392
|
+
return 0
|
|
1393
|
+
},
|
|
1394
|
+
list: async (selected) => ({
|
|
1395
|
+
skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
|
|
1396
|
+
}),
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const result = await initCommand(
|
|
1400
|
+
setupReadyDeps(io, cwd, nativeSkills, calls),
|
|
1401
|
+
{ skills: true, skillsScope: scope, hooks: false },
|
|
1402
|
+
)
|
|
1403
|
+
expect(result).toBe(EXIT.ok)
|
|
1404
|
+
expect(receivedScope).toBe(scope)
|
|
1405
|
+
expect(calls.submit).toBe(1)
|
|
1406
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1407
|
+
},
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
it('lets the native interactive flow choose scope and resumes after cancellation', async () => {
|
|
1411
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-cancelled-'))
|
|
1412
|
+
const io = new InteractiveIo()
|
|
1413
|
+
const calls: { submit: number } = { submit: 0 }
|
|
1414
|
+
let receivedScope: SkillScope | undefined = 'global'
|
|
1415
|
+
const nativeSkills: NativeSkills = {
|
|
1416
|
+
add: async (options) => {
|
|
1417
|
+
receivedScope = options.scope
|
|
1418
|
+
return 0
|
|
1419
|
+
},
|
|
1420
|
+
list: async () => ({ skills: [] }),
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
|
|
1424
|
+
EXIT.ok,
|
|
1425
|
+
)
|
|
1426
|
+
expect(receivedScope).toBeUndefined()
|
|
1427
|
+
expect(calls.submit).toBe(1)
|
|
1428
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
it('reports an optional native installer failure without blocking remaining setup', async () => {
|
|
1432
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
|
|
1433
|
+
const io = new InteractiveIo()
|
|
1434
|
+
const calls: { submit: number } = { submit: 0 }
|
|
1435
|
+
const nativeSkills: NativeSkills = {
|
|
1436
|
+
add: async () => 1,
|
|
1437
|
+
list: async () => ({ skills: [] }),
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
|
|
1441
|
+
EXIT.failed,
|
|
1442
|
+
)
|
|
1443
|
+
expect(calls.submit).toBe(1)
|
|
1444
|
+
expect(io.errLines.join('\n')).toContain('Skill installation failed')
|
|
1445
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1106
1448
|
it('tells the user what only they can do when nothing is signed in', async () => {
|
|
1107
1449
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-nocred-'))
|
|
1108
1450
|
const io = new CapturedIo()
|
|
@@ -1116,7 +1458,10 @@ describe('init', () => {
|
|
|
1116
1458
|
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
1117
1459
|
})
|
|
1118
1460
|
|
|
1119
|
-
it('
|
|
1461
|
+
it('announces sign-in for a present human without re-confirming', async () => {
|
|
1462
|
+
// Running `init` is the consent; re-asking "Sign in now?" is noise.
|
|
1463
|
+
// Ctrl-C is the escape hatch (announced). Here beginPairing fails so we
|
|
1464
|
+
// still end on the credential Next line with a re-run hint.
|
|
1120
1465
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-human-'))
|
|
1121
1466
|
const asked: string[] = []
|
|
1122
1467
|
const io = new (class extends CapturedIo {
|
|
@@ -1126,16 +1471,24 @@ describe('init', () => {
|
|
|
1126
1471
|
return false
|
|
1127
1472
|
}
|
|
1128
1473
|
})()
|
|
1474
|
+
const client = {
|
|
1475
|
+
health: async () => true,
|
|
1476
|
+
beginPairing: async () => {
|
|
1477
|
+
throw new NetworkError('offline for test')
|
|
1478
|
+
},
|
|
1479
|
+
} as unknown as ApiClient
|
|
1129
1480
|
const deps: CommandDeps = {
|
|
1130
|
-
...makeDeps(io,
|
|
1481
|
+
...makeDeps(io, client),
|
|
1131
1482
|
cwd,
|
|
1132
1483
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
1133
1484
|
}
|
|
1134
1485
|
|
|
1135
|
-
expect(await initCommand(deps, {})).toBe(EXIT.
|
|
1136
|
-
expect(asked.some((q) => q.includes('Sign in'))).toBe(
|
|
1137
|
-
|
|
1138
|
-
expect(
|
|
1486
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
1487
|
+
expect(asked.some((q) => q.includes('Sign in'))).toBe(false)
|
|
1488
|
+
const out = io.outLines.join('\n')
|
|
1489
|
+
expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
1490
|
+
expect(out).toContain('Next: This machine')
|
|
1491
|
+
expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
|
|
1139
1492
|
})
|
|
1140
1493
|
|
|
1141
1494
|
it('never prompts or opens a browser when an agent runs it unattended', async () => {
|
|
@@ -1167,6 +1520,12 @@ describe('init', () => {
|
|
|
1167
1520
|
health: async () => true,
|
|
1168
1521
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1169
1522
|
listDevices: async () => ({ devices: [] }),
|
|
1523
|
+
accessStatus: async () => ({
|
|
1524
|
+
status: 'active',
|
|
1525
|
+
reason: 'alpha_grant',
|
|
1526
|
+
expires_at: null,
|
|
1527
|
+
email: 'alpha@example.com',
|
|
1528
|
+
}),
|
|
1170
1529
|
} as unknown as ApiClient
|
|
1171
1530
|
const deps = {
|
|
1172
1531
|
...makeDeps(io, client),
|
|
@@ -1177,11 +1536,57 @@ describe('init', () => {
|
|
|
1177
1536
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1178
1537
|
const out = io.outLines.join('\n')
|
|
1179
1538
|
expect(out).toContain('Next: Your devices')
|
|
1180
|
-
expect(out).toContain('
|
|
1181
|
-
expect(out).toContain('
|
|
1539
|
+
expect(out).toContain('https://test.notifai.invalid/support')
|
|
1540
|
+
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
1541
|
+
expect(out).toContain('install Notifai on iPhone or Mac via https://test.notifai.invalid/support')
|
|
1182
1542
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
1183
1543
|
})
|
|
1184
1544
|
|
|
1545
|
+
it('offers hooks and skill before stopping at a phone-less device gap', async () => {
|
|
1546
|
+
// P2.1: optionals that work without a phone must not be trapped behind the
|
|
1547
|
+
// user-elsewhere device blocker.
|
|
1548
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-optionals-first-'))
|
|
1549
|
+
const asked: string[] = []
|
|
1550
|
+
const io = new (class extends CapturedIo {
|
|
1551
|
+
interactive = true
|
|
1552
|
+
override async confirm(question: string) {
|
|
1553
|
+
asked.push(question)
|
|
1554
|
+
return false
|
|
1555
|
+
}
|
|
1556
|
+
})()
|
|
1557
|
+
const client = {
|
|
1558
|
+
health: async () => true,
|
|
1559
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1560
|
+
listDevices: async () => ({ devices: [] }),
|
|
1561
|
+
accessStatus: async () => ({
|
|
1562
|
+
status: 'active',
|
|
1563
|
+
reason: 'alpha_grant',
|
|
1564
|
+
expires_at: null,
|
|
1565
|
+
email: 'alpha@example.com',
|
|
1566
|
+
}),
|
|
1567
|
+
} as unknown as ApiClient
|
|
1568
|
+
const deps = {
|
|
1569
|
+
...makeDeps(io, client),
|
|
1570
|
+
cwd,
|
|
1571
|
+
env: {
|
|
1572
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
1573
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1574
|
+
CODEX_HOME: path.join(cwd, 'codex'),
|
|
1575
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
1576
|
+
},
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
1580
|
+
expect(asked.some((q) => q.includes('hooks'))).toBe(true)
|
|
1581
|
+
expect(asked.some((q) => q.includes('skill'))).toBe(true)
|
|
1582
|
+
const out = io.outLines.join('\n')
|
|
1583
|
+
expect(out).toContain('Next: Your devices')
|
|
1584
|
+
// Device wait prompts only after optionals have been considered.
|
|
1585
|
+
expect(asked.indexOf(asked.find((q) => q.includes('hooks'))!)).toBeLessThan(
|
|
1586
|
+
asked.findIndex((q) => q.includes('Wait here') || q.includes('Open install')),
|
|
1587
|
+
)
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1185
1590
|
it.each([
|
|
1186
1591
|
['denied', 'system settings'],
|
|
1187
1592
|
['not_determined', 'allow its notification prompt'],
|
|
@@ -1219,6 +1624,12 @@ describe('init', () => {
|
|
|
1219
1624
|
health: async () => true,
|
|
1220
1625
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1221
1626
|
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
|
|
1627
|
+
accessStatus: async () => ({
|
|
1628
|
+
status: 'active',
|
|
1629
|
+
reason: 'alpha_grant',
|
|
1630
|
+
expires_at: null,
|
|
1631
|
+
email: 'alpha@example.com',
|
|
1632
|
+
}),
|
|
1222
1633
|
submit: async (draft: SubmitNotificationRequestT) => {
|
|
1223
1634
|
submitCalls += 1
|
|
1224
1635
|
submittedDraft = draft
|
|
@@ -1243,10 +1654,17 @@ describe('init', () => {
|
|
|
1243
1654
|
}
|
|
1244
1655
|
|
|
1245
1656
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1246
|
-
expect(io.prompts).toEqual([
|
|
1657
|
+
expect(io.prompts).toEqual([
|
|
1658
|
+
'Open install instructions in your browser?',
|
|
1659
|
+
'Wait here while you finish that on your device?',
|
|
1660
|
+
])
|
|
1661
|
+
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
1662
|
+
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
1247
1663
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
1248
1664
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
1249
|
-
expect(io.outLines.join('\n')).toContain(
|
|
1665
|
+
expect(io.outLines.join('\n')).toContain(
|
|
1666
|
+
"Companion Receipt (the app's delivery confirmation) observed from iPhone",
|
|
1667
|
+
)
|
|
1250
1668
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1251
1669
|
expect(submitCalls).toBe(1)
|
|
1252
1670
|
expect(submittedDraft?.draft.event).toBe('setup_verified')
|
|
@@ -1254,12 +1672,96 @@ describe('init', () => {
|
|
|
1254
1672
|
|
|
1255
1673
|
io.outLines = []
|
|
1256
1674
|
io.prompts = []
|
|
1675
|
+
io.openedUrls = []
|
|
1257
1676
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1258
1677
|
expect(submitCalls).toBe(1)
|
|
1259
1678
|
expect(io.prompts).toEqual([])
|
|
1260
1679
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1261
1680
|
})
|
|
1262
1681
|
|
|
1682
|
+
it('says the wait timer expired and can keep waiting for a late device', async () => {
|
|
1683
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-keep-waiting-'))
|
|
1684
|
+
const io = new InteractiveIo()
|
|
1685
|
+
// Open browser, wait yes, keep-waiting yes on first expiry.
|
|
1686
|
+
io.confirmAnswers = [true, true, true]
|
|
1687
|
+
let now = 0
|
|
1688
|
+
// Match DEVICE_BRIDGE_TIMEOUT_MS (10 minutes): device appears only after
|
|
1689
|
+
// the first budget has fully elapsed and keep-waiting has restarted.
|
|
1690
|
+
const firstBudgetMs = 10 * 60 * 1000
|
|
1691
|
+
const client = {
|
|
1692
|
+
health: async () => true,
|
|
1693
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1694
|
+
listDevices: async () => ({
|
|
1695
|
+
devices: now >= firstBudgetMs ? [readyIphone] : [],
|
|
1696
|
+
}),
|
|
1697
|
+
accessStatus: async () => ({
|
|
1698
|
+
status: 'active',
|
|
1699
|
+
reason: 'alpha_grant',
|
|
1700
|
+
expires_at: null,
|
|
1701
|
+
email: 'late@example.com',
|
|
1702
|
+
}),
|
|
1703
|
+
submit: async () => setupReceipt(),
|
|
1704
|
+
evidence: async (requestId: string) =>
|
|
1705
|
+
setupEvidence(requestId, {
|
|
1706
|
+
state: 'observed',
|
|
1707
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
1708
|
+
latency_ms: 1_000,
|
|
1709
|
+
}),
|
|
1710
|
+
} as unknown as ApiClient
|
|
1711
|
+
const deps: CommandDeps = {
|
|
1712
|
+
...makeDeps(io, client),
|
|
1713
|
+
cwd,
|
|
1714
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1715
|
+
now: () => now,
|
|
1716
|
+
sleep: async (milliseconds) => {
|
|
1717
|
+
now += milliseconds
|
|
1718
|
+
},
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1722
|
+
expect(io.errLines.join('\n')).toContain('wait timer expired')
|
|
1723
|
+
expect(io.errLines.join('\n')).toMatch(/setup is not finished, only this wait/)
|
|
1724
|
+
expect(io.prompts.some((q) => q.includes('Keep waiting'))).toBe(true)
|
|
1725
|
+
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
1726
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
it('never hangs an agent on the device wait or keep-waiting prompt', async () => {
|
|
1730
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-agent-'))
|
|
1731
|
+
const io = new (class extends CapturedIo {
|
|
1732
|
+
override async confirm(): Promise<boolean> {
|
|
1733
|
+
throw new Error('an unattended init reached a device-wait prompt')
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
override openUrl(): void {
|
|
1737
|
+
throw new Error('an unattended init opened a browser')
|
|
1738
|
+
}
|
|
1739
|
+
})()
|
|
1740
|
+
const client = {
|
|
1741
|
+
health: async () => true,
|
|
1742
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1743
|
+
listDevices: async () => ({ devices: [] }),
|
|
1744
|
+
accessStatus: async () => ({
|
|
1745
|
+
status: 'active',
|
|
1746
|
+
reason: 'alpha_grant',
|
|
1747
|
+
expires_at: null,
|
|
1748
|
+
email: 'agent@example.com',
|
|
1749
|
+
}),
|
|
1750
|
+
} as unknown as ApiClient
|
|
1751
|
+
const deps = {
|
|
1752
|
+
...makeDeps(io, client),
|
|
1753
|
+
cwd,
|
|
1754
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1758
|
+
const out = io.outLines.join('\n')
|
|
1759
|
+
expect(out).toContain('Next: Your devices')
|
|
1760
|
+
expect(out).toContain('https://test.notifai.invalid/support')
|
|
1761
|
+
expect(out).toContain('sign in with the same email as this account (agent@example.com)')
|
|
1762
|
+
expect(io.openedUrls).toEqual([])
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1263
1765
|
it('persists a partial proof and checks the same request instead of sending again', async () => {
|
|
1264
1766
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-partial-'))
|
|
1265
1767
|
const io = new CapturedIo()
|
|
@@ -1303,7 +1805,8 @@ describe('init', () => {
|
|
|
1303
1805
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1304
1806
|
expect(submitCalls).toBe(1)
|
|
1305
1807
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
1306
|
-
expect(io.
|
|
1808
|
+
expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
|
|
1809
|
+
expect(io.outLines.join('\n')).toContain('Proof may still arrive')
|
|
1307
1810
|
|
|
1308
1811
|
io.outLines = []
|
|
1309
1812
|
io.errLines = []
|
|
@@ -1317,7 +1820,9 @@ describe('init', () => {
|
|
|
1317
1820
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1318
1821
|
expect(submitCalls).toBe(2)
|
|
1319
1822
|
expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
|
|
1320
|
-
expect(io.outLines.join('\n')).toContain(
|
|
1823
|
+
expect(io.outLines.join('\n')).toContain(
|
|
1824
|
+
"Companion Receipt (the app's delivery confirmation) observed from iPhone",
|
|
1825
|
+
)
|
|
1321
1826
|
})
|
|
1322
1827
|
|
|
1323
1828
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
@@ -1345,7 +1850,7 @@ describe('init', () => {
|
|
|
1345
1850
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
1346
1851
|
})
|
|
1347
1852
|
|
|
1348
|
-
it('
|
|
1853
|
+
it('treats macOS-only delivery proof as an honest non-blocking caveat', async () => {
|
|
1349
1854
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
1350
1855
|
const io = new CapturedIo()
|
|
1351
1856
|
let submitCalls = 0
|
|
@@ -1366,10 +1871,66 @@ describe('init', () => {
|
|
|
1366
1871
|
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1367
1872
|
}
|
|
1368
1873
|
|
|
1369
|
-
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.
|
|
1874
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1370
1875
|
expect(submitCalls).toBe(0)
|
|
1371
|
-
|
|
1372
|
-
expect(
|
|
1876
|
+
const out = io.outLines.join('\n')
|
|
1877
|
+
expect(out).toContain('All set.')
|
|
1878
|
+
expect(out).toContain('receipt proof needs an iPhone in this release')
|
|
1879
|
+
expect(out).not.toContain('Next: Delivery proof')
|
|
1880
|
+
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
1881
|
+
|
|
1882
|
+
// doctor must render the same non-blocking state (not FAIL / not a Next:).
|
|
1883
|
+
const doctorIo = new CapturedIo()
|
|
1884
|
+
expect(
|
|
1885
|
+
await doctorCommand(
|
|
1886
|
+
{
|
|
1887
|
+
...makeDeps(doctorIo, client),
|
|
1888
|
+
cwd,
|
|
1889
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1890
|
+
},
|
|
1891
|
+
{},
|
|
1892
|
+
),
|
|
1893
|
+
).toBe(EXIT.ok)
|
|
1894
|
+
const doctorOut = doctorIo.outLines.join('\n')
|
|
1895
|
+
expect(doctorOut).toMatch(/--\s+Delivery proof:/)
|
|
1896
|
+
expect(doctorOut).toContain('receipt proof needs an iPhone')
|
|
1897
|
+
expect(doctorOut).not.toMatch(/FAIL\s+Delivery proof:/)
|
|
1898
|
+
})
|
|
1899
|
+
|
|
1900
|
+
it('stops login when the approval page reports no Alpha access', async () => {
|
|
1901
|
+
const io = new CapturedIo()
|
|
1902
|
+
let now = 0
|
|
1903
|
+
let polls = 0
|
|
1904
|
+
const client = {
|
|
1905
|
+
beginPairing: async () => ({
|
|
1906
|
+
pairing_id: 'pair_no_plan',
|
|
1907
|
+
code: 'NOPE-PLAN',
|
|
1908
|
+
approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
|
|
1909
|
+
expires_at: new Date(60_000).toISOString(),
|
|
1910
|
+
poll_interval_seconds: 1,
|
|
1911
|
+
}),
|
|
1912
|
+
pollPairing: async () => {
|
|
1913
|
+
polls += 1
|
|
1914
|
+
return {
|
|
1915
|
+
status: 'no_active_plan' as const,
|
|
1916
|
+
next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
1917
|
+
}
|
|
1918
|
+
},
|
|
1919
|
+
} as unknown as ApiClient
|
|
1920
|
+
const deps = {
|
|
1921
|
+
...makeDeps(io, client),
|
|
1922
|
+
now: () => now,
|
|
1923
|
+
sleep: async (milliseconds: number) => {
|
|
1924
|
+
now += milliseconds
|
|
1925
|
+
},
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
1929
|
+
expect(polls).toBe(1)
|
|
1930
|
+
expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
|
|
1931
|
+
expect(io.errLines.join('\n')).toContain(
|
|
1932
|
+
'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
1933
|
+
)
|
|
1373
1934
|
})
|
|
1374
1935
|
|
|
1375
1936
|
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
@@ -1486,7 +2047,7 @@ describe('an outage is not an answer', () => {
|
|
|
1486
2047
|
json: true,
|
|
1487
2048
|
})
|
|
1488
2049
|
|
|
1489
|
-
const payload = JSON.parse(io.outLines[
|
|
2050
|
+
const payload = JSON.parse(io.outLines[1] ?? '{}') as { degraded: boolean }
|
|
1490
2051
|
expect(payload.degraded).toBe(true)
|
|
1491
2052
|
})
|
|
1492
2053
|
|
|
@@ -1512,6 +2073,276 @@ describe('an outage is not an answer', () => {
|
|
|
1512
2073
|
})
|
|
1513
2074
|
|
|
1514
2075
|
describe('asking before the hooks have ever run', () => {
|
|
2076
|
+
const execPath = '/usr/local/bin/node'
|
|
2077
|
+
const scriptPath = '/opt/notifai/dist/main.js'
|
|
2078
|
+
|
|
2079
|
+
it('names the active Codex harness instead of unrelated installed adapters', () => {
|
|
2080
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
|
|
2081
|
+
const io = new CapturedIo()
|
|
2082
|
+
const env = {
|
|
2083
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2084
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2085
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2086
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
2087
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2088
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
2089
|
+
}
|
|
2090
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
2091
|
+
|
|
2092
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2093
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2094
|
+
io.outLines = []
|
|
2095
|
+
|
|
2096
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2097
|
+
const said = io.errLines.join(' ')
|
|
2098
|
+
expect(said).toMatch(/active Codex session/i)
|
|
2099
|
+
expect(said).toContain('notifai hooks install --harness codex')
|
|
2100
|
+
expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
|
|
2101
|
+
})
|
|
2102
|
+
|
|
2103
|
+
it('refuses a recent pointer owned by another Codex thread', () => {
|
|
2104
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-mismatch-'))
|
|
2105
|
+
const io = new CapturedIo()
|
|
2106
|
+
const env = {
|
|
2107
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2108
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2109
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2110
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
2111
|
+
}
|
|
2112
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2113
|
+
|
|
2114
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2115
|
+
writeSessionState('codex-other-thread', env, { last_prompt_at: 42 })
|
|
2116
|
+
writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
|
|
2117
|
+
io.outLines = []
|
|
2118
|
+
|
|
2119
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2120
|
+
expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
|
|
2121
|
+
expect(io.outLines).not.toContain(
|
|
2122
|
+
'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
2123
|
+
)
|
|
2124
|
+
})
|
|
2125
|
+
|
|
2126
|
+
it('registers only when the active Codex thread owns the project pointer', () => {
|
|
2127
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-matching-'))
|
|
2128
|
+
const io = new CapturedIo()
|
|
2129
|
+
const env = {
|
|
2130
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2131
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2132
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2133
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
2134
|
+
}
|
|
2135
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2136
|
+
|
|
2137
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2138
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42 })
|
|
2139
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
2140
|
+
io.outLines = []
|
|
2141
|
+
|
|
2142
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2143
|
+
expect(io.outLines).toContain(
|
|
2144
|
+
'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
2145
|
+
)
|
|
2146
|
+
})
|
|
2147
|
+
|
|
2148
|
+
it('gives doctor the same active-Codex diagnosis as ask', async () => {
|
|
2149
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
|
|
2150
|
+
const io = new CapturedIo()
|
|
2151
|
+
const env = {
|
|
2152
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2153
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2154
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2155
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
2156
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2157
|
+
}
|
|
2158
|
+
const client = {
|
|
2159
|
+
health: async () => true,
|
|
2160
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
2161
|
+
listDevices: async () => ({ devices: [] }),
|
|
2162
|
+
} as unknown as ApiClient
|
|
2163
|
+
const nativeSkills: NativeSkills = {
|
|
2164
|
+
add: async () => 0,
|
|
2165
|
+
list: async (scope) => ({
|
|
2166
|
+
skills:
|
|
2167
|
+
scope === 'global'
|
|
2168
|
+
? [
|
|
2169
|
+
{
|
|
2170
|
+
name: 'notifai',
|
|
2171
|
+
scope,
|
|
2172
|
+
path: path.join(cwd, 'global-skills', 'notifai'),
|
|
2173
|
+
source: 'Raidiant-io/notifai',
|
|
2174
|
+
sourceType: 'github',
|
|
2175
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2176
|
+
ref: 'v0.1.8',
|
|
2177
|
+
},
|
|
2178
|
+
]
|
|
2179
|
+
: [],
|
|
2180
|
+
}),
|
|
2181
|
+
}
|
|
2182
|
+
const deps = { ...makeDeps(io, client), cwd, env, nativeSkills }
|
|
2183
|
+
|
|
2184
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2185
|
+
io.outLines = []
|
|
2186
|
+
|
|
2187
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2188
|
+
const said = io.outLines.join(' ')
|
|
2189
|
+
expect(said).toContain(`installed from ${SKILLS_SOURCE} in the global scope`)
|
|
2190
|
+
expect(said).toMatch(/active Codex/i)
|
|
2191
|
+
expect(said).toContain('notifai hooks install --harness codex')
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
it('refuses a Claude Code pointer owned by another live session', () => {
|
|
2195
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-mismatch-'))
|
|
2196
|
+
const io = new CapturedIo()
|
|
2197
|
+
const env = {
|
|
2198
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2199
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2200
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2201
|
+
CLAUDECODE: '1',
|
|
2202
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
2203
|
+
}
|
|
2204
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2205
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2206
|
+
EXIT.ok,
|
|
2207
|
+
)
|
|
2208
|
+
writeSessionState('claude-other', env, { last_prompt_at: 42 })
|
|
2209
|
+
writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
|
|
2210
|
+
io.outLines = []
|
|
2211
|
+
|
|
2212
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2213
|
+
expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
|
|
2214
|
+
})
|
|
2215
|
+
|
|
2216
|
+
it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
|
|
2217
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-child-'))
|
|
2218
|
+
const io = new CapturedIo()
|
|
2219
|
+
const env = {
|
|
2220
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2221
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2222
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2223
|
+
CLAUDECODE: '1',
|
|
2224
|
+
CLAUDE_CODE_CHILD_SESSION: '1',
|
|
2225
|
+
CLAUDE_CODE_SESSION_ID: 'claude-parent-loop',
|
|
2226
|
+
}
|
|
2227
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2228
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2229
|
+
EXIT.ok,
|
|
2230
|
+
)
|
|
2231
|
+
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
|
|
2232
|
+
writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
|
|
2233
|
+
io.outLines = []
|
|
2234
|
+
|
|
2235
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2236
|
+
expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
|
|
2237
|
+
})
|
|
2238
|
+
|
|
2239
|
+
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
2240
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
|
|
2241
|
+
const io = new CapturedIo()
|
|
2242
|
+
const env = {
|
|
2243
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2244
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2245
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
2246
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
2247
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
|
|
2248
|
+
}
|
|
2249
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2250
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2251
|
+
writeSessionState('opencode-other', env, { last_prompt_at: 42 })
|
|
2252
|
+
writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
|
|
2253
|
+
io.outLines = []
|
|
2254
|
+
|
|
2255
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2256
|
+
expect(io.errLines.join(' ')).toMatch(/another OpenCode session/i)
|
|
2257
|
+
})
|
|
2258
|
+
|
|
2259
|
+
it('recognizes Cursor only from its active-agent marker', () => {
|
|
2260
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
|
|
2261
|
+
const io = new CapturedIo()
|
|
2262
|
+
const env = {
|
|
2263
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2264
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2265
|
+
CURSOR_AGENT: '1',
|
|
2266
|
+
}
|
|
2267
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2268
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2269
|
+
writeSessionState('cursor-live', env, { last_prompt_at: 42 })
|
|
2270
|
+
writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
|
|
2271
|
+
io.outLines = []
|
|
2272
|
+
|
|
2273
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2274
|
+
expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
|
|
2275
|
+
})
|
|
2276
|
+
|
|
2277
|
+
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
2278
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
|
|
2279
|
+
const io = new CapturedIo()
|
|
2280
|
+
const env = {
|
|
2281
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2282
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2283
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2284
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2285
|
+
CLAUDECODE: '1',
|
|
2286
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
2287
|
+
}
|
|
2288
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2289
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2290
|
+
writeSessionState('codex-live', env, { last_prompt_at: 42 })
|
|
2291
|
+
writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
|
|
2292
|
+
io.outLines = []
|
|
2293
|
+
|
|
2294
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2295
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
|
|
2296
|
+
})
|
|
2297
|
+
|
|
2298
|
+
it('shows resolved question-routing values with their winning config sources', async () => {
|
|
2299
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
|
|
2300
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
2301
|
+
writeFileSync(
|
|
2302
|
+
path.join(cwd, '.notifai', 'config.local.toml'),
|
|
2303
|
+
'ask_notifications = false\nrequire_idle = false\naway_after_seconds = 45\nask_grace_seconds = 90\nhook_reply_timeout_seconds = 120\n',
|
|
2304
|
+
)
|
|
2305
|
+
const io = new CapturedIo()
|
|
2306
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
|
|
2307
|
+
|
|
2308
|
+
await doctorCommand(deps, {})
|
|
2309
|
+
|
|
2310
|
+
const said = io.outLines.join('\n')
|
|
2311
|
+
expect(said).toContain('Question routing settings:')
|
|
2312
|
+
expect(said).toContain('ask_notifications=false (project-local:')
|
|
2313
|
+
expect(said).toContain('require_idle=false (project-local:')
|
|
2314
|
+
expect(said).toContain('away_after_seconds=45 (project-local:')
|
|
2315
|
+
expect(said).toContain('ask_grace_seconds=90 (project-local:')
|
|
2316
|
+
expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
|
|
2317
|
+
})
|
|
2318
|
+
|
|
2319
|
+
it('fails doctor when runtime waits outgrow an installed Stop timeout', async () => {
|
|
2320
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
2321
|
+
const io = new CapturedIo()
|
|
2322
|
+
const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
|
|
2323
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
2324
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2325
|
+
EXIT.ok,
|
|
2326
|
+
)
|
|
2327
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
2328
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
|
|
2329
|
+
io.outLines = []
|
|
2330
|
+
|
|
2331
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2332
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(timeout\)/)
|
|
2333
|
+
expect(io.outLines.join('\n')).toContain('notifai hooks install --harness claude-code')
|
|
2334
|
+
})
|
|
2335
|
+
|
|
2336
|
+
it('documents the failing exit contract in doctor JSON', async () => {
|
|
2337
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-exit-json-'))
|
|
2338
|
+
const io = new CapturedIo()
|
|
2339
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
|
|
2340
|
+
|
|
2341
|
+
expect(await doctorCommand(deps, { json: true })).toBe(EXIT.failed)
|
|
2342
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as { ok?: boolean; exit_code?: number }
|
|
2343
|
+
expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
|
|
2344
|
+
})
|
|
2345
|
+
|
|
1515
2346
|
it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
|
|
1516
2347
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
|
|
1517
2348
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
@@ -1533,6 +2364,43 @@ describe('asking before the hooks have ever run', () => {
|
|
|
1533
2364
|
expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
|
|
1534
2365
|
})
|
|
1535
2366
|
|
|
2367
|
+
it('gives Cursor its prompt-and-doctor activation path', () => {
|
|
2368
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
|
|
2369
|
+
const io = new CapturedIo()
|
|
2370
|
+
const deps = {
|
|
2371
|
+
...makeDeps(io, {} as ApiClient),
|
|
2372
|
+
cwd,
|
|
2373
|
+
env: { XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2377
|
+
io.outLines = []
|
|
2378
|
+
|
|
2379
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2380
|
+
expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
|
|
2381
|
+
})
|
|
2382
|
+
|
|
2383
|
+
it('keeps OpenCode activation separate from answer continuation', () => {
|
|
2384
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
|
|
2385
|
+
const io = new CapturedIo()
|
|
2386
|
+
const deps = {
|
|
2387
|
+
...makeDeps(io, {} as ApiClient),
|
|
2388
|
+
cwd,
|
|
2389
|
+
env: {
|
|
2390
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2391
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
2392
|
+
},
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2396
|
+
io.outLines = []
|
|
2397
|
+
|
|
2398
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2399
|
+
const said = io.errLines.join(' ')
|
|
2400
|
+
expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
|
|
2401
|
+
expect(said).toContain('device answer is delivered on the next prompt')
|
|
2402
|
+
})
|
|
2403
|
+
|
|
1536
2404
|
it('says to install when nothing is installed at all', () => {
|
|
1537
2405
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
|
|
1538
2406
|
const io = new CapturedIo()
|
|
@@ -1572,7 +2440,7 @@ describe('a server behind this CLI', () => {
|
|
|
1572
2440
|
expect(said).not.toMatch(/older than this CLI/)
|
|
1573
2441
|
})
|
|
1574
2442
|
|
|
1575
|
-
it('doctor
|
|
2443
|
+
it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
|
|
1576
2444
|
const io = new CapturedIo()
|
|
1577
2445
|
const client = {
|
|
1578
2446
|
health: async () => true,
|
|
@@ -1593,7 +2461,8 @@ describe('a server behind this CLI', () => {
|
|
|
1593
2461
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
1594
2462
|
// The label is the user's word for it; the detail is what must survive.
|
|
1595
2463
|
expect(said).toMatch(/Protocol version/)
|
|
1596
|
-
expect(said).toMatch(/
|
|
2464
|
+
expect(said).toMatch(/service is being updated/)
|
|
2465
|
+
expect(said).not.toMatch(/needs deploying/)
|
|
1597
2466
|
})
|
|
1598
2467
|
|
|
1599
2468
|
it('doctor is quiet when both sides agree', async () => {
|