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