@raidiant/notifai 0.2.0 → 0.3.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/dist/commands.d.ts +33 -3
- package/dist/commands.js +283 -59
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +2 -1
- package/src/commands.test.ts +153 -11
- package/src/commands.ts +328 -60
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/commands.test.ts
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
buildQuestions,
|
|
18
18
|
assessReadiness,
|
|
19
19
|
capabilitiesCommand,
|
|
20
|
+
configExplainCommand,
|
|
20
21
|
configSetCommand,
|
|
22
|
+
configShowCommand,
|
|
21
23
|
contradictingAnswer,
|
|
22
24
|
describeHookFailure,
|
|
23
25
|
doctorCommand,
|
|
@@ -38,6 +40,8 @@ import {
|
|
|
38
40
|
import { applyPlan, buildHookConfig } from './install-hooks.js'
|
|
39
41
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
40
42
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
43
|
+
import { CONFIG_KEYS } from './config.js'
|
|
44
|
+
import type { Tone } from './ui/theme.js'
|
|
41
45
|
|
|
42
46
|
class CapturedIo implements CommandIo {
|
|
43
47
|
outLines: string[] = []
|
|
@@ -72,7 +76,7 @@ class InteractiveIo extends CapturedIo {
|
|
|
72
76
|
intros: string[] = []
|
|
73
77
|
outros: string[] = []
|
|
74
78
|
spinnerEvents: string[] = []
|
|
75
|
-
checks: { ok: boolean; message: string }[] = []
|
|
79
|
+
checks: { ok: boolean; message: string; tone?: Tone }[] = []
|
|
76
80
|
|
|
77
81
|
override async confirm(question: string) {
|
|
78
82
|
this.prompts.push(question)
|
|
@@ -111,8 +115,8 @@ class InteractiveIo extends CapturedIo {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
async check(ok: boolean, message: string) {
|
|
115
|
-
this.checks.push({ ok, message })
|
|
118
|
+
async check(ok: boolean, message: string, tone?: Tone) {
|
|
119
|
+
this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -634,7 +638,7 @@ describe('command contracts', () => {
|
|
|
634
638
|
XDG_STATE_HOME: path.join(root, 'state'),
|
|
635
639
|
}
|
|
636
640
|
writeSessionState('pending-session', deps.env, {
|
|
637
|
-
pending: { question: 'Deploy?', request_id: receipt.request_id },
|
|
641
|
+
pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
|
|
638
642
|
})
|
|
639
643
|
writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
|
|
640
644
|
|
|
@@ -644,6 +648,36 @@ describe('command contracts', () => {
|
|
|
644
648
|
'reply from iPhone: yes, after the migration',
|
|
645
649
|
])
|
|
646
650
|
})
|
|
651
|
+
|
|
652
|
+
it('walks every delivered pending question in queue order', async () => {
|
|
653
|
+
const io = new CapturedIo()
|
|
654
|
+
const client = {
|
|
655
|
+
replies: async (requestId: string) =>
|
|
656
|
+
requestId === 'req_first'
|
|
657
|
+
? replyResponse([reply])
|
|
658
|
+
: replyResponse([]),
|
|
659
|
+
} as unknown as ApiClient
|
|
660
|
+
const deps = makeDeps(io, client)
|
|
661
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-multi-'))
|
|
662
|
+
deps.cwd = root
|
|
663
|
+
deps.env = {
|
|
664
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
665
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
666
|
+
}
|
|
667
|
+
writeSessionState('pending-multi', deps.env, {
|
|
668
|
+
pending: [
|
|
669
|
+
{ question: 'Deploy?', request_id: 'req_first' },
|
|
670
|
+
{ question: 'Which region?', request_id: 'req_second' },
|
|
671
|
+
{ question: 'Not yet asked?' },
|
|
672
|
+
],
|
|
673
|
+
})
|
|
674
|
+
writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
|
|
675
|
+
|
|
676
|
+
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
677
|
+
expect(io.outLines[0]).toBe('pending request req_first')
|
|
678
|
+
expect(io.outLines[1]).toContain('yes, after the migration')
|
|
679
|
+
expect(io.outLines.join('\n')).toContain('req_second')
|
|
680
|
+
})
|
|
647
681
|
})
|
|
648
682
|
|
|
649
683
|
describe('delivery evidence status', () => {
|
|
@@ -889,6 +923,67 @@ describe('projectSlugFrom', () => {
|
|
|
889
923
|
})
|
|
890
924
|
})
|
|
891
925
|
|
|
926
|
+
describe('config surfaces', () => {
|
|
927
|
+
function configDeps(io: CapturedIo): CommandDeps {
|
|
928
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
|
|
929
|
+
return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
it('gives an agent the same flat key = value output it always had', () => {
|
|
933
|
+
// The whole point of gating the readable rendering on a human terminal.
|
|
934
|
+
// Anything parsing this today must keep parsing it tomorrow.
|
|
935
|
+
const io = new CapturedIo()
|
|
936
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
937
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
938
|
+
expect(io.outLines[0]).toMatch(/^base_url = "/)
|
|
939
|
+
for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('explains each setting once a human is at the terminal', () => {
|
|
943
|
+
const io = new InteractiveIo()
|
|
944
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
945
|
+
const text = io.outLines.join('\n')
|
|
946
|
+
expect(text).toContain('Questions & presence')
|
|
947
|
+
expect(text).toContain('Whether sitting at this keyboard holds a question back')
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
it('keeps --plain available to a human who wants the parseable form', () => {
|
|
951
|
+
const io = new InteractiveIo()
|
|
952
|
+
expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
|
|
953
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
it('explains one setting, and says so in JSON when asked', () => {
|
|
957
|
+
const io = new InteractiveIo()
|
|
958
|
+
expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
|
|
959
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
960
|
+
expect(parsed['key']).toBe('require_idle')
|
|
961
|
+
expect(parsed['accepts']).toBe('true or false')
|
|
962
|
+
expect(parsed['detail']).toContain('question waits while you are using this machine')
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('rejects an unknown setting and points at the nearest real one', () => {
|
|
966
|
+
const io = new CapturedIo()
|
|
967
|
+
expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
|
|
968
|
+
expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('refuses an enum value the sender would later reject', async () => {
|
|
972
|
+
// `config set sound whatever` used to be written straight to disk: the
|
|
973
|
+
// typo only surfaced when a notification failed to carry the sound.
|
|
974
|
+
const io = new CapturedIo()
|
|
975
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
|
|
976
|
+
EXIT.usage,
|
|
977
|
+
)
|
|
978
|
+
expect(io.errLines[0]).toContain('default, done, attention, alert, none')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('still accepts a legal enum value', async () => {
|
|
982
|
+
const io = new CapturedIo()
|
|
983
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
|
|
984
|
+
})
|
|
985
|
+
})
|
|
986
|
+
|
|
892
987
|
describe('interactive command UX', () => {
|
|
893
988
|
it('styles login pairing progress for a human terminal', async () => {
|
|
894
989
|
const io = new InteractiveIo()
|
|
@@ -1022,7 +1117,9 @@ describe('interactive command UX', () => {
|
|
|
1022
1117
|
)
|
|
1023
1118
|
expect(io.errLines).toEqual([
|
|
1024
1119
|
'ask_grace_seconds must be between 0 and 540.',
|
|
1025
|
-
|
|
1120
|
+
// Names the key and its range: `"1.5" is not an integer` left the reader
|
|
1121
|
+
// to work out which of the two settings they had just mistyped.
|
|
1122
|
+
'ask_grace_seconds takes a whole number from 0s–540s, not "1.5".',
|
|
1026
1123
|
])
|
|
1027
1124
|
expect(existsSync(configFile)).toBe(false)
|
|
1028
1125
|
})
|
|
@@ -1051,6 +1148,18 @@ describe('interactive command UX', () => {
|
|
|
1051
1148
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
1052
1149
|
expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
|
|
1053
1150
|
expect(io.outLines).toEqual([])
|
|
1151
|
+
|
|
1152
|
+
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
1153
|
+
// to pass or fail, and rounding them to pass put a tick beside things the
|
|
1154
|
+
// user had declined and beside things nothing had checked.
|
|
1155
|
+
const tone = (prefix: string): Tone | undefined =>
|
|
1156
|
+
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
1157
|
+
expect(tone('This machine:')).toBe('bad')
|
|
1158
|
+
expect(tone('Protocol version:')).toBe('ok')
|
|
1159
|
+
// Never evaluated: the account cannot be checked without a credential.
|
|
1160
|
+
expect(tone('Account:')).toBe('pending')
|
|
1161
|
+
// Legitimately declined rather than broken.
|
|
1162
|
+
expect(tone('Project identity:')).toBe('warn')
|
|
1054
1163
|
})
|
|
1055
1164
|
|
|
1056
1165
|
it('keeps doctor JSON as one machine-readable stdout document', async () => {
|
|
@@ -1151,7 +1260,7 @@ describe('init', () => {
|
|
|
1151
1260
|
source: 'Raidiant-io/notifai',
|
|
1152
1261
|
sourceType: 'github',
|
|
1153
1262
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1154
|
-
ref: 'v0.
|
|
1263
|
+
ref: 'v0.3.0',
|
|
1155
1264
|
}
|
|
1156
1265
|
}
|
|
1157
1266
|
|
|
@@ -1254,7 +1363,7 @@ describe('init', () => {
|
|
|
1254
1363
|
})
|
|
1255
1364
|
|
|
1256
1365
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1257
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.
|
|
1366
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
|
|
1258
1367
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1259
1368
|
})
|
|
1260
1369
|
|
|
@@ -1277,7 +1386,7 @@ describe('init', () => {
|
|
|
1277
1386
|
source: 'Raidiant-io/notifai',
|
|
1278
1387
|
sourceType: 'github',
|
|
1279
1388
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1280
|
-
ref: 'v0.
|
|
1389
|
+
ref: 'v0.3.0',
|
|
1281
1390
|
},
|
|
1282
1391
|
],
|
|
1283
1392
|
}),
|
|
@@ -2177,7 +2286,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2177
2286
|
source: 'Raidiant-io/notifai',
|
|
2178
2287
|
sourceType: 'github',
|
|
2179
2288
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2180
|
-
ref: 'v0.
|
|
2289
|
+
ref: 'v0.3.0',
|
|
2181
2290
|
},
|
|
2182
2291
|
]
|
|
2183
2292
|
: [],
|
|
@@ -2237,7 +2346,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2237
2346
|
io.outLines = []
|
|
2238
2347
|
|
|
2239
2348
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2240
|
-
expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
|
|
2349
|
+
expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2241
2350
|
})
|
|
2242
2351
|
|
|
2243
2352
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
@@ -2275,7 +2384,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2275
2384
|
io.outLines = []
|
|
2276
2385
|
|
|
2277
2386
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2278
|
-
expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
|
|
2387
|
+
expect(readSessionState('cursor-live', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2279
2388
|
})
|
|
2280
2389
|
|
|
2281
2390
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
@@ -2299,6 +2408,39 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2299
2408
|
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
|
|
2300
2409
|
})
|
|
2301
2410
|
|
|
2411
|
+
it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
|
|
2412
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-unfired-pointer-'))
|
|
2413
|
+
const io = new CapturedIo()
|
|
2414
|
+
const env = {
|
|
2415
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2416
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2417
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2418
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2419
|
+
CLAUDECODE: '1',
|
|
2420
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
2421
|
+
}
|
|
2422
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2423
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2424
|
+
EXIT.ok,
|
|
2425
|
+
)
|
|
2426
|
+
// Hooks installed seconds ago, nothing has fired: the exact state a
|
|
2427
|
+
// fresh-project agent `init` runs from. Neither pointer state may block
|
|
2428
|
+
// the walk, and neither may prescribe a reinstall.
|
|
2429
|
+
const readiness = await assessReadiness(deps)
|
|
2430
|
+
|
|
2431
|
+
const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
|
|
2432
|
+
expect(pointer?.status).toBe('optional-gap')
|
|
2433
|
+
expect(pointer?.detail).toMatch(/has not published a live pointer/)
|
|
2434
|
+
expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
|
|
2435
|
+
expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
|
|
2436
|
+
'notifai doctor',
|
|
2437
|
+
)
|
|
2438
|
+
|
|
2439
|
+
const fired = readiness.states.find((s) => s.id === 'hooks-fired')
|
|
2440
|
+
expect(fired?.status).toBe('optional-gap')
|
|
2441
|
+
expect(fired?.remedy?.summary).not.toMatch(/hooks install/)
|
|
2442
|
+
})
|
|
2443
|
+
|
|
2302
2444
|
it('shows resolved question-routing values with their winning config sources', async () => {
|
|
2303
2445
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
|
|
2304
2446
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|