@raidiant/notifai 0.5.1 → 1.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 +165 -0
- package/README.md +11 -4
- package/dist/commands.d.ts +16 -1
- package/dist/commands.js +371 -157
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +2 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +19 -8
- package/dist/config.js +50 -7
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +1 -0
- package/dist/harnesses.js +6 -0
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -17
- package/dist/install-hooks.js +258 -97
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +163 -47
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +8 -8
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/package.json +3 -2
- package/src/commands.test.ts +364 -31
- package/src/commands.ts +450 -177
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +8 -8
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
package/src/interactive.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
deviceInventory,
|
|
37
37
|
doctorCommand,
|
|
38
38
|
hooksInstallCommand,
|
|
39
|
+
hooksUninstallCommand,
|
|
39
40
|
initCommand,
|
|
40
41
|
loginCommand,
|
|
41
42
|
logoutCommand,
|
|
@@ -43,6 +44,11 @@ import {
|
|
|
43
44
|
type CommandDeps,
|
|
44
45
|
} from './commands.js'
|
|
45
46
|
import { CONFIG_KEYS, loadConfig, type CliConfig, type ConfigKey } from './config.js'
|
|
47
|
+
import {
|
|
48
|
+
detectedHarnesses,
|
|
49
|
+
findInstallations,
|
|
50
|
+
type Installation,
|
|
51
|
+
} from './install-hooks.js'
|
|
46
52
|
import {
|
|
47
53
|
CONFIG_GROUPS,
|
|
48
54
|
acceptedValues,
|
|
@@ -53,15 +59,112 @@ import {
|
|
|
53
59
|
formatValue,
|
|
54
60
|
type ConfigGroup,
|
|
55
61
|
} from './config-schema.js'
|
|
56
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
firstBlocker,
|
|
64
|
+
refreshAfterMenuAction,
|
|
65
|
+
type Readiness,
|
|
66
|
+
type ReadinessRefresh,
|
|
67
|
+
type ReadinessState,
|
|
68
|
+
} from './readiness.js'
|
|
57
69
|
import { printBanner, tagline } from './ui/banner.js'
|
|
58
70
|
import { card, glyph, mark, style, type Tone } from './ui/theme.js'
|
|
59
71
|
|
|
60
72
|
/** Clack returns a symbol when the user pressed Ctrl-C or Escape. */
|
|
61
|
-
function cancelled(value: unknown):
|
|
73
|
+
function cancelled(value: unknown): value is symbol {
|
|
62
74
|
return clack.isCancel(value)
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
export type HookScope = 'project' | 'machine'
|
|
78
|
+
|
|
79
|
+
export type RoutingAction = 'install' | 'uninstall' | 'settings' | 'back'
|
|
80
|
+
|
|
81
|
+
export interface MenuOption<Value extends string> {
|
|
82
|
+
value: Value
|
|
83
|
+
label: string
|
|
84
|
+
hint?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which hook actions this directory and setup can actually perform.
|
|
89
|
+
*
|
|
90
|
+
* Install is offered only when a harness is detectable here. Uninstall is
|
|
91
|
+
* offered only for a scope that already has Notifai hooks, and the label names
|
|
92
|
+
* that scope so a machine-wide install is never stripped from a random folder
|
|
93
|
+
* under a generic "uninstall".
|
|
94
|
+
*/
|
|
95
|
+
export function routingHookActions(input: {
|
|
96
|
+
canInstall: boolean
|
|
97
|
+
projectInstallations: readonly { harness: string }[]
|
|
98
|
+
machineInstallations: readonly { harness: string }[]
|
|
99
|
+
hooksReady: boolean
|
|
100
|
+
}): MenuOption<RoutingAction>[] {
|
|
101
|
+
const options: MenuOption<RoutingAction>[] = []
|
|
102
|
+
const hasProject = input.projectInstallations.length > 0
|
|
103
|
+
const hasMachine = input.machineInstallations.length > 0
|
|
104
|
+
const hasAny = hasProject || hasMachine
|
|
105
|
+
|
|
106
|
+
if (input.canInstall) {
|
|
107
|
+
options.push({
|
|
108
|
+
value: 'install',
|
|
109
|
+
label: input.hooksReady || hasAny ? 'Re-install hooks' : 'Install hooks',
|
|
110
|
+
hint: 'this project or this machine',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (hasProject && hasMachine) {
|
|
115
|
+
options.push({
|
|
116
|
+
value: 'uninstall',
|
|
117
|
+
label: 'Uninstall hooks',
|
|
118
|
+
hint: 'this project or this machine',
|
|
119
|
+
})
|
|
120
|
+
} else if (hasProject) {
|
|
121
|
+
options.push({
|
|
122
|
+
value: 'uninstall',
|
|
123
|
+
label: 'Uninstall hooks for this project',
|
|
124
|
+
hint: harnessHint(input.projectInstallations),
|
|
125
|
+
})
|
|
126
|
+
} else if (hasMachine) {
|
|
127
|
+
options.push({
|
|
128
|
+
value: 'uninstall',
|
|
129
|
+
label: 'Uninstall hooks on this machine',
|
|
130
|
+
hint: harnessHint(input.machineInstallations),
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
options.push(
|
|
135
|
+
{ value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
|
|
136
|
+
{ value: 'back', label: '← Back' },
|
|
137
|
+
)
|
|
138
|
+
return options
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Scope choices that would actually remove something. Project first. */
|
|
142
|
+
export function uninstallScopeOptions(
|
|
143
|
+
projectInstallations: readonly { harness: string }[],
|
|
144
|
+
machineInstallations: readonly { harness: string }[],
|
|
145
|
+
): MenuOption<HookScope>[] {
|
|
146
|
+
const options: MenuOption<HookScope>[] = []
|
|
147
|
+
if (projectInstallations.length > 0) {
|
|
148
|
+
options.push({
|
|
149
|
+
value: 'project',
|
|
150
|
+
label: 'This project',
|
|
151
|
+
hint: 'remove hooks from this directory',
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
if (machineInstallations.length > 0) {
|
|
155
|
+
options.push({
|
|
156
|
+
value: 'machine',
|
|
157
|
+
label: 'This machine',
|
|
158
|
+
hint: 'remove hooks for every project here',
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
return options
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function harnessHint(installations: readonly { harness: string }[]): string {
|
|
165
|
+
return [...new Set(installations.map((installation) => installation.harness))].join(', ')
|
|
166
|
+
}
|
|
167
|
+
|
|
65
168
|
type Screen =
|
|
66
169
|
| 'test'
|
|
67
170
|
| 'devices'
|
|
@@ -87,6 +190,7 @@ export async function interactiveCommand(deps: CommandDeps, version: string): Pr
|
|
|
87
190
|
message: 'What would you like to do?',
|
|
88
191
|
options: menuFor(readiness),
|
|
89
192
|
})
|
|
193
|
+
// Root is the only place Escape/Ctrl-C quits. Every nested prompt returns.
|
|
90
194
|
if (cancelled(choice)) return farewell()
|
|
91
195
|
|
|
92
196
|
switch (choice) {
|
|
@@ -94,28 +198,38 @@ export async function interactiveCommand(deps: CommandDeps, version: string): Pr
|
|
|
94
198
|
return farewell()
|
|
95
199
|
case 'setup':
|
|
96
200
|
await initCommand(deps, {})
|
|
97
|
-
readiness = await
|
|
201
|
+
readiness = await refresh(deps, readiness, refreshAfterMenuAction('setup', true))
|
|
98
202
|
break
|
|
99
|
-
case 'account':
|
|
100
|
-
|
|
203
|
+
case 'account': {
|
|
204
|
+
const changed = await accountScreen(deps, readiness)
|
|
205
|
+
readiness = await refresh(deps, readiness, refreshAfterMenuAction('account', changed))
|
|
101
206
|
break
|
|
207
|
+
}
|
|
102
208
|
case 'test':
|
|
103
209
|
await testNotificationScreen(deps)
|
|
104
|
-
readiness = await assess(deps)
|
|
105
210
|
break
|
|
106
211
|
case 'devices':
|
|
107
212
|
await devicesScreen(deps)
|
|
108
213
|
break
|
|
109
|
-
case 'settings':
|
|
110
|
-
await settingsScreen(deps)
|
|
111
|
-
readiness = await
|
|
214
|
+
case 'settings': {
|
|
215
|
+
const result = await settingsScreen(deps)
|
|
216
|
+
readiness = await refresh(
|
|
217
|
+
deps,
|
|
218
|
+
readiness,
|
|
219
|
+
refreshAfterMenuAction('settings', result !== 'unchanged', { remote: result === 'remote' }),
|
|
220
|
+
)
|
|
112
221
|
break
|
|
113
|
-
|
|
114
|
-
|
|
222
|
+
}
|
|
223
|
+
case 'routing': {
|
|
224
|
+
const changed = await routingScreen(deps, readiness)
|
|
225
|
+
readiness = await refresh(deps, readiness, refreshAfterMenuAction('routing', changed))
|
|
115
226
|
break
|
|
227
|
+
}
|
|
116
228
|
case 'doctor':
|
|
117
|
-
|
|
229
|
+
// One assessment serves the report and the redraw. Doctor does not
|
|
230
|
+
// change setup, so running the graph again would only wait.
|
|
118
231
|
readiness = await assess(deps)
|
|
232
|
+
await doctorCommand(deps, {}, { readiness })
|
|
119
233
|
break
|
|
120
234
|
}
|
|
121
235
|
}
|
|
@@ -126,16 +240,27 @@ function farewell(): number {
|
|
|
126
240
|
return EXIT.ok
|
|
127
241
|
}
|
|
128
242
|
|
|
129
|
-
async function assess(
|
|
243
|
+
async function assess(
|
|
244
|
+
deps: CommandDeps,
|
|
245
|
+
options: { previous?: Readiness; refresh?: readonly ReadinessRefresh[] } = {},
|
|
246
|
+
): Promise<Readiness> {
|
|
130
247
|
const spinner = clack.spinner()
|
|
131
248
|
spinner.start('Checking your setup')
|
|
132
249
|
try {
|
|
133
|
-
return await assessReadiness(deps)
|
|
250
|
+
return await assessReadiness(deps, options)
|
|
134
251
|
} finally {
|
|
135
252
|
spinner.stop(style.dim('Setup checked'))
|
|
136
253
|
}
|
|
137
254
|
}
|
|
138
255
|
|
|
256
|
+
async function refresh(
|
|
257
|
+
deps: CommandDeps,
|
|
258
|
+
previous: Readiness,
|
|
259
|
+
next: readonly ReadinessRefresh[] | null,
|
|
260
|
+
): Promise<Readiness> {
|
|
261
|
+
return next === null ? previous : assess(deps, { previous, refresh: next })
|
|
262
|
+
}
|
|
263
|
+
|
|
139
264
|
// ---------------------------------------------------------------------------
|
|
140
265
|
// Status
|
|
141
266
|
// ---------------------------------------------------------------------------
|
|
@@ -294,23 +419,21 @@ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
|
|
|
294
419
|
})
|
|
295
420
|
if (cancelled(body)) return
|
|
296
421
|
|
|
297
|
-
const
|
|
298
|
-
message: '
|
|
422
|
+
const kind = await clack.select<string>({
|
|
423
|
+
message: 'Kind',
|
|
299
424
|
options: [
|
|
300
|
-
{ value: '
|
|
301
|
-
{ value: 'done', label: 'Done', hint: '
|
|
302
|
-
{ value: 'attention', label: 'Attention' },
|
|
303
|
-
{ value: 'none', label: 'Silent' },
|
|
425
|
+
{ value: 'update', label: 'Update', hint: 'something happened' },
|
|
426
|
+
{ value: 'done', label: 'Done', hint: 'work finished' },
|
|
304
427
|
],
|
|
305
|
-
initialValue: '
|
|
428
|
+
initialValue: 'done',
|
|
306
429
|
})
|
|
307
|
-
if (cancelled(
|
|
430
|
+
if (cancelled(kind)) return
|
|
308
431
|
|
|
309
432
|
clack.log.step('Sending, then waiting for your devices to confirm…')
|
|
310
433
|
const code = await sendCommand(deps, {
|
|
311
434
|
title: String(title),
|
|
312
435
|
body: String(body),
|
|
313
|
-
|
|
436
|
+
kind: String(kind),
|
|
314
437
|
...(project !== null ? { project } : {}),
|
|
315
438
|
})
|
|
316
439
|
if (code === EXIT.ok) clack.log.success('Delivered. Check the device it landed on.')
|
|
@@ -359,7 +482,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
|
|
|
359
482
|
// Settings
|
|
360
483
|
// ---------------------------------------------------------------------------
|
|
361
484
|
|
|
362
|
-
async function settingsScreen(deps: CommandDeps): Promise<
|
|
485
|
+
async function settingsScreen(deps: CommandDeps): Promise<'unchanged' | 'local' | 'remote'> {
|
|
486
|
+
let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
|
|
363
487
|
for (;;) {
|
|
364
488
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
365
489
|
const group = await clack.select<ConfigGroup | 'back'>({
|
|
@@ -373,8 +497,10 @@ async function settingsScreen(deps: CommandDeps): Promise<void> {
|
|
|
373
497
|
{ value: 'back' as const, label: '← Back' },
|
|
374
498
|
],
|
|
375
499
|
})
|
|
376
|
-
if (cancelled(group) || group === 'back') return
|
|
377
|
-
await settingsGroupScreen(deps, group as ConfigGroup)
|
|
500
|
+
if (cancelled(group) || group === 'back') return result
|
|
501
|
+
const next = await settingsGroupScreen(deps, group as ConfigGroup)
|
|
502
|
+
if (next === 'remote') result = 'remote'
|
|
503
|
+
else if (next === 'local' && result === 'unchanged') result = 'local'
|
|
378
504
|
}
|
|
379
505
|
}
|
|
380
506
|
|
|
@@ -385,7 +511,11 @@ function changedCount(config: CliConfig, group: ConfigGroup): string {
|
|
|
385
511
|
return changed === 0 ? style.dim('all default') : style.dim(`${changed} changed`)
|
|
386
512
|
}
|
|
387
513
|
|
|
388
|
-
async function settingsGroupScreen(
|
|
514
|
+
async function settingsGroupScreen(
|
|
515
|
+
deps: CommandDeps,
|
|
516
|
+
group: ConfigGroup,
|
|
517
|
+
): Promise<'unchanged' | 'local' | 'remote'> {
|
|
518
|
+
let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
|
|
389
519
|
for (;;) {
|
|
390
520
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
391
521
|
const info = CONFIG_GROUPS.find((entry) => entry.id === group)!
|
|
@@ -407,8 +537,10 @@ async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promi
|
|
|
407
537
|
{ value: 'back' as const, label: '← Back' },
|
|
408
538
|
],
|
|
409
539
|
})
|
|
410
|
-
if (cancelled(key) || key === 'back') return
|
|
411
|
-
await settingDetailScreen(deps, key as ConfigKey)
|
|
540
|
+
if (cancelled(key) || key === 'back') return result
|
|
541
|
+
const next = await settingDetailScreen(deps, key as ConfigKey)
|
|
542
|
+
if (next === 'remote') result = 'remote'
|
|
543
|
+
else if (next === 'local' && result === 'unchanged') result = 'local'
|
|
412
544
|
}
|
|
413
545
|
}
|
|
414
546
|
|
|
@@ -419,7 +551,10 @@ async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promi
|
|
|
419
551
|
* so that arriving here means the reader has already said which key they care
|
|
420
552
|
* about.
|
|
421
553
|
*/
|
|
422
|
-
async function settingDetailScreen(
|
|
554
|
+
async function settingDetailScreen(
|
|
555
|
+
deps: CommandDeps,
|
|
556
|
+
key: ConfigKey,
|
|
557
|
+
): Promise<'unchanged' | 'local' | 'remote'> {
|
|
423
558
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
424
559
|
const info = configInfo(key)
|
|
425
560
|
const resolved = config[key]
|
|
@@ -444,20 +579,22 @@ async function settingDetailScreen(deps: CommandDeps, key: ConfigKey): Promise<v
|
|
|
444
579
|
],
|
|
445
580
|
initialValue: 'back',
|
|
446
581
|
})
|
|
447
|
-
if (cancelled(action) || action === 'back') return
|
|
582
|
+
if (cancelled(action) || action === 'back') return 'unchanged'
|
|
448
583
|
|
|
449
584
|
const value = await promptForValue(deps, key)
|
|
450
|
-
if (value === null) return
|
|
585
|
+
if (value === null) return 'unchanged'
|
|
451
586
|
|
|
452
587
|
const layer = await chooseLayer()
|
|
453
|
-
if (layer === null) return
|
|
588
|
+
if (layer === null) return 'unchanged'
|
|
454
589
|
|
|
455
590
|
const code = await configSetCommand(deps, key, value, {
|
|
456
591
|
yes: true,
|
|
457
592
|
...(layer === 'project' ? { project: true } : {}),
|
|
458
593
|
...(layer === 'local' ? { local: true } : {}),
|
|
459
594
|
})
|
|
460
|
-
if (code
|
|
595
|
+
if (code !== EXIT.ok) return 'unchanged'
|
|
596
|
+
clack.log.success(`${info.label} is now ${formatValueRaw(key, value)}`)
|
|
597
|
+
return 'local'
|
|
461
598
|
}
|
|
462
599
|
|
|
463
600
|
function formatValueRaw(key: ConfigKey, raw: string): string {
|
|
@@ -589,6 +726,9 @@ function wiringSummary(hooks: ReadinessState | undefined): string {
|
|
|
589
726
|
async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
|
|
590
727
|
const hooks = stateById(readiness, 'hooks')
|
|
591
728
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
729
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
730
|
+
const projectInstallations = installations.filter((installation) => !installation.global)
|
|
731
|
+
const machineInstallations = installations.filter((installation) => installation.global)
|
|
592
732
|
|
|
593
733
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
594
734
|
clack.note(
|
|
@@ -604,17 +744,14 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
604
744
|
'Question routing',
|
|
605
745
|
)
|
|
606
746
|
|
|
607
|
-
const action = await clack.select<
|
|
747
|
+
const action = await clack.select<RoutingAction>({
|
|
608
748
|
message: 'Question routing',
|
|
609
|
-
options:
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
{ value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
|
|
616
|
-
{ value: 'back', label: '← Back' },
|
|
617
|
-
],
|
|
749
|
+
options: routingHookActions({
|
|
750
|
+
canInstall: detectedHarnesses(deps.cwd, deps.env).length > 0,
|
|
751
|
+
projectInstallations,
|
|
752
|
+
machineInstallations,
|
|
753
|
+
hooksReady: hooks?.status === 'ready',
|
|
754
|
+
}),
|
|
618
755
|
})
|
|
619
756
|
if (cancelled(action) || action === 'back') return false
|
|
620
757
|
|
|
@@ -623,16 +760,51 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
623
760
|
return true
|
|
624
761
|
}
|
|
625
762
|
|
|
626
|
-
|
|
627
|
-
|
|
763
|
+
if (action === 'install') return await installHooksInteractively(deps)
|
|
764
|
+
return await uninstallHooksInteractively(deps, projectInstallations, machineInstallations)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function installHooksInteractively(deps: CommandDeps): Promise<boolean> {
|
|
768
|
+
const scope = await clack.select<HookScope>({
|
|
769
|
+
message: 'Install hooks for',
|
|
628
770
|
options: [
|
|
629
|
-
{ value: 'project', label: 'This project only' },
|
|
630
|
-
{ value: '
|
|
771
|
+
{ value: 'project', label: 'This project', hint: 'hooks only in this directory' },
|
|
772
|
+
{ value: 'machine', label: 'This machine', hint: 'hooks for every project here' },
|
|
631
773
|
],
|
|
632
774
|
initialValue: 'project',
|
|
633
775
|
})
|
|
634
776
|
if (cancelled(scope)) return false
|
|
635
|
-
hooksInstallCommand(deps, scope === '
|
|
777
|
+
hooksInstallCommand(deps, scope === 'machine' ? { global: true } : {})
|
|
778
|
+
return true
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function uninstallHooksInteractively(
|
|
782
|
+
deps: CommandDeps,
|
|
783
|
+
projectInstallations: Installation[],
|
|
784
|
+
machineInstallations: Installation[],
|
|
785
|
+
): Promise<boolean> {
|
|
786
|
+
const scopes = uninstallScopeOptions(projectInstallations, machineInstallations)
|
|
787
|
+
if (scopes.length === 0) return false
|
|
788
|
+
|
|
789
|
+
let scope: HookScope
|
|
790
|
+
if (scopes.length === 1) {
|
|
791
|
+
scope = scopes[0]!.value
|
|
792
|
+
} else {
|
|
793
|
+
const picked = await clack.select<HookScope | 'back'>({
|
|
794
|
+
message: 'Uninstall hooks from',
|
|
795
|
+
options: [...scopes, { value: 'back', label: '← Back' }],
|
|
796
|
+
})
|
|
797
|
+
if (cancelled(picked) || picked === 'back') return false
|
|
798
|
+
scope = picked
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const targets = scope === 'machine' ? machineInstallations : projectInstallations
|
|
802
|
+
for (const installation of targets) {
|
|
803
|
+
hooksUninstallCommand(deps, {
|
|
804
|
+
harness: installation.harness,
|
|
805
|
+
global: installation.global,
|
|
806
|
+
})
|
|
807
|
+
}
|
|
636
808
|
return true
|
|
637
809
|
}
|
|
638
810
|
|
package/src/main.ts
CHANGED
|
@@ -199,7 +199,7 @@ program
|
|
|
199
199
|
.summary('Sign in and pair this machine')
|
|
200
200
|
.description('Pair this machine with your Notifai account via browser approval')
|
|
201
201
|
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
202
|
-
.option('--base-url <url>', '
|
|
202
|
+
.option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
|
|
203
203
|
.option('--no-open', 'do not open the approval page in a browser')
|
|
204
204
|
.action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
|
|
205
205
|
process.exit(await loginCommand(deps, opts))
|
|
@@ -275,8 +275,6 @@ const send = program
|
|
|
275
275
|
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
276
276
|
.option('--event <event>', 'agent event name, e.g. tests_passed')
|
|
277
277
|
.optionsGroup(SEND_GROUP.presentation)
|
|
278
|
-
.option('--sound <sound>', 'default | done | attention | alert | none')
|
|
279
|
-
.option('--level <level>', 'interruption level: passive | active | time_sensitive')
|
|
280
278
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
281
279
|
.option('--thread-id <id>', 'group related notifications')
|
|
282
280
|
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
@@ -295,11 +293,13 @@ const send = program
|
|
|
295
293
|
// a message pointing at `ask` instead of "unknown option".
|
|
296
294
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
297
295
|
.optionsGroup(SEND_GROUP.advanced)
|
|
296
|
+
.option('--sound <sound>', 'override kind profile / saved config: default | done | attention | alert | none')
|
|
297
|
+
.option('--level <level>', 'override kind profile / saved config: passive | active | time_sensitive')
|
|
298
298
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
299
299
|
.option('--no-wait', 'return immediately after acceptance')
|
|
300
300
|
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
301
301
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
302
|
-
.option('--base-url <url>', '
|
|
302
|
+
.option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
|
|
303
303
|
.option('--json', 'print the full submission receipt as JSON')
|
|
304
304
|
.action(async (opts: Record<string, unknown>) => {
|
|
305
305
|
// commander maps --no-wait onto the same "wait" flag; disentangle.
|
|
@@ -336,7 +336,7 @@ const send = program
|
|
|
336
336
|
|
|
337
337
|
send.addHelpText(
|
|
338
338
|
'after',
|
|
339
|
-
`\nKind profiles
|
|
339
|
+
`\nKind profiles apply automatically. Pass --sound or --level only to override them or saved user config:\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
|
|
340
340
|
)
|
|
341
341
|
|
|
342
342
|
program
|
|
@@ -455,7 +455,7 @@ hooks
|
|
|
455
455
|
.description('Wire this harness to route registered questions to your devices')
|
|
456
456
|
.option(
|
|
457
457
|
'--harness <name>',
|
|
458
|
-
'claude-code | codex | cursor | opencode (default: detected)',
|
|
458
|
+
'claude-code | codex | cursor | opencode (default: every detected harness)',
|
|
459
459
|
)
|
|
460
460
|
.option('--global', 'install for every project instead of just this one')
|
|
461
461
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -525,7 +525,7 @@ config
|
|
|
525
525
|
'Set a configuration value (choose a layer interactively; unattended defaults machine-global and requires --yes)',
|
|
526
526
|
)
|
|
527
527
|
.option('--project', 'write to the shared .notifai/config.toml instead')
|
|
528
|
-
.option('--local', 'write
|
|
528
|
+
.option('--local', 'write a personal project preference on this machine (not in the repo)')
|
|
529
529
|
.option('--session <id>', 'apply only to one session')
|
|
530
530
|
.option('--yes', 'skip the confirmation gate')
|
|
531
531
|
.action(
|
|
@@ -543,7 +543,7 @@ config
|
|
|
543
543
|
'Remove a configuration value so the next layer or shipped default applies (choose a layer interactively; unattended defaults machine-global and requires --yes)',
|
|
544
544
|
)
|
|
545
545
|
.option('--project', 'remove from the shared .notifai/config.toml instead')
|
|
546
|
-
.option('--local', 'remove
|
|
546
|
+
.option('--local', 'remove a personal project preference stored on this machine')
|
|
547
547
|
.option('--session <id>', 'remove from one session')
|
|
548
548
|
.option('--yes', 'skip the confirmation gate')
|
|
549
549
|
.action(
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { nativeSkills } from './native-skills.js'
|
|
6
|
+
|
|
7
|
+
function writeLock(file: string, ref: string): void {
|
|
8
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
9
|
+
writeFileSync(
|
|
10
|
+
file,
|
|
11
|
+
`${JSON.stringify({
|
|
12
|
+
version: 1,
|
|
13
|
+
skills: {
|
|
14
|
+
notifai: {
|
|
15
|
+
source: 'Raidiant-io/notifai',
|
|
16
|
+
sourceType: 'github',
|
|
17
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
18
|
+
ref,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
})}\n`,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('nativeSkills.list', () => {
|
|
26
|
+
it('reads the project lock file instead of spawning npx', async () => {
|
|
27
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-project-'))
|
|
28
|
+
writeLock(path.join(cwd, 'skills-lock.json'), 'v0.5.1')
|
|
29
|
+
|
|
30
|
+
const result = await nativeSkills.list('project', cwd, { PATH: '/nonexistent' })
|
|
31
|
+
expect(result.error).toBeUndefined()
|
|
32
|
+
expect(result.skills).toEqual([
|
|
33
|
+
expect.objectContaining({
|
|
34
|
+
name: 'notifai',
|
|
35
|
+
scope: 'project',
|
|
36
|
+
source: 'Raidiant-io/notifai',
|
|
37
|
+
sourceType: 'github',
|
|
38
|
+
ref: 'v0.5.1',
|
|
39
|
+
}),
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('reads the XDG global lock file instead of spawning npx', async () => {
|
|
44
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-xdg-'))
|
|
45
|
+
const state = path.join(cwd, 'state')
|
|
46
|
+
writeLock(path.join(state, 'skills', '.skill-lock.json'), 'v0.4.0')
|
|
47
|
+
|
|
48
|
+
const result = await nativeSkills.list('global', cwd, {
|
|
49
|
+
PATH: '/nonexistent',
|
|
50
|
+
XDG_STATE_HOME: state,
|
|
51
|
+
})
|
|
52
|
+
expect(result.error).toBeUndefined()
|
|
53
|
+
expect(result.skills).toEqual([
|
|
54
|
+
expect.objectContaining({ name: 'notifai', scope: 'global', ref: 'v0.4.0' }),
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('falls back to the home-directory global lock', async () => {
|
|
59
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-home-'))
|
|
60
|
+
const home = path.join(cwd, 'home')
|
|
61
|
+
writeLock(path.join(home, '.agents', '.skill-lock.json'), 'v0.3.0')
|
|
62
|
+
|
|
63
|
+
const result = await nativeSkills.list('global', cwd, { PATH: '/nonexistent', HOME: home })
|
|
64
|
+
expect(result.error).toBeUndefined()
|
|
65
|
+
expect(result.skills).toEqual([
|
|
66
|
+
expect.objectContaining({ name: 'notifai', scope: 'global', ref: 'v0.3.0' }),
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('treats a missing lock as an empty inventory, not an installer error', async () => {
|
|
71
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-missing-'))
|
|
72
|
+
const result = await nativeSkills.list('project', cwd, { PATH: '/nonexistent' })
|
|
73
|
+
expect(result).toEqual({ skills: [] })
|
|
74
|
+
})
|
|
75
|
+
})
|