@raidiant/notifai 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +46 -15
- package/dist/commands.js +760 -248
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +27 -12
- package/dist/hooks.js +519 -155
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +80 -36
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +19 -29
- package/dist/send.js +62 -57
- package/dist/send.js.map +1 -1
- package/package.json +5 -5
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +1036 -72
- package/src/commands.ts +923 -261
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +500 -9
- package/src/hooks.ts +624 -173
- package/src/install-hooks.test.ts +44 -16
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +95 -41
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +82 -60
- package/src/skill.test.ts +43 -0
|
@@ -44,28 +44,21 @@ function ours(): HookConfig {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
describe('choice labels', () => {
|
|
47
|
-
it('
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
])
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('keeps commas inside a label when the flag is repeated', () => {
|
|
55
|
-
// Observed live 2026-08-02: "Yes, it worked,No, something broke" passed as
|
|
56
|
-
// one value silently became four choices, and 2-6 validation let it
|
|
57
|
-
// through because four is in range. Repeating the flag is the escape.
|
|
47
|
+
it('treats a comma as an ordinary character, never a delimiter', () => {
|
|
48
|
+
// Observed live 2026-08-02 under the old comma-split grammar:
|
|
49
|
+
// "Yes, it worked,No, something broke" passed as one value silently
|
|
50
|
+
// became four choices. The delimiter form is gone; one flag, one answer.
|
|
58
51
|
expect(parseChoices(['Yes, it worked', 'No, something broke'])).toEqual([
|
|
59
52
|
{ id: 'yes-it-worked', label: 'Yes, it worked' },
|
|
60
53
|
{ id: 'no-something-broke', label: 'No, something broke' },
|
|
61
54
|
])
|
|
62
|
-
expect(parseChoices('
|
|
55
|
+
expect(parseChoices(['Staging,Production', 'Neither'])).toEqual([
|
|
56
|
+
{ id: 'staging-production', label: 'Staging,Production' },
|
|
57
|
+
{ id: 'neither', label: 'Neither' },
|
|
58
|
+
])
|
|
63
59
|
})
|
|
64
60
|
|
|
65
61
|
it('rejects a set that cannot become a valid closed question', () => {
|
|
66
|
-
// A pipe-separated string is one label, not two — the shape that reached
|
|
67
|
-
// the hook and failed there instead of at registration.
|
|
68
|
-
expect(parseChoices('Yes|No')).toBe('invalid')
|
|
69
62
|
expect(parseChoices(['Only one'])).toBe('invalid')
|
|
70
63
|
expect(parseChoices(['A', 'a'])).toBe('invalid')
|
|
71
64
|
expect(parseChoices(['1', '2', '3', '4', '5', '6', '7'])).toBe('invalid')
|
|
@@ -97,6 +90,22 @@ describe('hook config', () => {
|
|
|
97
90
|
expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
98
91
|
expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
99
92
|
})
|
|
93
|
+
|
|
94
|
+
it('stamps the exact harness on every generated command', () => {
|
|
95
|
+
const config = buildHookConfig({
|
|
96
|
+
execPath: EXEC,
|
|
97
|
+
scriptPath: SCRIPT,
|
|
98
|
+
replyTimeoutSeconds: 240,
|
|
99
|
+
graceSeconds: 0,
|
|
100
|
+
harness: 'claude-code',
|
|
101
|
+
})
|
|
102
|
+
expect(
|
|
103
|
+
Object.values(config)
|
|
104
|
+
.flatMap((groups) => groups)
|
|
105
|
+
.flatMap((group) => group.hooks)
|
|
106
|
+
.every((handler) => handler.command.includes('--harness claude-code')),
|
|
107
|
+
).toBe(true)
|
|
108
|
+
})
|
|
100
109
|
})
|
|
101
110
|
|
|
102
111
|
describe('merging into existing settings', () => {
|
|
@@ -450,6 +459,7 @@ describe('the OpenCode adapter', () => {
|
|
|
450
459
|
it('shells out to the same hook commands the other harnesses run', () => {
|
|
451
460
|
// The whole point: presence, escalation and retirement stay in the CLI.
|
|
452
461
|
expect(source).toContain('"hook", event')
|
|
462
|
+
expect(source).toContain('"--harness", "opencode"')
|
|
453
463
|
expect(source).toContain(JSON.stringify(SCRIPT))
|
|
454
464
|
expect(source).toContain(JSON.stringify(EXEC))
|
|
455
465
|
})
|
|
@@ -464,6 +474,18 @@ describe('the OpenCode adapter', () => {
|
|
|
464
474
|
expect(source).not.toContain('"permission.ask"')
|
|
465
475
|
})
|
|
466
476
|
|
|
477
|
+
it('injects collected late answers into the next OpenCode user message', () => {
|
|
478
|
+
expect(source).toContain('hookSpecificOutput?.additionalContext')
|
|
479
|
+
expect(source).toContain('synthetic: true')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('injects an exact active-harness and session marker into OpenCode shells', () => {
|
|
483
|
+
expect(source).toContain('"shell.env"')
|
|
484
|
+
expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
|
|
485
|
+
expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
|
|
486
|
+
expect(source).toContain('input.sessionID')
|
|
487
|
+
})
|
|
488
|
+
|
|
467
489
|
it('carries the ownership marker so a second checkout replaces it', () => {
|
|
468
490
|
expect(source).toContain(OPENCODE_PLUGIN_MARKER)
|
|
469
491
|
expect(isOurOpencodePlugin(source)).toBe(true)
|
|
@@ -487,11 +509,17 @@ describe('the OpenCode adapter', () => {
|
|
|
487
509
|
// rather than a command line, so answering with the plugin's own path made
|
|
488
510
|
// an ordinary OpenCode install look like a second checkout and turned the
|
|
489
511
|
// duplicate check red on a healthy machine.
|
|
490
|
-
expect(opencodePluginTarget(source)).toEqual({
|
|
512
|
+
expect(opencodePluginTarget(source)).toEqual({
|
|
513
|
+
exec: EXEC,
|
|
514
|
+
script: SCRIPT,
|
|
515
|
+
current: true,
|
|
516
|
+
timeoutSeconds: 240,
|
|
517
|
+
})
|
|
491
518
|
expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
|
|
492
519
|
exec: EXEC,
|
|
493
520
|
script: SCRIPT,
|
|
494
521
|
current: false,
|
|
522
|
+
timeoutSeconds: 240,
|
|
495
523
|
})
|
|
496
524
|
expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
|
|
497
525
|
// Ours, but with the constants edited beyond recognition: no answer beats
|
package/src/install-hooks.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
closeSync,
|
|
3
2
|
existsSync,
|
|
4
|
-
fsyncSync,
|
|
5
|
-
lstatSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
openSync,
|
|
8
3
|
readFileSync,
|
|
9
4
|
statSync,
|
|
10
|
-
renameSync,
|
|
11
|
-
unlinkSync,
|
|
12
|
-
writeFileSync,
|
|
13
5
|
} from 'node:fs'
|
|
14
6
|
import os from 'node:os'
|
|
15
7
|
import path from 'node:path'
|
|
8
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
16
9
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
17
10
|
|
|
18
11
|
/**
|
|
@@ -73,22 +66,22 @@ export function hookCommand(
|
|
|
73
66
|
execPath: string,
|
|
74
67
|
scriptPath: string,
|
|
75
68
|
event: string,
|
|
76
|
-
harness?:
|
|
69
|
+
harness?: Harness,
|
|
77
70
|
): string {
|
|
78
71
|
return (
|
|
79
72
|
`${quote(execPath)} ${quote(scriptPath)} hook ${event} ${OWNER_MARKER}` +
|
|
80
|
-
(harness ===
|
|
73
|
+
(harness === undefined ? '' : ` --harness ${harness}`)
|
|
81
74
|
)
|
|
82
75
|
}
|
|
83
76
|
|
|
84
77
|
/**
|
|
85
|
-
* Says "
|
|
78
|
+
* Says "Notifai installed this" without saying which checkout did.
|
|
86
79
|
*
|
|
87
80
|
* Ownership used to be matched on the absolute script path, so installing from
|
|
88
81
|
* a second checkout did not recognise the first one's handlers as ours. Both
|
|
89
82
|
* stayed, the harness ran both, and one question produced two notifications;
|
|
90
83
|
* uninstalling the second silently left the first running. A marker that every
|
|
91
|
-
*
|
|
84
|
+
* Notifai build writes and no build's path appears in fixes that by making
|
|
92
85
|
* ownership a property of the handler rather than of the machine it was
|
|
93
86
|
* installed from.
|
|
94
87
|
*
|
|
@@ -114,6 +107,8 @@ export interface BuildOptions {
|
|
|
114
107
|
replyTimeoutSeconds: number
|
|
115
108
|
/** Seconds the question waits in the terminal before it is pushed at all. */
|
|
116
109
|
graceSeconds: number
|
|
110
|
+
/** The installed adapter stamps its exact harness into project pointers. */
|
|
111
|
+
harness?: Harness
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
/** Command-hook harnesses kill at 600s; generated adapters use the same safe ceiling. */
|
|
@@ -139,7 +134,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
139
134
|
hooks: [
|
|
140
135
|
{
|
|
141
136
|
type: 'command',
|
|
142
|
-
command: hookCommand(execPath, scriptPath, 'user-prompt-submit'),
|
|
137
|
+
command: hookCommand(execPath, scriptPath, 'user-prompt-submit', options.harness),
|
|
143
138
|
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
144
139
|
// a slow network can never delay the user's own prompt.
|
|
145
140
|
timeout: 15,
|
|
@@ -152,7 +147,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
152
147
|
hooks: [
|
|
153
148
|
{
|
|
154
149
|
type: 'command',
|
|
155
|
-
command: hookCommand(execPath, scriptPath, 'stop'),
|
|
150
|
+
command: hookCommand(execPath, scriptPath, 'stop', options.harness),
|
|
156
151
|
timeout: blockingTimeout,
|
|
157
152
|
},
|
|
158
153
|
],
|
|
@@ -163,7 +158,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
163
158
|
hooks: [
|
|
164
159
|
{
|
|
165
160
|
type: 'command',
|
|
166
|
-
command: hookCommand(execPath, scriptPath, 'session-end'),
|
|
161
|
+
command: hookCommand(execPath, scriptPath, 'session-end', options.harness),
|
|
167
162
|
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
168
163
|
// only touches local state.
|
|
169
164
|
timeout: 3,
|
|
@@ -205,9 +200,9 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
205
200
|
{
|
|
206
201
|
command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
|
|
207
202
|
timeout: blockingTimeout,
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
loop_limit:
|
|
203
|
+
// A continuation may register a real follow-up question. Match the
|
|
204
|
+
// session-state cap so those chains are useful but never unbounded.
|
|
205
|
+
loop_limit: 3,
|
|
211
206
|
},
|
|
212
207
|
],
|
|
213
208
|
sessionEnd: [
|
|
@@ -568,49 +563,7 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
|
|
|
568
563
|
* support.
|
|
569
564
|
*/
|
|
570
565
|
export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
|
|
571
|
-
|
|
572
|
-
const mode = targetMode(file)
|
|
573
|
-
const temp = path.join(path.dirname(file), `.${path.basename(file)}.notifai-${process.pid}.tmp`)
|
|
574
|
-
|
|
575
|
-
const handle = openSync(temp, 'w', mode)
|
|
576
|
-
try {
|
|
577
|
-
writeFileSync(handle, `${JSON.stringify(document, null, 2)}\n`)
|
|
578
|
-
fsyncSync(handle)
|
|
579
|
-
} finally {
|
|
580
|
-
closeSync(handle)
|
|
581
|
-
}
|
|
582
|
-
try {
|
|
583
|
-
renameSync(temp, file)
|
|
584
|
-
} catch (err) {
|
|
585
|
-
// Leaving the temp behind would be a second, quieter mess on top of the
|
|
586
|
-
// failure the caller is about to hear about.
|
|
587
|
-
try {
|
|
588
|
-
unlinkSync(temp)
|
|
589
|
-
} catch {
|
|
590
|
-
// Nothing useful to do; the rename error is the one that matters.
|
|
591
|
-
}
|
|
592
|
-
throw err
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
|
|
597
|
-
function targetMode(file: string): number {
|
|
598
|
-
let stat
|
|
599
|
-
try {
|
|
600
|
-
stat = lstatSync(file)
|
|
601
|
-
} catch (err) {
|
|
602
|
-
// A settings file can carry tokens, so a file we create is ours alone.
|
|
603
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0o600
|
|
604
|
-
throw err
|
|
605
|
-
}
|
|
606
|
-
if (stat.isSymbolicLink()) {
|
|
607
|
-
throw new Error(
|
|
608
|
-
`${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
|
|
609
|
-
'or install to a different location.',
|
|
610
|
-
)
|
|
611
|
-
}
|
|
612
|
-
if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
|
|
613
|
-
return stat.mode & 0o777
|
|
566
|
+
atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`)
|
|
614
567
|
}
|
|
615
568
|
|
|
616
569
|
export function loadSettings(file: string): SettingsDocument {
|
|
@@ -633,6 +586,8 @@ export interface InstalledHandler {
|
|
|
633
586
|
/** Index of the handler within its group. */
|
|
634
587
|
handlerIndex: number
|
|
635
588
|
command: string
|
|
589
|
+
/** Declared process lifetime, used to detect config/install drift. */
|
|
590
|
+
timeout?: number
|
|
636
591
|
}
|
|
637
592
|
|
|
638
593
|
export interface Installation {
|
|
@@ -645,9 +600,9 @@ export interface Installation {
|
|
|
645
600
|
}
|
|
646
601
|
|
|
647
602
|
/**
|
|
648
|
-
* Any
|
|
603
|
+
* Any Notifai handler, whatever checkout wrote it.
|
|
649
604
|
*
|
|
650
|
-
* Deliberately looser than `isOurHandler`: this answers "has
|
|
605
|
+
* Deliberately looser than `isOurHandler`: this answers "has Notifai been set
|
|
651
606
|
* up here at all", and a handler installed from a second checkout is still
|
|
652
607
|
* evidence that it has (and is itself worth reporting).
|
|
653
608
|
*/
|
|
@@ -659,7 +614,7 @@ function isNotifaiHandler(handler: HookHandler): boolean {
|
|
|
659
614
|
return isNotifaiCommand(handler.command)
|
|
660
615
|
}
|
|
661
616
|
|
|
662
|
-
/** Every place either harness would read a
|
|
617
|
+
/** Every place either harness would read a Notifai handler from. */
|
|
663
618
|
export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
|
|
664
619
|
const found: Installation[] = []
|
|
665
620
|
for (const harness of HARNESSES) {
|
|
@@ -687,6 +642,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
687
642
|
groupIndex: 0,
|
|
688
643
|
handlerIndex: 0,
|
|
689
644
|
command: hookCommand(target.exec, target.script, hookEvent),
|
|
645
|
+
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
690
646
|
})),
|
|
691
647
|
})
|
|
692
648
|
continue
|
|
@@ -699,7 +655,10 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
699
655
|
continue
|
|
700
656
|
}
|
|
701
657
|
const handlers = locateCursorHandlers(document)
|
|
702
|
-
if (handlers.length > 0)
|
|
658
|
+
if (handlers.length > 0) {
|
|
659
|
+
const problems = harnessMarkerProblems(harness, handlers)
|
|
660
|
+
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
661
|
+
}
|
|
703
662
|
continue
|
|
704
663
|
}
|
|
705
664
|
let document: SettingsDocument
|
|
@@ -709,18 +668,35 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
709
668
|
continue
|
|
710
669
|
}
|
|
711
670
|
const handlers = locateHandlers(document)
|
|
712
|
-
if (handlers.length > 0)
|
|
671
|
+
if (handlers.length > 0) {
|
|
672
|
+
const problems = harnessMarkerProblems(harness, handlers)
|
|
673
|
+
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
674
|
+
}
|
|
713
675
|
}
|
|
714
676
|
}
|
|
715
677
|
return found
|
|
716
678
|
}
|
|
717
679
|
|
|
680
|
+
function harnessMarkerProblems(harness: Harness, handlers: InstalledHandler[]): string[] {
|
|
681
|
+
return handlers.every((handler) => handler.command.includes(`--harness ${harness}`))
|
|
682
|
+
? []
|
|
683
|
+
: [
|
|
684
|
+
`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
|
|
685
|
+
]
|
|
686
|
+
}
|
|
687
|
+
|
|
718
688
|
function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
|
|
719
689
|
const handlers: InstalledHandler[] = []
|
|
720
690
|
for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
|
|
721
691
|
eventHandlers.forEach((handler, handlerIndex) => {
|
|
722
692
|
if (isNotifaiCommand(handler.command)) {
|
|
723
|
-
handlers.push({
|
|
693
|
+
handlers.push({
|
|
694
|
+
event,
|
|
695
|
+
groupIndex: 0,
|
|
696
|
+
handlerIndex,
|
|
697
|
+
command: handler.command,
|
|
698
|
+
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
699
|
+
})
|
|
724
700
|
}
|
|
725
701
|
})
|
|
726
702
|
}
|
|
@@ -733,7 +709,13 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
|
733
709
|
groups.forEach((group, groupIndex) => {
|
|
734
710
|
group.hooks?.forEach((handler, handlerIndex) => {
|
|
735
711
|
if (isNotifaiHandler(handler)) {
|
|
736
|
-
handlers.push({
|
|
712
|
+
handlers.push({
|
|
713
|
+
event,
|
|
714
|
+
groupIndex,
|
|
715
|
+
handlerIndex,
|
|
716
|
+
command: handler.command,
|
|
717
|
+
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
718
|
+
})
|
|
737
719
|
}
|
|
738
720
|
})
|
|
739
721
|
})
|
package/src/main.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import { Command } from 'commander'
|
|
6
6
|
import {
|
|
7
7
|
askCommand,
|
|
8
|
+
accessStatusCommand,
|
|
8
9
|
authStatusCommand,
|
|
9
10
|
capabilitiesCommand,
|
|
10
11
|
closeCommand,
|
|
@@ -25,13 +26,16 @@ import {
|
|
|
25
26
|
type CommandDeps,
|
|
26
27
|
} from './commands.js'
|
|
27
28
|
import { defaultCredentialStore } from './credentials.js'
|
|
29
|
+
import { HARNESSES } from './install-hooks.js'
|
|
28
30
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
31
|
+
import { nativeSkills, type SkillScope } from './native-skills.js'
|
|
29
32
|
|
|
30
33
|
const deps: CommandDeps = {
|
|
31
34
|
io: realIo(),
|
|
32
35
|
store: defaultCredentialStore(),
|
|
33
36
|
env: process.env,
|
|
34
37
|
cwd: process.cwd(),
|
|
38
|
+
nativeSkills,
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -89,11 +93,36 @@ const program = new Command('notifai')
|
|
|
89
93
|
.description('Send native device notifications from agents and local programs')
|
|
90
94
|
.version(version())
|
|
91
95
|
|
|
96
|
+
// Setup leads: `init` is the single entry command for first-run friendliness.
|
|
97
|
+
program
|
|
98
|
+
.command('init')
|
|
99
|
+
.description(
|
|
100
|
+
'Set up Notifai here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
101
|
+
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
102
|
+
)
|
|
103
|
+
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
104
|
+
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
105
|
+
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
106
|
+
.option('--skills-scope <scope>', 'unattended skill scope: project or global')
|
|
107
|
+
.option('--hooks', 'install harness hooks for registered-question routing')
|
|
108
|
+
.option('--no-hooks', 'skip the hooks without being asked')
|
|
109
|
+
.action(async (opts: { projectId?: string; skills?: boolean; skillsScope?: SkillScope; hooks?: boolean }) => {
|
|
110
|
+
process.exit(await initCommand(deps, opts))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command('doctor')
|
|
115
|
+
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof; exits nonzero when any line is FAIL (no live send)')
|
|
116
|
+
.option('--json', 'machine-readable output')
|
|
117
|
+
.action(async (opts: { json?: boolean }) => {
|
|
118
|
+
process.exit(await doctorCommand(deps, opts))
|
|
119
|
+
})
|
|
120
|
+
|
|
92
121
|
program
|
|
93
122
|
.command('login')
|
|
94
|
-
.description('Pair this machine with your
|
|
123
|
+
.description('Pair this machine with your Notifai account via browser approval')
|
|
95
124
|
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
96
|
-
.option('--base-url <url>', '
|
|
125
|
+
.option('--base-url <url>', 'Notifai server URL')
|
|
97
126
|
.option('--no-open', 'do not open the approval page in a browser')
|
|
98
127
|
.action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
|
|
99
128
|
process.exit(await loginCommand(deps, opts))
|
|
@@ -114,6 +143,13 @@ auth
|
|
|
114
143
|
.action((opts: { json?: boolean }) => {
|
|
115
144
|
process.exit(authStatusCommand(deps, opts))
|
|
116
145
|
})
|
|
146
|
+
auth
|
|
147
|
+
.command('access')
|
|
148
|
+
.description('Show the account plan and access decision')
|
|
149
|
+
.option('--json', 'machine-readable output')
|
|
150
|
+
.action(async (opts: { json?: boolean }) => {
|
|
151
|
+
process.exit(await accessStatusCommand(deps, opts))
|
|
152
|
+
})
|
|
117
153
|
|
|
118
154
|
program
|
|
119
155
|
.command('devices')
|
|
@@ -132,7 +168,7 @@ program
|
|
|
132
168
|
process.exit(await capabilitiesCommand(deps, opts))
|
|
133
169
|
})
|
|
134
170
|
|
|
135
|
-
program
|
|
171
|
+
const send = program
|
|
136
172
|
.command('send')
|
|
137
173
|
.description('Send a notification')
|
|
138
174
|
.requiredOption('--title <title>', 'notification title')
|
|
@@ -148,30 +184,28 @@ program
|
|
|
148
184
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
149
185
|
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
150
186
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
151
|
-
.option('--platform <platform>', '
|
|
187
|
+
.option('--platform <platform>', 'limit optional fields to ios or macos (default: both)')
|
|
152
188
|
.option('--sound <sound>', 'default | done | attention | alert | none')
|
|
153
|
-
.option('--badge <n>', 'app badge count', (v: string) => Number(v))
|
|
154
189
|
.option('--thread-id <id>', 'group related notifications')
|
|
155
190
|
.option('--level <level>', 'interruption level: passive | active | time_sensitive')
|
|
156
|
-
.option('--relevance <score>', '0..1 relevance score', (v: string) => Number(v))
|
|
157
|
-
.option('--target-content-id <id>')
|
|
158
191
|
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
159
192
|
.option('--image <path|url|media_id>', 'upload or attach an image')
|
|
160
193
|
.option('--reply', 'enable the inline reply action and block for the answer')
|
|
161
194
|
.option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
|
|
162
|
-
.option('--reply-window <seconds>', 'how long the server accepts a reply (default:
|
|
195
|
+
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
|
|
163
196
|
.option(
|
|
164
197
|
'--reply-choice <label>',
|
|
165
|
-
'with --reply, ask a closed question
|
|
198
|
+
'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
|
|
166
199
|
(v: string, all: string[]) => [...all, v], [],
|
|
167
200
|
)
|
|
201
|
+
.option('--reply-multi', 'with --reply-choice, several answers may be selected')
|
|
168
202
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
169
203
|
// a message pointing at `ask` instead of "unknown option".
|
|
170
204
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
171
205
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
172
206
|
.option('--no-wait', 'return immediately after acceptance')
|
|
173
207
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
174
|
-
.option('--base-url <url>', '
|
|
208
|
+
.option('--base-url <url>', 'Notifai server URL')
|
|
175
209
|
.option('--json', 'print the full submission receipt as JSON')
|
|
176
210
|
.action(async (opts: Record<string, unknown>) => {
|
|
177
211
|
// commander maps --no-wait onto the same "wait" flag; disentangle.
|
|
@@ -206,13 +240,19 @@ program
|
|
|
206
240
|
process.exit(await sendCommand(deps, flags))
|
|
207
241
|
})
|
|
208
242
|
|
|
243
|
+
send.addHelpText(
|
|
244
|
+
'after',
|
|
245
|
+
`\nKind profiles (used unless --sound, --level, or saved user config overrides them):\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
|
|
246
|
+
)
|
|
247
|
+
|
|
209
248
|
program
|
|
210
|
-
.command('replies
|
|
249
|
+
.command('replies [request_id]')
|
|
211
250
|
.description('Retrieve replies for a notification request')
|
|
251
|
+
.option('--pending', 'use the pushed question pending for this project session')
|
|
212
252
|
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
213
253
|
.option('--after <seq>', 'return replies after this sequence number', (v: string) => Number(v))
|
|
214
254
|
.option('--json', 'machine-readable output')
|
|
215
|
-
.action(async (requestId: string, opts: { wait?: number; after?: number; json?: boolean }) => {
|
|
255
|
+
.action(async (requestId: string | undefined, opts: { wait?: number; after?: number; json?: boolean; pending?: boolean }) => {
|
|
216
256
|
process.exit(await repliesCommand(deps, requestId, opts))
|
|
217
257
|
})
|
|
218
258
|
|
|
@@ -225,19 +265,55 @@ program
|
|
|
225
265
|
})
|
|
226
266
|
|
|
227
267
|
program
|
|
228
|
-
.command('ask
|
|
268
|
+
.command('ask [question]')
|
|
229
269
|
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
230
270
|
.option(
|
|
231
271
|
'--choice <label>',
|
|
232
|
-
'answers to offer instead of free text
|
|
272
|
+
'answers to offer instead of free text; repeat the flag once per answer (2-6)',
|
|
233
273
|
(v: string, all: string[]) => [...all, v], [],
|
|
234
274
|
)
|
|
275
|
+
.option('--multi', 'with --choice, several answers may be selected')
|
|
276
|
+
.option('--detail <markdown>', 'long-form context shown only in the app, never on the banner')
|
|
277
|
+
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
278
|
+
.option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
|
|
235
279
|
.option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
|
|
236
|
-
.action((question: string, opts: {
|
|
280
|
+
.action((question: string | undefined, opts: {
|
|
281
|
+
choice?: string[]
|
|
282
|
+
multi?: boolean
|
|
283
|
+
detail?: string
|
|
284
|
+
detailFile?: string
|
|
285
|
+
form?: string
|
|
286
|
+
session?: string
|
|
287
|
+
}) => {
|
|
288
|
+
let detail = opts.detail
|
|
289
|
+
if (typeof opts.detailFile === 'string') {
|
|
290
|
+
if (detail !== undefined) {
|
|
291
|
+
deps.io.err('Pass either --detail or --detail-file, not both.')
|
|
292
|
+
process.exit(2)
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
detail = readFileSync(opts.detailFile === '-' ? 0 : opts.detailFile, 'utf8')
|
|
296
|
+
} catch (err) {
|
|
297
|
+
deps.io.err(`Could not read ${opts.detailFile}: ${String(err)}`)
|
|
298
|
+
process.exit(2)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let form: string | undefined
|
|
302
|
+
if (typeof opts.form === 'string') {
|
|
303
|
+
try {
|
|
304
|
+
form = readFileSync(opts.form === '-' ? 0 : opts.form, 'utf8')
|
|
305
|
+
} catch (err) {
|
|
306
|
+
deps.io.err(`Could not read ${opts.form}: ${String(err)}`)
|
|
307
|
+
process.exit(2)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
237
310
|
// commander's collector defaults to []; an empty list means "not asked".
|
|
238
311
|
const flags: Parameters<typeof askCommand>[2] = {
|
|
239
312
|
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
240
313
|
...(opts.choice?.length ? { choice: opts.choice } : {}),
|
|
314
|
+
...(opts.multi ? { multi: true } : {}),
|
|
315
|
+
...(detail !== undefined ? { detail } : {}),
|
|
316
|
+
...(form !== undefined ? { form } : {}),
|
|
241
317
|
}
|
|
242
318
|
process.exit(askCommand(deps, question, flags))
|
|
243
319
|
})
|
|
@@ -253,13 +329,14 @@ program
|
|
|
253
329
|
.command('hook <event>')
|
|
254
330
|
.description('Internal: run a harness hook (reads hook JSON on stdin)')
|
|
255
331
|
// Inert, and the point of it is that it is inert: the installed command line
|
|
256
|
-
// carries a marker that says "
|
|
332
|
+
// carries a marker that says "Notifai wrote this" independently of which
|
|
257
333
|
// checkout wrote it.
|
|
258
334
|
.option('--owner <name>', 'internal ownership marker')
|
|
259
335
|
.option('--harness <name>', 'internal harness output adapter')
|
|
260
336
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
337
|
+
const harness = HARNESSES.find((candidate) => candidate === opts.harness)
|
|
261
338
|
process.exit(
|
|
262
|
-
await hookRunCommand(deps, event, readStdin,
|
|
339
|
+
await hookRunCommand(deps, event, readStdin, harness),
|
|
263
340
|
)
|
|
264
341
|
})
|
|
265
342
|
|
|
@@ -269,7 +346,7 @@ hooks
|
|
|
269
346
|
.description('Wire this harness to route registered questions to your devices')
|
|
270
347
|
.option(
|
|
271
348
|
'--harness <name>',
|
|
272
|
-
'claude-code | codex | cursor | opencode (default: detected; OpenCode
|
|
349
|
+
'claude-code | codex | cursor | opencode (default: detected; OpenCode delivers answers on the next prompt)',
|
|
273
350
|
)
|
|
274
351
|
.option('--global', 'install for every project instead of just this one')
|
|
275
352
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -312,27 +389,4 @@ config
|
|
|
312
389
|
},
|
|
313
390
|
)
|
|
314
391
|
|
|
315
|
-
program
|
|
316
|
-
.command('init')
|
|
317
|
-
.description(
|
|
318
|
-
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
319
|
-
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
320
|
-
)
|
|
321
|
-
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
322
|
-
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
323
|
-
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
324
|
-
.option('--hooks', 'install harness hooks for registered-question routing')
|
|
325
|
-
.option('--no-hooks', 'skip the hooks without being asked')
|
|
326
|
-
.action(async (opts: { projectId?: string; skills?: boolean; hooks?: boolean }) => {
|
|
327
|
-
process.exit(await initCommand(deps, opts))
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
program
|
|
331
|
-
.command('doctor')
|
|
332
|
-
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof (no live send)')
|
|
333
|
-
.option('--json', 'machine-readable output')
|
|
334
|
-
.action(async (opts: { json?: boolean }) => {
|
|
335
|
-
process.exit(await doctorCommand(deps, opts))
|
|
336
|
-
})
|
|
337
|
-
|
|
338
392
|
await program.parseAsync(process.argv)
|