@raidiant/notifai 8.0.0 → 9.0.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/CHANGELOG.md +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands.test.ts
CHANGED
|
@@ -63,17 +63,26 @@ import {
|
|
|
63
63
|
import {
|
|
64
64
|
applyPlan,
|
|
65
65
|
buildHookConfig,
|
|
66
|
+
hookCommand,
|
|
66
67
|
codexHookIdentityHash,
|
|
67
68
|
codexTrustKey,
|
|
68
69
|
findInstallations,
|
|
70
|
+
handlerEvent,
|
|
71
|
+
settingsFile,
|
|
69
72
|
} from './install-hooks.js'
|
|
70
73
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
71
74
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
72
75
|
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
|
|
73
|
-
import {
|
|
76
|
+
import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
|
|
77
|
+
import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
78
|
+
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
74
79
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
75
80
|
import type { Tone } from './ui/theme.js'
|
|
76
81
|
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
resetLatestPublishedCliVersionForTest()
|
|
84
|
+
})
|
|
85
|
+
|
|
77
86
|
/**
|
|
78
87
|
* The release tag this build pins the agent skill to.
|
|
79
88
|
*
|
|
@@ -328,6 +337,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
328
337
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
329
338
|
cwd: os.tmpdir(),
|
|
330
339
|
clientFactory: () => withCompatibilityDefaults(client),
|
|
340
|
+
fetchImpl: async () => new Response('{}', { status: 503 }),
|
|
331
341
|
}
|
|
332
342
|
}
|
|
333
343
|
|
|
@@ -382,6 +392,8 @@ const reply: ReplyView = {
|
|
|
382
392
|
device_id: 'dev_test',
|
|
383
393
|
device_name: 'iPhone',
|
|
384
394
|
text: 'yes, after the migration',
|
|
395
|
+
answers: [],
|
|
396
|
+
source: null,
|
|
385
397
|
created_at: '2026-08-01T18:01:00.000Z',
|
|
386
398
|
}
|
|
387
399
|
|
|
@@ -573,6 +585,108 @@ describe('command contracts', () => {
|
|
|
573
585
|
expect(io.errLines.join('\n')).toContain('project')
|
|
574
586
|
})
|
|
575
587
|
|
|
588
|
+
it('prints the exact idempotency key needed after an ambiguous submit failure', async () => {
|
|
589
|
+
const io = new CapturedIo()
|
|
590
|
+
let attemptedKey = ''
|
|
591
|
+
const client = {
|
|
592
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
593
|
+
attemptedKey = body.idempotency_key
|
|
594
|
+
throw new NetworkError('Connection closed before a response arrived')
|
|
595
|
+
},
|
|
596
|
+
} as unknown as ApiClient
|
|
597
|
+
|
|
598
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
599
|
+
const deps = makeDeps(io, client)
|
|
600
|
+
deps.env = {
|
|
601
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
602
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
603
|
+
}
|
|
604
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
605
|
+
expect(
|
|
606
|
+
await sendCommand(deps, {
|
|
607
|
+
kind: 'done',
|
|
608
|
+
title: 'Checks finished',
|
|
609
|
+
body: 'All checks passed.',
|
|
610
|
+
}),
|
|
611
|
+
).toBe(EXIT.network)
|
|
612
|
+
|
|
613
|
+
expect(attemptedKey).toMatch(/^cli-/)
|
|
614
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
615
|
+
expect(
|
|
616
|
+
readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
|
|
617
|
+
).toMatchObject({
|
|
618
|
+
idempotency_key: attemptedKey,
|
|
619
|
+
kind: 'done',
|
|
620
|
+
title_chars: 'Checks finished'.length,
|
|
621
|
+
session_id: null,
|
|
622
|
+
})
|
|
623
|
+
rmSync(root, { recursive: true, force: true })
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('prints the same generated key for an ambiguous server failure', async () => {
|
|
627
|
+
const io = new CapturedIo()
|
|
628
|
+
let attemptedKey = ''
|
|
629
|
+
const client = {
|
|
630
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
631
|
+
attemptedKey = body.idempotency_key
|
|
632
|
+
throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
|
|
633
|
+
},
|
|
634
|
+
} as unknown as ApiClient
|
|
635
|
+
|
|
636
|
+
expect(
|
|
637
|
+
await sendCommand(makeDeps(io, client), {
|
|
638
|
+
kind: 'failed',
|
|
639
|
+
title: 'Validation could not finish',
|
|
640
|
+
body: 'The service failed while accepting the result.',
|
|
641
|
+
}),
|
|
642
|
+
).toBe(EXIT.network)
|
|
643
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('routes a send with no active Companion devices into the setup coordinator', async () => {
|
|
647
|
+
const io = new CapturedIo()
|
|
648
|
+
let attemptedKey = ''
|
|
649
|
+
const client = {
|
|
650
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
651
|
+
attemptedKey = body.idempotency_key
|
|
652
|
+
throw new ApiCallError(
|
|
653
|
+
409,
|
|
654
|
+
'no_active_devices',
|
|
655
|
+
'No enabled active Device Installation can receive this request.',
|
|
656
|
+
'Open the Companion App and enable notifications.',
|
|
657
|
+
)
|
|
658
|
+
},
|
|
659
|
+
} as unknown as ApiClient
|
|
660
|
+
|
|
661
|
+
expect(
|
|
662
|
+
await sendCommand(makeDeps(io, client), {
|
|
663
|
+
kind: 'done',
|
|
664
|
+
title: 'Checks finished',
|
|
665
|
+
body: 'All checks passed.',
|
|
666
|
+
}),
|
|
667
|
+
).toBe(EXIT.failed)
|
|
668
|
+
|
|
669
|
+
expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
|
|
670
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
671
|
+
expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('routes a send from an unpaired machine into the same setup coordinator', async () => {
|
|
675
|
+
const io = new CapturedIo()
|
|
676
|
+
const deps = makeDeps(io, {} as ApiClient)
|
|
677
|
+
deps.store.load = () => null
|
|
678
|
+
|
|
679
|
+
expect(
|
|
680
|
+
await sendCommand(deps, {
|
|
681
|
+
kind: 'done',
|
|
682
|
+
title: 'Checks finished',
|
|
683
|
+
body: 'All checks passed.',
|
|
684
|
+
}),
|
|
685
|
+
).toBe(EXIT.auth)
|
|
686
|
+
|
|
687
|
+
expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
|
|
688
|
+
})
|
|
689
|
+
|
|
576
690
|
it('authors and validates an Android-specific Notification Request', async () => {
|
|
577
691
|
const io = new CapturedIo()
|
|
578
692
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -1209,8 +1323,11 @@ describe('command contracts', () => {
|
|
|
1209
1323
|
request_id: receipt.request_id,
|
|
1210
1324
|
sequence: reply.seq,
|
|
1211
1325
|
device: reply.device_name,
|
|
1212
|
-
|
|
1326
|
+
text_chars: reply.text.length,
|
|
1327
|
+
answers_count: reply.answers.length,
|
|
1213
1328
|
})
|
|
1329
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1330
|
+
expect(raw).not.toContain(reply.text)
|
|
1214
1331
|
})
|
|
1215
1332
|
|
|
1216
1333
|
it('backs off and retries a transient network error while waiting', async () => {
|
|
@@ -1501,6 +1618,10 @@ describe('command contracts', () => {
|
|
|
1501
1618
|
'acknowledgement.attempted',
|
|
1502
1619
|
'acknowledgement.outcome',
|
|
1503
1620
|
])
|
|
1621
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1622
|
+
expect(raw).not.toContain('I will deploy staging now.')
|
|
1623
|
+
expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
|
|
1624
|
+
expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
|
|
1504
1625
|
})
|
|
1505
1626
|
|
|
1506
1627
|
it('clears a matching active-session obligation after acknowledgement success', async () => {
|
|
@@ -1719,6 +1840,113 @@ describe('command contracts', () => {
|
|
|
1719
1840
|
})
|
|
1720
1841
|
})
|
|
1721
1842
|
|
|
1843
|
+
it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
|
|
1844
|
+
const io = new CapturedIo()
|
|
1845
|
+
const client = {
|
|
1846
|
+
closeReplies: async () => {
|
|
1847
|
+
throw new Error('unpushed questions must not call the server')
|
|
1848
|
+
},
|
|
1849
|
+
} as unknown as ApiClient
|
|
1850
|
+
const deps = makeDeps(io, client)
|
|
1851
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
|
|
1852
|
+
deps.cwd = root
|
|
1853
|
+
deps.env = {
|
|
1854
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1855
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1856
|
+
}
|
|
1857
|
+
writeSessionState('close-unpushed', deps.env, {
|
|
1858
|
+
pending: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1859
|
+
})
|
|
1860
|
+
writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
|
|
1861
|
+
|
|
1862
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1863
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1864
|
+
session_id: 'close-unpushed',
|
|
1865
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1866
|
+
closed: [],
|
|
1867
|
+
})
|
|
1868
|
+
expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
|
|
1869
|
+
})
|
|
1870
|
+
|
|
1871
|
+
it('close withdraws one unpushed question by the ask id', async () => {
|
|
1872
|
+
const io = new CapturedIo()
|
|
1873
|
+
const client = {
|
|
1874
|
+
closeReplies: async () => {
|
|
1875
|
+
throw new Error('unpushed questions must not call the server')
|
|
1876
|
+
},
|
|
1877
|
+
} as unknown as ApiClient
|
|
1878
|
+
const deps = makeDeps(io, client)
|
|
1879
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
|
|
1880
|
+
deps.cwd = root
|
|
1881
|
+
deps.env = {
|
|
1882
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1883
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1884
|
+
}
|
|
1885
|
+
writeSessionState('close-one', deps.env, {
|
|
1886
|
+
pending: [
|
|
1887
|
+
{ question: 'Ship it?', question_id: 'q_local' },
|
|
1888
|
+
{ question: 'Keep this?', question_id: 'q_keep' },
|
|
1889
|
+
],
|
|
1890
|
+
})
|
|
1891
|
+
writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
|
|
1892
|
+
|
|
1893
|
+
expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
|
|
1894
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1895
|
+
session_id: 'close-one',
|
|
1896
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1897
|
+
closed: [],
|
|
1898
|
+
})
|
|
1899
|
+
expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
|
|
1900
|
+
'q_keep',
|
|
1901
|
+
])
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
|
|
1905
|
+
const io = new CapturedIo()
|
|
1906
|
+
const closed: string[] = []
|
|
1907
|
+
const client = {
|
|
1908
|
+
closeReplies: async (requestId: string) => {
|
|
1909
|
+
closed.push(requestId)
|
|
1910
|
+
return replyResponse([])
|
|
1911
|
+
},
|
|
1912
|
+
} as unknown as ApiClient
|
|
1913
|
+
const deps = makeDeps(io, client)
|
|
1914
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
|
|
1915
|
+
deps.cwd = root
|
|
1916
|
+
deps.env = {
|
|
1917
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1918
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1919
|
+
}
|
|
1920
|
+
writeSessionState('close-mix', deps.env, {
|
|
1921
|
+
pending: [
|
|
1922
|
+
{ question: 'Unpushed?', question_id: 'q_local' },
|
|
1923
|
+
{
|
|
1924
|
+
question: 'Already on a device?',
|
|
1925
|
+
request_id: receipt.request_id,
|
|
1926
|
+
collapse_key: 'question-live',
|
|
1927
|
+
device_ids: ['dev_iphone'],
|
|
1928
|
+
},
|
|
1929
|
+
],
|
|
1930
|
+
})
|
|
1931
|
+
writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
|
|
1932
|
+
|
|
1933
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1934
|
+
expect(closed).toEqual([receipt.request_id])
|
|
1935
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1936
|
+
session_id: 'close-mix',
|
|
1937
|
+
withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
|
|
1938
|
+
closed: [receipt.request_id],
|
|
1939
|
+
})
|
|
1940
|
+
expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
|
|
1941
|
+
})
|
|
1942
|
+
|
|
1943
|
+
it('close requires a request id or --pending', async () => {
|
|
1944
|
+
const io = new CapturedIo()
|
|
1945
|
+
const client = {} as unknown as ApiClient
|
|
1946
|
+
expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
|
|
1947
|
+
expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1722
1950
|
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
1723
1951
|
const io = new CapturedIo()
|
|
1724
1952
|
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
@@ -1797,8 +2025,9 @@ describe('command contracts', () => {
|
|
|
1797
2025
|
writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
|
|
1798
2026
|
|
|
1799
2027
|
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
1800
|
-
expect(io.outLines[0]).toBe('
|
|
1801
|
-
expect(io.outLines
|
|
2028
|
+
expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
|
|
2029
|
+
expect(io.outLines.join('\n')).toContain('pending request req_first')
|
|
2030
|
+
expect(io.outLines.join('\n')).toContain('yes, after the migration')
|
|
1802
2031
|
expect(io.outLines.join('\n')).toContain('req_second')
|
|
1803
2032
|
})
|
|
1804
2033
|
|
|
@@ -1995,13 +2224,188 @@ describe('command contracts', () => {
|
|
|
1995
2224
|
})
|
|
1996
2225
|
})
|
|
1997
2226
|
|
|
2227
|
+
describe('credential origin pinning', () => {
|
|
2228
|
+
it('never sends the machine bearer to a flag or env origin override', async () => {
|
|
2229
|
+
const io = new CapturedIo()
|
|
2230
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2231
|
+
const client = {
|
|
2232
|
+
submit: async () => receipt,
|
|
2233
|
+
} as unknown as ApiClient
|
|
2234
|
+
const deps = makeDeps(io, client)
|
|
2235
|
+
deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
|
|
2236
|
+
deps.clientFactory = (baseUrl, bearer) => {
|
|
2237
|
+
seen.push({ baseUrl, bearer })
|
|
2238
|
+
return withCompatibilityDefaults(client)
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
expect(
|
|
2242
|
+
await sendCommand(deps, {
|
|
2243
|
+
kind: 'update',
|
|
2244
|
+
title: 'T',
|
|
2245
|
+
body: 'B',
|
|
2246
|
+
baseUrl: 'https://attacker.flag',
|
|
2247
|
+
}),
|
|
2248
|
+
).toBe(EXIT.ok)
|
|
2249
|
+
|
|
2250
|
+
expect(seen).toEqual([
|
|
2251
|
+
{
|
|
2252
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
2253
|
+
bearer: 'Bearer nfm_mac_test.test-secret',
|
|
2254
|
+
},
|
|
2255
|
+
])
|
|
2256
|
+
expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
|
|
2257
|
+
expect(io.errLines.join('\n')).not.toContain('test-secret')
|
|
2258
|
+
expect(io.errLines.join('\n')).not.toContain('nfm_')
|
|
2259
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
|
|
2260
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
|
|
2261
|
+
})
|
|
2262
|
+
|
|
2263
|
+
it('lets unsigned-in login target an origin override', async () => {
|
|
2264
|
+
const io = new CapturedIo()
|
|
2265
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2266
|
+
let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
|
|
2267
|
+
let now = 0
|
|
2268
|
+
const client = {
|
|
2269
|
+
beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
|
|
2270
|
+
beginBody = body
|
|
2271
|
+
return {
|
|
2272
|
+
pairing_id: 'pair_test',
|
|
2273
|
+
code: 'ABCD-EFGH',
|
|
2274
|
+
approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
|
|
2275
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2276
|
+
poll_interval_seconds: 1,
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
|
|
2280
|
+
} as unknown as ApiClient
|
|
2281
|
+
const saved: { baseUrl?: string } = {}
|
|
2282
|
+
const deps: CommandDeps = {
|
|
2283
|
+
...makeDeps(io, client),
|
|
2284
|
+
now: () => now,
|
|
2285
|
+
sleep: async (milliseconds: number) => {
|
|
2286
|
+
now += milliseconds
|
|
2287
|
+
},
|
|
2288
|
+
store: {
|
|
2289
|
+
load: () => null,
|
|
2290
|
+
save: (credential) => {
|
|
2291
|
+
saved.baseUrl = credential.baseUrl
|
|
2292
|
+
},
|
|
2293
|
+
clear: () => {},
|
|
2294
|
+
describe: () => 'test credential store',
|
|
2295
|
+
},
|
|
2296
|
+
clientFactory: (baseUrl, bearer) => {
|
|
2297
|
+
seen.push({ baseUrl, bearer })
|
|
2298
|
+
return withCompatibilityDefaults(client)
|
|
2299
|
+
},
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
|
|
2303
|
+
EXIT.ok,
|
|
2304
|
+
)
|
|
2305
|
+
expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
|
|
2306
|
+
expect(saved.baseUrl).toBe('https://selfhost.example')
|
|
2307
|
+
expect(beginBody).toMatchObject({
|
|
2308
|
+
machine_name: expect.any(String),
|
|
2309
|
+
credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2310
|
+
poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2311
|
+
confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2312
|
+
})
|
|
2313
|
+
expect(io.outLines[1]).toMatch(
|
|
2314
|
+
/^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
2315
|
+
)
|
|
2316
|
+
})
|
|
2317
|
+
|
|
2318
|
+
it('stops login when the approving account has no product access', async () => {
|
|
2319
|
+
const io = new CapturedIo()
|
|
2320
|
+
let now = 0
|
|
2321
|
+
const client = {
|
|
2322
|
+
beginPairing: async () => ({
|
|
2323
|
+
pairing_id: 'pair_test',
|
|
2324
|
+
code: 'ABCD-EFGH',
|
|
2325
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2326
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2327
|
+
poll_interval_seconds: 1,
|
|
2328
|
+
}),
|
|
2329
|
+
pollPairing: async () => ({
|
|
2330
|
+
status: 'no_active_plan',
|
|
2331
|
+
next_action: 'Ask the account owner for Alpha access.',
|
|
2332
|
+
}),
|
|
2333
|
+
} as unknown as ApiClient
|
|
2334
|
+
const deps: CommandDeps = {
|
|
2335
|
+
...makeDeps(io, client),
|
|
2336
|
+
now: () => now,
|
|
2337
|
+
sleep: async (milliseconds: number) => {
|
|
2338
|
+
now += milliseconds
|
|
2339
|
+
},
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
2343
|
+
expect(io.errLines).toEqual([
|
|
2344
|
+
'This account has no active plan or temporary Alpha access.',
|
|
2345
|
+
'next: Ask the account owner for Alpha access.',
|
|
2346
|
+
'After access is granted, run `notifai login` again.',
|
|
2347
|
+
])
|
|
2348
|
+
})
|
|
2349
|
+
|
|
2350
|
+
it('keeps notification titles out of send logs', async () => {
|
|
2351
|
+
const io = new CapturedIo()
|
|
2352
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
|
|
2353
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2354
|
+
const deps = makeDeps(io, client)
|
|
2355
|
+
deps.env = {
|
|
2356
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
2357
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2358
|
+
}
|
|
2359
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
|
|
2360
|
+
|
|
2361
|
+
expect(
|
|
2362
|
+
await sendCommand(deps, {
|
|
2363
|
+
kind: 'update',
|
|
2364
|
+
title: 'SECRET_SEND_TITLE',
|
|
2365
|
+
body: 'SECRET_SEND_BODY',
|
|
2366
|
+
}),
|
|
2367
|
+
).toBe(EXIT.ok)
|
|
2368
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
2369
|
+
expect(raw).not.toContain('SECRET_SEND_TITLE')
|
|
2370
|
+
expect(raw).not.toContain('SECRET_SEND_BODY')
|
|
2371
|
+
const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
|
|
2372
|
+
expect(submitted[0]?.data).toMatchObject({
|
|
2373
|
+
request_id: receipt.request_id,
|
|
2374
|
+
title_chars: 'SECRET_SEND_TITLE'.length,
|
|
2375
|
+
})
|
|
2376
|
+
})
|
|
2377
|
+
|
|
2378
|
+
it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
|
|
2379
|
+
const io = new CapturedIo()
|
|
2380
|
+
expect(
|
|
2381
|
+
await sendCommand(makeDeps(io, {} as ApiClient), {
|
|
2382
|
+
kind: 'update',
|
|
2383
|
+
title: 'Escaped',
|
|
2384
|
+
body: 'line1\\nline2',
|
|
2385
|
+
}),
|
|
2386
|
+
).toBe(EXIT.usage)
|
|
2387
|
+
expect(io.errLines.join('\n')).toContain('--body-file -')
|
|
2388
|
+
expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
|
|
2389
|
+
|
|
2390
|
+
io.errLines = []
|
|
2391
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2392
|
+
expect(
|
|
2393
|
+
await sendCommand(makeDeps(io, client), {
|
|
2394
|
+
kind: 'update',
|
|
2395
|
+
title: 'Escaped',
|
|
2396
|
+
body: 'line1\\nline2',
|
|
2397
|
+
literalBackslashN: true,
|
|
2398
|
+
}),
|
|
2399
|
+
).toBe(EXIT.ok)
|
|
2400
|
+
})
|
|
2401
|
+
})
|
|
2402
|
+
|
|
1998
2403
|
describe('delivery evidence status', () => {
|
|
1999
2404
|
function snapshot(
|
|
2000
2405
|
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
2001
2406
|
): EvidenceSnapshot {
|
|
2002
2407
|
return {
|
|
2003
2408
|
request_id: 'req_status_test',
|
|
2004
|
-
event: 'tests_passed',
|
|
2005
2409
|
accepted_at: '2026-08-05T13:05:48.000Z',
|
|
2006
2410
|
overall: 'provider_accepted_all',
|
|
2007
2411
|
deliveries: [
|
|
@@ -2097,6 +2501,53 @@ describe('delivery evidence status', () => {
|
|
|
2097
2501
|
expect(said).toContain('Reply received: yes')
|
|
2098
2502
|
expect(said).toContain('answers are on the server')
|
|
2099
2503
|
})
|
|
2504
|
+
|
|
2505
|
+
it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
|
|
2506
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
|
|
2507
|
+
const evidence = snapshot({
|
|
2508
|
+
state: 'observed',
|
|
2509
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
2510
|
+
latency_ms: 1_000,
|
|
2511
|
+
})
|
|
2512
|
+
const device = {
|
|
2513
|
+
device_id: evidence.deliveries[0]!.device_id,
|
|
2514
|
+
display_name: 'iPhone',
|
|
2515
|
+
platform: 'ios' as const,
|
|
2516
|
+
permission_status: 'authorized',
|
|
2517
|
+
registration_healthy: true,
|
|
2518
|
+
app_version: '0.1.0',
|
|
2519
|
+
app_build: '42',
|
|
2520
|
+
os_version: '19.0',
|
|
2521
|
+
capabilities: ['answer'] as const,
|
|
2522
|
+
support: currentSupport,
|
|
2523
|
+
support_state: 'current' as const,
|
|
2524
|
+
derived_status: 'working' as const,
|
|
2525
|
+
status_message: null,
|
|
2526
|
+
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2527
|
+
}
|
|
2528
|
+
const client = {
|
|
2529
|
+
health: async () => true,
|
|
2530
|
+
listDevices: async () => ({ devices: [device] }),
|
|
2531
|
+
accessStatus: async () => ({
|
|
2532
|
+
status: 'active',
|
|
2533
|
+
reason: 'alpha_grant',
|
|
2534
|
+
expires_at: null,
|
|
2535
|
+
email: 'proof@example.com',
|
|
2536
|
+
}),
|
|
2537
|
+
evidence: async () => evidence,
|
|
2538
|
+
} as unknown as ApiClient
|
|
2539
|
+
const io = new CapturedIo()
|
|
2540
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
2541
|
+
|
|
2542
|
+
expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
|
|
2543
|
+
|
|
2544
|
+
const doctorIo = new CapturedIo()
|
|
2545
|
+
expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
|
|
2546
|
+
const report = JSON.parse(doctorIo.outLines.join('\n')) as {
|
|
2547
|
+
states: { id: string; status: string }[]
|
|
2548
|
+
}
|
|
2549
|
+
expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
|
|
2550
|
+
})
|
|
2100
2551
|
})
|
|
2101
2552
|
|
|
2102
2553
|
describe('Cursor hook commands', () => {
|
|
@@ -2122,12 +2573,17 @@ describe('Cursor hook commands', () => {
|
|
|
2122
2573
|
expect(Object.keys(installed.hooks).sort()).toEqual([
|
|
2123
2574
|
'beforeSubmitPrompt',
|
|
2124
2575
|
'sessionEnd',
|
|
2576
|
+
'sessionStart',
|
|
2125
2577
|
'stop',
|
|
2126
2578
|
])
|
|
2127
2579
|
expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
|
|
2128
2580
|
'hook user-prompt-submit --owner notifai --harness cursor',
|
|
2129
2581
|
)
|
|
2130
2582
|
expect(installed.hooks['stop']?.[0]).toMatchObject({
|
|
2583
|
+
command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
|
|
2584
|
+
loop_limit: 1,
|
|
2585
|
+
})
|
|
2586
|
+
expect(installed.hooks['stop']?.[1]).toMatchObject({
|
|
2131
2587
|
command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
|
|
2132
2588
|
loop_limit: 3,
|
|
2133
2589
|
})
|
|
@@ -2163,7 +2619,7 @@ describe('Cursor hook commands', () => {
|
|
|
2163
2619
|
expect(io.outLines).toContain(
|
|
2164
2620
|
`ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
|
|
2165
2621
|
)
|
|
2166
|
-
expect(io.outLines.some((line) => line.includes('Cursor:
|
|
2622
|
+
expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
|
|
2167
2623
|
})
|
|
2168
2624
|
|
|
2169
2625
|
it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
|
|
@@ -2242,13 +2698,7 @@ describe('Codex hook representation', () => {
|
|
|
2242
2698
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
2243
2699
|
})
|
|
2244
2700
|
|
|
2245
|
-
|
|
2246
|
-
* The foreign `hooks.json` stays byte-identical and Notifai lands inline.
|
|
2247
|
-
* Codex will now warn that the layer uses both representations; the install
|
|
2248
|
-
* output says why, because that warning is Codex reporting a merge, not a
|
|
2249
|
-
* fault, and a silent one would read as Notifai's bug.
|
|
2250
|
-
*/
|
|
2251
|
-
it('leaves a foreign hooks.json alone and installs inline instead', () => {
|
|
2701
|
+
it('joins a populated hooks.json without introducing a second representation', () => {
|
|
2252
2702
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
2253
2703
|
const layer = path.join(cwd, '.codex')
|
|
2254
2704
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -2266,17 +2716,12 @@ describe('Codex hook representation', () => {
|
|
|
2266
2716
|
|
|
2267
2717
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2268
2718
|
|
|
2269
|
-
const
|
|
2270
|
-
expect(
|
|
2271
|
-
expect(
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
expect(
|
|
2275
|
-
expect(after).toContain('--owner notifai')
|
|
2276
|
-
expect(io.outLines.join('\n')).toContain(toml)
|
|
2277
|
-
expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
|
|
2278
|
-
expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
|
|
2279
|
-
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
2719
|
+
const afterJson = readFileSync(json, 'utf8')
|
|
2720
|
+
expect(afterJson).toContain('gdh-stop')
|
|
2721
|
+
expect(afterJson).toContain('--owner notifai')
|
|
2722
|
+
expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
|
|
2723
|
+
expect(io.outLines.join('\n')).toContain(json)
|
|
2724
|
+
expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
|
|
2280
2725
|
})
|
|
2281
2726
|
|
|
2282
2727
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -2293,7 +2738,51 @@ describe('Codex hook representation', () => {
|
|
|
2293
2738
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
2294
2739
|
})
|
|
2295
2740
|
|
|
2296
|
-
|
|
2741
|
+
it('leaves config.toml byte-identical on a no-op reinstall', () => {
|
|
2742
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
|
|
2743
|
+
const io = new CapturedIo()
|
|
2744
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2745
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2746
|
+
const toml = path.join(cwd, '.codex', 'config.toml')
|
|
2747
|
+
const before = readFileSync(toml, 'utf8')
|
|
2748
|
+
io.outLines = []
|
|
2749
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2750
|
+
expect(readFileSync(toml, 'utf8')).toBe(before)
|
|
2751
|
+
})
|
|
2752
|
+
|
|
2753
|
+
it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
|
|
2754
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
|
|
2755
|
+
const layer = path.join(cwd, '.codex')
|
|
2756
|
+
const json = path.join(layer, 'hooks.json')
|
|
2757
|
+
mkdirSync(layer, { recursive: true })
|
|
2758
|
+
applyPlan(json, {
|
|
2759
|
+
hooks: {
|
|
2760
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
|
|
2761
|
+
},
|
|
2762
|
+
})
|
|
2763
|
+
const io = new CapturedIo()
|
|
2764
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2765
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
2766
|
+
expect(existsSync(json)).toBe(false)
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
it('global install does not leave a dead project .codex after uninstalling that layer', () => {
|
|
2770
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
|
|
2771
|
+
const io = new CapturedIo()
|
|
2772
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2773
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2774
|
+
expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
|
|
2775
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
|
|
2776
|
+
EXIT.ok,
|
|
2777
|
+
)
|
|
2778
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
2779
|
+
const leftover = existsSync(path.join(cwd, '.codex'))
|
|
2780
|
+
? readdirSync(path.join(cwd, '.codex'))
|
|
2781
|
+
: []
|
|
2782
|
+
expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
|
|
2783
|
+
})
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2297
2786
|
* Notifai in one file, someone else's hooks in the other: every handler
|
|
2298
2787
|
* fires once, so doctor has nothing to report and the foreign file is not
|
|
2299
2788
|
* ours to rewrite. Doctor used to fail here — the check read "two files" as
|
|
@@ -2386,7 +2875,7 @@ describe('harness activation guidance', () => {
|
|
|
2386
2875
|
const execPath = process.execPath
|
|
2387
2876
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
2388
2877
|
|
|
2389
|
-
it('
|
|
2878
|
+
it('requires a fresh Claude Code session so SessionStart can activate it', () => {
|
|
2390
2879
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
2391
2880
|
const io = new CapturedIo()
|
|
2392
2881
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2397,11 +2886,11 @@ describe('harness activation guidance', () => {
|
|
|
2397
2886
|
|
|
2398
2887
|
const output = io.outLines.join('\n')
|
|
2399
2888
|
expect(output).toContain('Installed claude-code hooks in')
|
|
2400
|
-
expect(output).toContain('
|
|
2889
|
+
expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
|
|
2401
2890
|
expect(output).not.toMatch(/timeout|asynchronous|600s/i)
|
|
2402
2891
|
})
|
|
2403
2892
|
|
|
2404
|
-
it('
|
|
2893
|
+
it('names Codex trust and fresh-session activation in the correct order', () => {
|
|
2405
2894
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
|
|
2406
2895
|
const io = new CapturedIo()
|
|
2407
2896
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2411,8 +2900,8 @@ describe('harness activation guidance', () => {
|
|
|
2411
2900
|
)
|
|
2412
2901
|
|
|
2413
2902
|
const output = io.outLines.join('\n')
|
|
2414
|
-
expect(output).toContain('
|
|
2415
|
-
expect(output).
|
|
2903
|
+
expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
|
|
2904
|
+
expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
|
|
2416
2905
|
})
|
|
2417
2906
|
|
|
2418
2907
|
it('names the installed harness in the close, never a different one', () => {
|
|
@@ -2424,7 +2913,7 @@ describe('harness activation guidance', () => {
|
|
|
2424
2913
|
|
|
2425
2914
|
const output = io.outLines.join('\n')
|
|
2426
2915
|
expect(output).toContain('Installed cursor hooks in')
|
|
2427
|
-
expect(output).toContain('
|
|
2916
|
+
expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
|
|
2428
2917
|
expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
|
|
2429
2918
|
expect(output).not.toMatch(/timeout|worktree|fails closed/i)
|
|
2430
2919
|
})
|
|
@@ -2487,7 +2976,7 @@ describe('harness activation guidance', () => {
|
|
|
2487
2976
|
)
|
|
2488
2977
|
|
|
2489
2978
|
expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
|
|
2490
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2979
|
+
expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
|
|
2491
2980
|
expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
|
|
2492
2981
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
2493
2982
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
@@ -2632,6 +3121,141 @@ describe('stable hook installation', () => {
|
|
|
2632
3121
|
findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
|
|
2633
3122
|
).toEqual([expect.objectContaining({ global: true })])
|
|
2634
3123
|
})
|
|
3124
|
+
|
|
3125
|
+
it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
|
|
3126
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
|
|
3127
|
+
const io = new CapturedIo()
|
|
3128
|
+
const env = isolatedEnv(cwd)
|
|
3129
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3130
|
+
const execPath = process.execPath
|
|
3131
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3132
|
+
|
|
3133
|
+
expect(
|
|
3134
|
+
hooksInstallCommand(deps, {
|
|
3135
|
+
harness: 'cursor',
|
|
3136
|
+
global: true,
|
|
3137
|
+
execPath,
|
|
3138
|
+
scriptPath,
|
|
3139
|
+
}),
|
|
3140
|
+
).toBe(EXIT.ok)
|
|
3141
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3142
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3143
|
+
hooks: { stop: Array<{ command?: string }> }
|
|
3144
|
+
}
|
|
3145
|
+
settings.hooks.stop = settings.hooks.stop.filter(
|
|
3146
|
+
(handler) => !handler.command?.includes(' hook activation-stop '),
|
|
3147
|
+
)
|
|
3148
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3149
|
+
|
|
3150
|
+
io.outLines = []
|
|
3151
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3152
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3153
|
+
const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
|
|
3154
|
+
(item) => item.harness === 'cursor' && item.global,
|
|
3155
|
+
)
|
|
3156
|
+
expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
|
|
3157
|
+
'activation-stop',
|
|
3158
|
+
)
|
|
3159
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3160
|
+
})
|
|
3161
|
+
|
|
3162
|
+
it('refreshes a stale global Stop shape instead of declining a project install', () => {
|
|
3163
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
|
|
3164
|
+
const io = new CapturedIo()
|
|
3165
|
+
const env = isolatedEnv(cwd)
|
|
3166
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3167
|
+
const execPath = process.execPath
|
|
3168
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3169
|
+
|
|
3170
|
+
expect(
|
|
3171
|
+
hooksInstallCommand(deps, {
|
|
3172
|
+
harness: 'cursor',
|
|
3173
|
+
global: true,
|
|
3174
|
+
execPath,
|
|
3175
|
+
scriptPath,
|
|
3176
|
+
}),
|
|
3177
|
+
).toBe(EXIT.ok)
|
|
3178
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3179
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3180
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3181
|
+
}
|
|
3182
|
+
const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
|
|
3183
|
+
expect(stop).toBeDefined()
|
|
3184
|
+
stop!.timeout = 1
|
|
3185
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3186
|
+
|
|
3187
|
+
io.outLines = []
|
|
3188
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3189
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3190
|
+
const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3191
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3192
|
+
}
|
|
3193
|
+
expect(
|
|
3194
|
+
refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
|
|
3195
|
+
).toBeGreaterThan(1)
|
|
3196
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3197
|
+
})
|
|
3198
|
+
|
|
3199
|
+
it('consolidates duplicate global Codex representations before declining a project install', () => {
|
|
3200
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
|
|
3201
|
+
const io = new CapturedIo()
|
|
3202
|
+
const env = isolatedEnv(cwd)
|
|
3203
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3204
|
+
const execPath = process.execPath
|
|
3205
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3206
|
+
|
|
3207
|
+
expect(
|
|
3208
|
+
hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
|
|
3209
|
+
).toBe(EXIT.ok)
|
|
3210
|
+
const json = path.join(env.CODEX_HOME!, 'hooks.json')
|
|
3211
|
+
applyPlan(json, {
|
|
3212
|
+
hooks: {
|
|
3213
|
+
...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
|
|
3214
|
+
PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
|
|
3215
|
+
},
|
|
3216
|
+
})
|
|
3217
|
+
|
|
3218
|
+
io.outLines = []
|
|
3219
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3220
|
+
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
3221
|
+
expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
|
|
3222
|
+
expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
|
|
3223
|
+
expect(
|
|
3224
|
+
findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
3225
|
+
(installation) => installation.harness === 'codex',
|
|
3226
|
+
),
|
|
3227
|
+
).toEqual([expect.objectContaining({ global: true })])
|
|
3228
|
+
expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
|
|
3229
|
+
})
|
|
3230
|
+
|
|
3231
|
+
it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
|
|
3232
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
|
|
3233
|
+
const io = new CapturedIo()
|
|
3234
|
+
const env = isolatedEnv(cwd)
|
|
3235
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3236
|
+
const execPath = process.execPath
|
|
3237
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3238
|
+
|
|
3239
|
+
expect(
|
|
3240
|
+
hooksInstallCommand(deps, {
|
|
3241
|
+
harness: 'opencode',
|
|
3242
|
+
global: true,
|
|
3243
|
+
execPath,
|
|
3244
|
+
scriptPath,
|
|
3245
|
+
}),
|
|
3246
|
+
).toBe(EXIT.ok)
|
|
3247
|
+
const globalFile = settingsFile('opencode', true, cwd, env)
|
|
3248
|
+
const current = readFileSync(globalFile, 'utf8')
|
|
3249
|
+
writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 10', 'const ADAPTER_VERSION = 8'))
|
|
3250
|
+
|
|
3251
|
+
io.outLines = []
|
|
3252
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3253
|
+
expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
|
|
3254
|
+
const refreshed = readFileSync(globalFile, 'utf8')
|
|
3255
|
+
expect(refreshed).toContain('const ADAPTER_VERSION = 10')
|
|
3256
|
+
expect(refreshed).toContain('experimental.chat.system.transform')
|
|
3257
|
+
expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
|
|
3258
|
+
})
|
|
2635
3259
|
})
|
|
2636
3260
|
|
|
2637
3261
|
describe('projectSlugFrom', () => {
|
|
@@ -2967,7 +3591,7 @@ describe('interactive command UX', () => {
|
|
|
2967
3591
|
beginPairing: async () => ({
|
|
2968
3592
|
pairing_id: 'pair_test',
|
|
2969
3593
|
code: 'ABCD-EFGH',
|
|
2970
|
-
approve_url: 'https://
|
|
3594
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2971
3595
|
expires_at: new Date(10_000).toISOString(),
|
|
2972
3596
|
poll_interval_seconds: 1,
|
|
2973
3597
|
}),
|
|
@@ -2997,7 +3621,9 @@ describe('interactive command UX', () => {
|
|
|
2997
3621
|
expect(io.notes).toEqual([
|
|
2998
3622
|
{
|
|
2999
3623
|
title: 'Approve this machine',
|
|
3000
|
-
message:
|
|
3624
|
+
message: expect.stringMatching(
|
|
3625
|
+
/^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3626
|
+
),
|
|
3001
3627
|
},
|
|
3002
3628
|
])
|
|
3003
3629
|
expect(io.spinnerEvents).toEqual([
|
|
@@ -3016,7 +3642,7 @@ describe('interactive command UX', () => {
|
|
|
3016
3642
|
beginPairing: async () => ({
|
|
3017
3643
|
pairing_id: 'pair_test',
|
|
3018
3644
|
code: 'ABCD-EFGH',
|
|
3019
|
-
approve_url: 'https://
|
|
3645
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
3020
3646
|
expires_at: new Date(10_000).toISOString(),
|
|
3021
3647
|
poll_interval_seconds: 1,
|
|
3022
3648
|
}),
|
|
@@ -3033,7 +3659,9 @@ describe('interactive command UX', () => {
|
|
|
3033
3659
|
expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
|
|
3034
3660
|
expect(io.outLines.slice(0, 3)).toEqual([
|
|
3035
3661
|
'Pairing code: ABCD-EFGH',
|
|
3036
|
-
|
|
3662
|
+
expect.stringMatching(
|
|
3663
|
+
/^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3664
|
+
),
|
|
3037
3665
|
'Waiting for approval…',
|
|
3038
3666
|
])
|
|
3039
3667
|
})
|
|
@@ -3174,6 +3802,26 @@ describe('interactive command UX', () => {
|
|
|
3174
3802
|
expect(io.intros).toEqual([])
|
|
3175
3803
|
expect(io.checks).toEqual([])
|
|
3176
3804
|
})
|
|
3805
|
+
|
|
3806
|
+
it('consults npm latest from an interactive doctor without failing the run', async () => {
|
|
3807
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
|
|
3808
|
+
const io = new InteractiveIo()
|
|
3809
|
+
const client = {
|
|
3810
|
+
health: async () => true,
|
|
3811
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3812
|
+
} as unknown as ApiClient
|
|
3813
|
+
const deps: CommandDeps = {
|
|
3814
|
+
...makeDeps(io, client),
|
|
3815
|
+
cwd,
|
|
3816
|
+
env: isolatedEnv(cwd),
|
|
3817
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
3818
|
+
fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
3822
|
+
expect(io.outLines).toContain('A newer Notifai is available.')
|
|
3823
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3824
|
+
})
|
|
3177
3825
|
})
|
|
3178
3826
|
|
|
3179
3827
|
describe('compatibility-first update guidance', () => {
|
|
@@ -3421,7 +4069,6 @@ describe('init', () => {
|
|
|
3421
4069
|
): EvidenceSnapshot {
|
|
3422
4070
|
return {
|
|
3423
4071
|
request_id: requestId,
|
|
3424
|
-
event: 'setup_verified',
|
|
3425
4072
|
accepted_at: '2026-08-05T18:00:00.000Z',
|
|
3426
4073
|
overall: 'provider_accepted_all',
|
|
3427
4074
|
deliveries: [
|
|
@@ -3659,18 +4306,22 @@ describe('init', () => {
|
|
|
3659
4306
|
} as unknown as ApiClient
|
|
3660
4307
|
const nativeSkills: NativeSkills = {
|
|
3661
4308
|
add: async () => 0,
|
|
4309
|
+
remove: async () => 0,
|
|
3662
4310
|
list: async (scope) => ({
|
|
3663
|
-
skills:
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
4311
|
+
skills:
|
|
4312
|
+
scope === 'project'
|
|
4313
|
+
? [
|
|
4314
|
+
{
|
|
4315
|
+
name: 'notifai',
|
|
4316
|
+
scope,
|
|
4317
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4318
|
+
source: 'Raidiant-io/notifai',
|
|
4319
|
+
sourceType: 'github',
|
|
4320
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
4321
|
+
ref: RELEASE_REF,
|
|
4322
|
+
},
|
|
4323
|
+
]
|
|
4324
|
+
: [],
|
|
3674
4325
|
}),
|
|
3675
4326
|
}
|
|
3676
4327
|
const readiness = await assessReadiness(
|
|
@@ -3696,9 +4347,10 @@ describe('init', () => {
|
|
|
3696
4347
|
const calls: SkillScope[] = []
|
|
3697
4348
|
const nativeSkills: NativeSkills = {
|
|
3698
4349
|
add: async () => 0,
|
|
4350
|
+
remove: async () => 0,
|
|
3699
4351
|
list: async (selected) => {
|
|
3700
4352
|
calls.push(selected)
|
|
3701
|
-
return { skills: [managedSkill(selected, cwd)] }
|
|
4353
|
+
return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
|
|
3702
4354
|
},
|
|
3703
4355
|
}
|
|
3704
4356
|
|
|
@@ -3710,7 +4362,7 @@ describe('init', () => {
|
|
|
3710
4362
|
status: 'ready',
|
|
3711
4363
|
detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
|
|
3712
4364
|
})
|
|
3713
|
-
expect(calls).toEqual([
|
|
4365
|
+
expect(calls).toEqual(['project', 'global'])
|
|
3714
4366
|
},
|
|
3715
4367
|
)
|
|
3716
4368
|
|
|
@@ -3726,8 +4378,12 @@ describe('init', () => {
|
|
|
3726
4378
|
} as unknown as ApiClient
|
|
3727
4379
|
const nativeSkills: NativeSkills = {
|
|
3728
4380
|
add: async () => 0,
|
|
4381
|
+
remove: async () => 0,
|
|
3729
4382
|
list: async (selected) => ({
|
|
3730
|
-
skills:
|
|
4383
|
+
skills:
|
|
4384
|
+
selected === scope
|
|
4385
|
+
? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
|
|
4386
|
+
: [],
|
|
3731
4387
|
}),
|
|
3732
4388
|
}
|
|
3733
4389
|
|
|
@@ -3742,6 +4398,192 @@ describe('init', () => {
|
|
|
3742
4398
|
},
|
|
3743
4399
|
)
|
|
3744
4400
|
|
|
4401
|
+
it('reports a duplicate notifai skill across project and global scope', async () => {
|
|
4402
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
|
|
4403
|
+
const io = new CapturedIo()
|
|
4404
|
+
const client = {
|
|
4405
|
+
health: async () => true,
|
|
4406
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4407
|
+
listDevices: async () => ({ devices: [] }),
|
|
4408
|
+
} as unknown as ApiClient
|
|
4409
|
+
const nativeSkills: NativeSkills = {
|
|
4410
|
+
add: async () => 0,
|
|
4411
|
+
remove: async () => 0,
|
|
4412
|
+
list: async (scope) => ({
|
|
4413
|
+
skills: [
|
|
4414
|
+
{
|
|
4415
|
+
...managedSkill(scope, cwd),
|
|
4416
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4417
|
+
path:
|
|
4418
|
+
scope === 'project'
|
|
4419
|
+
? path.join(cwd, '.agents', 'skills', 'notifai')
|
|
4420
|
+
: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4421
|
+
},
|
|
4422
|
+
],
|
|
4423
|
+
}),
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
|
|
4427
|
+
const skill = readiness.states.find((state) => state.id === 'skill')
|
|
4428
|
+
expect(skill).toMatchObject({
|
|
4429
|
+
status: 'gap',
|
|
4430
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4431
|
+
technical: {
|
|
4432
|
+
project: {
|
|
4433
|
+
ref: RELEASE_REF,
|
|
4434
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4435
|
+
current: true,
|
|
4436
|
+
},
|
|
4437
|
+
global: {
|
|
4438
|
+
ref: 'v0.2.1',
|
|
4439
|
+
path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4440
|
+
current: false,
|
|
4441
|
+
},
|
|
4442
|
+
resolution: 'both-listed',
|
|
4443
|
+
},
|
|
4444
|
+
})
|
|
4445
|
+
|
|
4446
|
+
expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
|
|
4447
|
+
EXIT.failed,
|
|
4448
|
+
)
|
|
4449
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4450
|
+
ok: boolean
|
|
4451
|
+
states: Array<{ id: string; status: string; detail: string }>
|
|
4452
|
+
}
|
|
4453
|
+
expect(payload.ok).toBe(false)
|
|
4454
|
+
expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4455
|
+
status: 'gap',
|
|
4456
|
+
detail: expect.stringContaining('harness lists both'),
|
|
4457
|
+
})
|
|
4458
|
+
})
|
|
4459
|
+
|
|
4460
|
+
it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
|
|
4461
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
|
|
4462
|
+
const io = new CapturedIo()
|
|
4463
|
+
const client = {
|
|
4464
|
+
health: async () => true,
|
|
4465
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4466
|
+
listDevices: async () => ({ devices: [] }),
|
|
4467
|
+
} as unknown as ApiClient
|
|
4468
|
+
const nativeSkills: NativeSkills = {
|
|
4469
|
+
add: async () => 0,
|
|
4470
|
+
remove: async () => 0,
|
|
4471
|
+
list: async (scope) => ({
|
|
4472
|
+
skills: [
|
|
4473
|
+
{
|
|
4474
|
+
...managedSkill(scope, cwd),
|
|
4475
|
+
ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
|
|
4476
|
+
},
|
|
4477
|
+
],
|
|
4478
|
+
}),
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
const readiness = await assessReadiness(
|
|
4482
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
4483
|
+
{ skillScope: 'global' },
|
|
4484
|
+
)
|
|
4485
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4486
|
+
status: 'gap',
|
|
4487
|
+
detail: `project (v0.2.1) and global (${RELEASE_REF}) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4488
|
+
})
|
|
4489
|
+
})
|
|
4490
|
+
|
|
4491
|
+
it('uninstalls the other skill scope before installing the chosen one', async () => {
|
|
4492
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
|
|
4493
|
+
const io = new CapturedIo()
|
|
4494
|
+
const removed: SkillScope[] = []
|
|
4495
|
+
const added: Array<SkillScope | undefined> = []
|
|
4496
|
+
let globalPresent = true
|
|
4497
|
+
const nativeSkills: NativeSkills = {
|
|
4498
|
+
add: async (options) => {
|
|
4499
|
+
added.push(options.scope)
|
|
4500
|
+
return 0
|
|
4501
|
+
},
|
|
4502
|
+
remove: async (options) => {
|
|
4503
|
+
removed.push(options.scope)
|
|
4504
|
+
if (options.scope === 'global') globalPresent = false
|
|
4505
|
+
return 0
|
|
4506
|
+
},
|
|
4507
|
+
list: async (scope) => ({
|
|
4508
|
+
skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4509
|
+
}),
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
expect(
|
|
4513
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4514
|
+
skills: true,
|
|
4515
|
+
setupScope: 'project',
|
|
4516
|
+
hooks: false,
|
|
4517
|
+
}),
|
|
4518
|
+
).toBe(EXIT.ok)
|
|
4519
|
+
expect(removed).toEqual(['global'])
|
|
4520
|
+
expect(added).toEqual(['project'])
|
|
4521
|
+
})
|
|
4522
|
+
|
|
4523
|
+
it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
|
|
4524
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
|
|
4525
|
+
const io = new CapturedIo()
|
|
4526
|
+
const removed: SkillScope[] = []
|
|
4527
|
+
const present = new Set<SkillScope>(['project', 'global'])
|
|
4528
|
+
const nativeSkills: NativeSkills = {
|
|
4529
|
+
add: async (options) => {
|
|
4530
|
+
if (options.scope !== undefined) present.add(options.scope)
|
|
4531
|
+
return 0
|
|
4532
|
+
},
|
|
4533
|
+
remove: async (options) => {
|
|
4534
|
+
removed.push(options.scope)
|
|
4535
|
+
present.delete(options.scope)
|
|
4536
|
+
return 0
|
|
4537
|
+
},
|
|
4538
|
+
list: async (scope) => ({
|
|
4539
|
+
skills: present.has(scope)
|
|
4540
|
+
? [
|
|
4541
|
+
{
|
|
4542
|
+
...managedSkill(scope, cwd),
|
|
4543
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4544
|
+
},
|
|
4545
|
+
]
|
|
4546
|
+
: [],
|
|
4547
|
+
}),
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
expect(
|
|
4551
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4552
|
+
skills: true,
|
|
4553
|
+
setupScope: 'project',
|
|
4554
|
+
hooks: false,
|
|
4555
|
+
}),
|
|
4556
|
+
).toBe(EXIT.ok)
|
|
4557
|
+
expect(removed).toEqual(['global'])
|
|
4558
|
+
expect([...present]).toEqual(['project'])
|
|
4559
|
+
})
|
|
4560
|
+
|
|
4561
|
+
it('updates the existing skill scope instead of adding a second copy', async () => {
|
|
4562
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
|
|
4563
|
+
const io = new InteractiveIo()
|
|
4564
|
+
const received: Array<SkillScope | undefined> = []
|
|
4565
|
+
const installed = true
|
|
4566
|
+
const nativeSkills: NativeSkills = {
|
|
4567
|
+
add: async (options) => {
|
|
4568
|
+
received.push(options.scope)
|
|
4569
|
+
return 0
|
|
4570
|
+
},
|
|
4571
|
+
remove: async () => 0,
|
|
4572
|
+
list: async (scope) => ({
|
|
4573
|
+
skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4574
|
+
}),
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
expect(
|
|
4578
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4579
|
+
skills: true,
|
|
4580
|
+
hooks: false,
|
|
4581
|
+
}),
|
|
4582
|
+
).toBe(EXIT.ok)
|
|
4583
|
+
expect(received).toEqual(['global'])
|
|
4584
|
+
expect(io.outLines.join('\n')).toContain('global scope')
|
|
4585
|
+
})
|
|
4586
|
+
|
|
3745
4587
|
it('requires an explicit skill scope before unattended installation', async () => {
|
|
3746
4588
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
|
|
3747
4589
|
const io = new CapturedIo()
|
|
@@ -3751,6 +4593,7 @@ describe('init', () => {
|
|
|
3751
4593
|
addCalls += 1
|
|
3752
4594
|
return 0
|
|
3753
4595
|
},
|
|
4596
|
+
remove: async () => 0,
|
|
3754
4597
|
list: async () => ({ skills: [] }),
|
|
3755
4598
|
}
|
|
3756
4599
|
|
|
@@ -3758,7 +4601,7 @@ describe('init', () => {
|
|
|
3758
4601
|
EXIT.usage,
|
|
3759
4602
|
)
|
|
3760
4603
|
expect(addCalls).toBe(0)
|
|
3761
|
-
expect(io.errLines.join('\n')).toContain('--
|
|
4604
|
+
expect(io.errLines.join('\n')).toContain('--setup-scope project')
|
|
3762
4605
|
})
|
|
3763
4606
|
|
|
3764
4607
|
it('rejects an invalid unattended skill scope instead of guessing', async () => {
|
|
@@ -3786,6 +4629,7 @@ describe('init', () => {
|
|
|
3786
4629
|
installed = true
|
|
3787
4630
|
return 0
|
|
3788
4631
|
},
|
|
4632
|
+
remove: async () => 0,
|
|
3789
4633
|
list: async (selected) => ({
|
|
3790
4634
|
skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
|
|
3791
4635
|
}),
|
|
@@ -3802,27 +4646,82 @@ describe('init', () => {
|
|
|
3802
4646
|
},
|
|
3803
4647
|
)
|
|
3804
4648
|
|
|
3805
|
-
it('
|
|
3806
|
-
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-
|
|
4649
|
+
it('asks one setup-scope question and passes that scope to the native installer', async () => {
|
|
4650
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
|
|
3807
4651
|
const io = new InteractiveIo()
|
|
4652
|
+
io.selectAnswer = 'project'
|
|
3808
4653
|
const calls: { submit: number } = { submit: 0 }
|
|
3809
|
-
let receivedScope: SkillScope | undefined
|
|
4654
|
+
let receivedScope: SkillScope | undefined
|
|
3810
4655
|
const nativeSkills: NativeSkills = {
|
|
3811
4656
|
add: async (options) => {
|
|
3812
4657
|
receivedScope = options.scope
|
|
3813
4658
|
return 0
|
|
3814
4659
|
},
|
|
4660
|
+
remove: async () => 0,
|
|
3815
4661
|
list: async () => ({ skills: [] }),
|
|
3816
4662
|
}
|
|
3817
4663
|
|
|
3818
4664
|
expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
|
|
3819
4665
|
EXIT.ok,
|
|
3820
4666
|
)
|
|
3821
|
-
expect(
|
|
4667
|
+
expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
|
|
4668
|
+
expect(receivedScope).toBe('project')
|
|
3822
4669
|
expect(calls.submit).toBe(1)
|
|
3823
4670
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
3824
4671
|
})
|
|
3825
4672
|
|
|
4673
|
+
it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
|
|
4674
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
|
|
4675
|
+
const io = new CapturedIo()
|
|
4676
|
+
let receivedScope: SkillScope | undefined
|
|
4677
|
+
const nativeSkills: NativeSkills = {
|
|
4678
|
+
add: async (options) => {
|
|
4679
|
+
receivedScope = options.scope
|
|
4680
|
+
return 0
|
|
4681
|
+
},
|
|
4682
|
+
remove: async () => 0,
|
|
4683
|
+
list: async (scope) => ({
|
|
4684
|
+
skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
|
|
4685
|
+
}),
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
expect(
|
|
4689
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4690
|
+
skills: true,
|
|
4691
|
+
setupScope: 'project',
|
|
4692
|
+
hooks: false,
|
|
4693
|
+
}),
|
|
4694
|
+
).toBe(EXIT.ok)
|
|
4695
|
+
expect(receivedScope).toBe('project')
|
|
4696
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
|
|
4697
|
+
})
|
|
4698
|
+
|
|
4699
|
+
it('stamps project identity outside the repo when setup-scope is global', async () => {
|
|
4700
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
|
|
4701
|
+
const io = new CapturedIo()
|
|
4702
|
+
const deps: CommandDeps = {
|
|
4703
|
+
...makeDeps(io, {} as ApiClient),
|
|
4704
|
+
cwd,
|
|
4705
|
+
env: isolatedEnv(cwd),
|
|
4706
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
|
|
4710
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
|
|
4711
|
+
expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
|
|
4712
|
+
})
|
|
4713
|
+
|
|
4714
|
+
it('rejects disagreeing --setup-scope and --skills-scope', async () => {
|
|
4715
|
+
const io = new CapturedIo()
|
|
4716
|
+
expect(
|
|
4717
|
+
await initCommand(
|
|
4718
|
+
{ ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
|
|
4719
|
+
{ skills: true, setupScope: 'project', skillsScope: 'global' },
|
|
4720
|
+
),
|
|
4721
|
+
).toBe(EXIT.usage)
|
|
4722
|
+
expect(io.errLines.join('\n')).toContain('disagree')
|
|
4723
|
+
})
|
|
4724
|
+
|
|
3826
4725
|
it('installs every detected project harness when --hooks is set', async () => {
|
|
3827
4726
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
|
|
3828
4727
|
mkdirSync(path.join(cwd, '.claude'))
|
|
@@ -3831,6 +4730,7 @@ describe('init', () => {
|
|
|
3831
4730
|
const calls = { submit: 0 }
|
|
3832
4731
|
const nativeSkills: NativeSkills = {
|
|
3833
4732
|
add: async () => 0,
|
|
4733
|
+
remove: async () => 0,
|
|
3834
4734
|
list: async () => ({ skills: [] }),
|
|
3835
4735
|
}
|
|
3836
4736
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3855,6 +4755,7 @@ describe('init', () => {
|
|
|
3855
4755
|
const calls = { submit: 0 }
|
|
3856
4756
|
const nativeSkills: NativeSkills = {
|
|
3857
4757
|
add: async () => 0,
|
|
4758
|
+
remove: async () => 0,
|
|
3858
4759
|
list: async () => ({ skills: [] }),
|
|
3859
4760
|
}
|
|
3860
4761
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3872,6 +4773,7 @@ describe('init', () => {
|
|
|
3872
4773
|
const calls: { submit: number } = { submit: 0 }
|
|
3873
4774
|
const nativeSkills: NativeSkills = {
|
|
3874
4775
|
add: async () => 1,
|
|
4776
|
+
remove: async () => 0,
|
|
3875
4777
|
list: async () => ({ skills: [] }),
|
|
3876
4778
|
}
|
|
3877
4779
|
|
|
@@ -4012,6 +4914,7 @@ describe('init', () => {
|
|
|
4012
4914
|
nativeSkills: {
|
|
4013
4915
|
list: async () => ({ skills: [] }),
|
|
4014
4916
|
add: async () => 0,
|
|
4917
|
+
remove: async () => 0,
|
|
4015
4918
|
} satisfies NativeSkills,
|
|
4016
4919
|
}
|
|
4017
4920
|
|
|
@@ -4109,7 +5012,7 @@ describe('init', () => {
|
|
|
4109
5012
|
)
|
|
4110
5013
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4111
5014
|
expect(submitCalls).toBe(1)
|
|
4112
|
-
expect(submittedDraft?.draft
|
|
5015
|
+
expect(submittedDraft?.draft).not.toHaveProperty('event')
|
|
4113
5016
|
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
4114
5017
|
|
|
4115
5018
|
io.outLines = []
|
|
@@ -4267,6 +5170,52 @@ describe('init', () => {
|
|
|
4267
5170
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4268
5171
|
})
|
|
4269
5172
|
|
|
5173
|
+
it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
|
|
5174
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
|
|
5175
|
+
const io = new CapturedIo()
|
|
5176
|
+
let submitCalls = 0
|
|
5177
|
+
const client = {
|
|
5178
|
+
health: async () => true,
|
|
5179
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5180
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
5181
|
+
submit: async () => {
|
|
5182
|
+
submitCalls += 1
|
|
5183
|
+
return setupReceipt('req_fresh')
|
|
5184
|
+
},
|
|
5185
|
+
evidence: async (requestId: string) =>
|
|
5186
|
+
setupEvidence(
|
|
5187
|
+
requestId,
|
|
5188
|
+
requestId === 'req_fresh'
|
|
5189
|
+
? {
|
|
5190
|
+
state: 'observed',
|
|
5191
|
+
observed_at: '2026-08-25T00:00:02.000Z',
|
|
5192
|
+
latency_ms: 1_000,
|
|
5193
|
+
}
|
|
5194
|
+
: { state: 'unknown', observed_at: null, latency_ms: null },
|
|
5195
|
+
),
|
|
5196
|
+
} as unknown as ApiClient
|
|
5197
|
+
const now = Date.parse('2026-08-25T00:00:00.000Z')
|
|
5198
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5199
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
|
|
5200
|
+
const deps: CommandDeps = {
|
|
5201
|
+
...makeDeps(io, client),
|
|
5202
|
+
cwd,
|
|
5203
|
+
env: isolatedEnv(cwd),
|
|
5204
|
+
now: () => now,
|
|
5205
|
+
}
|
|
5206
|
+
writeSetupProof(deps, {
|
|
5207
|
+
request_id: 'req_old',
|
|
5208
|
+
device_id: readyIphone.device_id,
|
|
5209
|
+
project: 'stale-proof',
|
|
5210
|
+
started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
|
|
5211
|
+
})
|
|
5212
|
+
|
|
5213
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5214
|
+
expect(submitCalls).toBe(1)
|
|
5215
|
+
expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
|
|
5216
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5217
|
+
})
|
|
5218
|
+
|
|
4270
5219
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
4271
5220
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
|
|
4272
5221
|
const io = new CapturedIo()
|
|
@@ -4386,42 +5335,6 @@ describe('init', () => {
|
|
|
4386
5335
|
)
|
|
4387
5336
|
})
|
|
4388
5337
|
|
|
4389
|
-
it('stops login when the approval page reports no Alpha access', async () => {
|
|
4390
|
-
const io = new CapturedIo()
|
|
4391
|
-
let now = 0
|
|
4392
|
-
let polls = 0
|
|
4393
|
-
const client = {
|
|
4394
|
-
beginPairing: async () => ({
|
|
4395
|
-
pairing_id: 'pair_no_plan',
|
|
4396
|
-
code: 'NOPE-PLAN',
|
|
4397
|
-
approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
|
|
4398
|
-
expires_at: new Date(60_000).toISOString(),
|
|
4399
|
-
poll_interval_seconds: 1,
|
|
4400
|
-
}),
|
|
4401
|
-
pollPairing: async () => {
|
|
4402
|
-
polls += 1
|
|
4403
|
-
return {
|
|
4404
|
-
status: 'no_active_plan' as const,
|
|
4405
|
-
next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
4406
|
-
}
|
|
4407
|
-
},
|
|
4408
|
-
} as unknown as ApiClient
|
|
4409
|
-
const deps = {
|
|
4410
|
-
...makeDeps(io, client),
|
|
4411
|
-
now: () => now,
|
|
4412
|
-
sleep: async (milliseconds: number) => {
|
|
4413
|
-
now += milliseconds
|
|
4414
|
-
},
|
|
4415
|
-
}
|
|
4416
|
-
|
|
4417
|
-
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
4418
|
-
expect(polls).toBe(1)
|
|
4419
|
-
expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
|
|
4420
|
-
expect(io.errLines.join('\n')).toContain(
|
|
4421
|
-
'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
4422
|
-
)
|
|
4423
|
-
})
|
|
4424
|
-
|
|
4425
5338
|
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
4426
5339
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
|
|
4427
5340
|
const io = new CapturedIo()
|
|
@@ -4600,6 +5513,54 @@ describe('readiness assessment cost', () => {
|
|
|
4600
5513
|
detail: `installed from ${SKILLS_SOURCE} in the project scope`,
|
|
4601
5514
|
})
|
|
4602
5515
|
})
|
|
5516
|
+
|
|
5517
|
+
it('detects a lock-file duplicate across project and global scope', async () => {
|
|
5518
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
|
|
5519
|
+
const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
|
|
5520
|
+
writeFileSync(
|
|
5521
|
+
path.join(cwd, 'skills-lock.json'),
|
|
5522
|
+
`${JSON.stringify({
|
|
5523
|
+
skills: {
|
|
5524
|
+
notifai: {
|
|
5525
|
+
source: 'Raidiant-io/notifai',
|
|
5526
|
+
sourceType: 'github',
|
|
5527
|
+
ref: RELEASE_REF,
|
|
5528
|
+
},
|
|
5529
|
+
},
|
|
5530
|
+
})}\n`,
|
|
5531
|
+
)
|
|
5532
|
+
mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
|
|
5533
|
+
writeFileSync(
|
|
5534
|
+
path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
|
|
5535
|
+
`${JSON.stringify({
|
|
5536
|
+
skills: {
|
|
5537
|
+
notifai: {
|
|
5538
|
+
source: 'Raidiant-io/notifai',
|
|
5539
|
+
sourceType: 'github',
|
|
5540
|
+
ref: 'v0.2.1',
|
|
5541
|
+
},
|
|
5542
|
+
},
|
|
5543
|
+
})}\n`,
|
|
5544
|
+
)
|
|
5545
|
+
const io = new CapturedIo()
|
|
5546
|
+
const client = {
|
|
5547
|
+
health: async () => true,
|
|
5548
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5549
|
+
listDevices: async () => ({ devices: [] }),
|
|
5550
|
+
} as unknown as ApiClient
|
|
5551
|
+
|
|
5552
|
+
const readiness = await assessReadiness({
|
|
5553
|
+
...makeDeps(io, client),
|
|
5554
|
+
cwd,
|
|
5555
|
+
env,
|
|
5556
|
+
nativeSkills: realNativeSkills,
|
|
5557
|
+
})
|
|
5558
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
5559
|
+
status: 'gap',
|
|
5560
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
5561
|
+
technical: { resolution: 'both-listed' },
|
|
5562
|
+
})
|
|
5563
|
+
})
|
|
4603
5564
|
})
|
|
4604
5565
|
|
|
4605
5566
|
describe('an outage is not an answer', () => {
|
|
@@ -4800,9 +5761,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4800
5761
|
io.outLines = []
|
|
4801
5762
|
|
|
4802
5763
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
4803
|
-
expect(io.outLines).
|
|
4804
|
-
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
4805
|
-
)
|
|
5764
|
+
expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
|
|
4806
5765
|
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
4807
5766
|
session_id: 'codex-current-thread',
|
|
4808
5767
|
harness: 'codex',
|
|
@@ -5024,7 +5983,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5024
5983
|
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
5025
5984
|
expect(fired?.status).toBe('optional-gap')
|
|
5026
5985
|
expect(fired?.detail).toMatch(/Claude Code/)
|
|
5027
|
-
expect(fired?.detail).toMatch(/Codex:
|
|
5986
|
+
expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
|
|
5028
5987
|
|
|
5029
5988
|
io.errLines = []
|
|
5030
5989
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
@@ -5144,6 +6103,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5144
6103
|
} as unknown as ApiClient
|
|
5145
6104
|
const nativeSkills: NativeSkills = {
|
|
5146
6105
|
add: async () => 0,
|
|
6106
|
+
remove: async () => 0,
|
|
5147
6107
|
list: async (scope) => ({
|
|
5148
6108
|
skills:
|
|
5149
6109
|
scope === 'global'
|
|
@@ -5447,6 +6407,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5447
6407
|
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
5448
6408
|
})
|
|
5449
6409
|
|
|
6410
|
+
it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
|
|
6411
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
|
|
6412
|
+
const io = new PlainInteractiveIo()
|
|
6413
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
6414
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
6415
|
+
EXIT.ok,
|
|
6416
|
+
)
|
|
6417
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
|
|
6418
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
|
|
6419
|
+
hooks: Record<string, unknown>
|
|
6420
|
+
}
|
|
6421
|
+
delete settings.hooks.SessionStart
|
|
6422
|
+
delete settings.hooks.SubagentStart
|
|
6423
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
|
|
6424
|
+
|
|
6425
|
+
const readiness = await assessReadiness(deps)
|
|
6426
|
+
const current = readiness.states.find((state) => state.id === 'hooks-stale')
|
|
6427
|
+
expect(current?.status).toBe('gap')
|
|
6428
|
+
expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
|
|
6429
|
+
expect(current?.detail).toMatch(/notifai hooks install/i)
|
|
6430
|
+
})
|
|
6431
|
+
|
|
5450
6432
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
5451
6433
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
5452
6434
|
const io = new PlainInteractiveIo()
|
|
@@ -5598,7 +6580,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5598
6580
|
expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
|
|
5599
6581
|
})
|
|
5600
6582
|
|
|
5601
|
-
it('tells Claude Code to
|
|
6583
|
+
it('tells Claude Code to start fresh so activation is model-visible', () => {
|
|
5602
6584
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
|
|
5603
6585
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
5604
6586
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
@@ -5612,11 +6594,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5612
6594
|
|
|
5613
6595
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5614
6596
|
const said = io.errLines.join(' ')
|
|
5615
|
-
expect(said).toMatch(/
|
|
6597
|
+
expect(said).toMatch(/Claude Code: start one fresh session, send one prompt/i)
|
|
5616
6598
|
expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
|
|
5617
6599
|
})
|
|
5618
6600
|
|
|
5619
|
-
it('gives Cursor its
|
|
6601
|
+
it('gives Cursor its fresh-conversation and first-Stop activation path', () => {
|
|
5620
6602
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
|
|
5621
6603
|
const io = new CapturedIo()
|
|
5622
6604
|
const deps = {
|
|
@@ -5629,7 +6611,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5629
6611
|
io.outLines = []
|
|
5630
6612
|
|
|
5631
6613
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5632
|
-
expect(io.errLines.join(' ')).toMatch(/Cursor:
|
|
6614
|
+
expect(io.errLines.join(' ')).toMatch(/Cursor: start one fresh conversation[\s\S]*finish its first turn[\s\S]*`notifai doctor`/)
|
|
5633
6615
|
})
|
|
5634
6616
|
|
|
5635
6617
|
it('keeps OpenCode activation separate from unsupported answer continuation', () => {
|