@raidiant/notifai 0.1.7 → 0.1.8
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 +9 -2
- 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 +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- 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 +45 -33
- 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 +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
|
@@ -97,6 +97,22 @@ describe('hook config', () => {
|
|
|
97
97
|
expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
98
98
|
expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
99
99
|
})
|
|
100
|
+
|
|
101
|
+
it('stamps the exact harness on every generated command', () => {
|
|
102
|
+
const config = buildHookConfig({
|
|
103
|
+
execPath: EXEC,
|
|
104
|
+
scriptPath: SCRIPT,
|
|
105
|
+
replyTimeoutSeconds: 240,
|
|
106
|
+
graceSeconds: 0,
|
|
107
|
+
harness: 'claude-code',
|
|
108
|
+
})
|
|
109
|
+
expect(
|
|
110
|
+
Object.values(config)
|
|
111
|
+
.flatMap((groups) => groups)
|
|
112
|
+
.flatMap((group) => group.hooks)
|
|
113
|
+
.every((handler) => handler.command.includes('--harness claude-code')),
|
|
114
|
+
).toBe(true)
|
|
115
|
+
})
|
|
100
116
|
})
|
|
101
117
|
|
|
102
118
|
describe('merging into existing settings', () => {
|
|
@@ -450,6 +466,7 @@ describe('the OpenCode adapter', () => {
|
|
|
450
466
|
it('shells out to the same hook commands the other harnesses run', () => {
|
|
451
467
|
// The whole point: presence, escalation and retirement stay in the CLI.
|
|
452
468
|
expect(source).toContain('"hook", event')
|
|
469
|
+
expect(source).toContain('"--harness", "opencode"')
|
|
453
470
|
expect(source).toContain(JSON.stringify(SCRIPT))
|
|
454
471
|
expect(source).toContain(JSON.stringify(EXEC))
|
|
455
472
|
})
|
|
@@ -464,6 +481,18 @@ describe('the OpenCode adapter', () => {
|
|
|
464
481
|
expect(source).not.toContain('"permission.ask"')
|
|
465
482
|
})
|
|
466
483
|
|
|
484
|
+
it('injects collected late answers into the next OpenCode user message', () => {
|
|
485
|
+
expect(source).toContain('hookSpecificOutput?.additionalContext')
|
|
486
|
+
expect(source).toContain('synthetic: true')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('injects an exact active-harness and session marker into OpenCode shells', () => {
|
|
490
|
+
expect(source).toContain('"shell.env"')
|
|
491
|
+
expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
|
|
492
|
+
expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
|
|
493
|
+
expect(source).toContain('input.sessionID')
|
|
494
|
+
})
|
|
495
|
+
|
|
467
496
|
it('carries the ownership marker so a second checkout replaces it', () => {
|
|
468
497
|
expect(source).toContain(OPENCODE_PLUGIN_MARKER)
|
|
469
498
|
expect(isOurOpencodePlugin(source)).toBe(true)
|
|
@@ -487,11 +516,17 @@ describe('the OpenCode adapter', () => {
|
|
|
487
516
|
// rather than a command line, so answering with the plugin's own path made
|
|
488
517
|
// an ordinary OpenCode install look like a second checkout and turned the
|
|
489
518
|
// duplicate check red on a healthy machine.
|
|
490
|
-
expect(opencodePluginTarget(source)).toEqual({
|
|
519
|
+
expect(opencodePluginTarget(source)).toEqual({
|
|
520
|
+
exec: EXEC,
|
|
521
|
+
script: SCRIPT,
|
|
522
|
+
current: true,
|
|
523
|
+
timeoutSeconds: 240,
|
|
524
|
+
})
|
|
491
525
|
expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
|
|
492
526
|
exec: EXEC,
|
|
493
527
|
script: SCRIPT,
|
|
494
528
|
current: false,
|
|
529
|
+
timeoutSeconds: 240,
|
|
495
530
|
})
|
|
496
531
|
expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
|
|
497
532
|
// 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,18 +184,15 @@ 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
198
|
'with --reply, ask a closed question (2-6 answers); one value splits on commas, or repeat the flag',
|
|
@@ -171,7 +204,7 @@ program
|
|
|
171
204
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
172
205
|
.option('--no-wait', 'return immediately after acceptance')
|
|
173
206
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
174
|
-
.option('--base-url <url>', '
|
|
207
|
+
.option('--base-url <url>', 'Notifai server URL')
|
|
175
208
|
.option('--json', 'print the full submission receipt as JSON')
|
|
176
209
|
.action(async (opts: Record<string, unknown>) => {
|
|
177
210
|
// commander maps --no-wait onto the same "wait" flag; disentangle.
|
|
@@ -206,13 +239,19 @@ program
|
|
|
206
239
|
process.exit(await sendCommand(deps, flags))
|
|
207
240
|
})
|
|
208
241
|
|
|
242
|
+
send.addHelpText(
|
|
243
|
+
'after',
|
|
244
|
+
`\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`,
|
|
245
|
+
)
|
|
246
|
+
|
|
209
247
|
program
|
|
210
|
-
.command('replies
|
|
248
|
+
.command('replies [request_id]')
|
|
211
249
|
.description('Retrieve replies for a notification request')
|
|
250
|
+
.option('--pending', 'use the pushed question pending for this project session')
|
|
212
251
|
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
213
252
|
.option('--after <seq>', 'return replies after this sequence number', (v: string) => Number(v))
|
|
214
253
|
.option('--json', 'machine-readable output')
|
|
215
|
-
.action(async (requestId: string, opts: { wait?: number; after?: number; json?: boolean }) => {
|
|
254
|
+
.action(async (requestId: string | undefined, opts: { wait?: number; after?: number; json?: boolean; pending?: boolean }) => {
|
|
216
255
|
process.exit(await repliesCommand(deps, requestId, opts))
|
|
217
256
|
})
|
|
218
257
|
|
|
@@ -253,13 +292,14 @@ program
|
|
|
253
292
|
.command('hook <event>')
|
|
254
293
|
.description('Internal: run a harness hook (reads hook JSON on stdin)')
|
|
255
294
|
// Inert, and the point of it is that it is inert: the installed command line
|
|
256
|
-
// carries a marker that says "
|
|
295
|
+
// carries a marker that says "Notifai wrote this" independently of which
|
|
257
296
|
// checkout wrote it.
|
|
258
297
|
.option('--owner <name>', 'internal ownership marker')
|
|
259
298
|
.option('--harness <name>', 'internal harness output adapter')
|
|
260
299
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
300
|
+
const harness = HARNESSES.find((candidate) => candidate === opts.harness)
|
|
261
301
|
process.exit(
|
|
262
|
-
await hookRunCommand(deps, event, readStdin,
|
|
302
|
+
await hookRunCommand(deps, event, readStdin, harness),
|
|
263
303
|
)
|
|
264
304
|
})
|
|
265
305
|
|
|
@@ -269,7 +309,7 @@ hooks
|
|
|
269
309
|
.description('Wire this harness to route registered questions to your devices')
|
|
270
310
|
.option(
|
|
271
311
|
'--harness <name>',
|
|
272
|
-
'claude-code | codex | cursor | opencode (default: detected; OpenCode
|
|
312
|
+
'claude-code | codex | cursor | opencode (default: detected; OpenCode delivers answers on the next prompt)',
|
|
273
313
|
)
|
|
274
314
|
.option('--global', 'install for every project instead of just this one')
|
|
275
315
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -312,27 +352,4 @@ config
|
|
|
312
352
|
},
|
|
313
353
|
)
|
|
314
354
|
|
|
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
355
|
await program.parseAsync(process.argv)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** The two scopes offered by the skills installer. */
|
|
7
|
+
export type SkillScope = 'project' | 'global'
|
|
8
|
+
|
|
9
|
+
/** The installer-managed evidence needed by Notifai readiness. */
|
|
10
|
+
export interface NativeSkill {
|
|
11
|
+
name: string
|
|
12
|
+
scope: SkillScope
|
|
13
|
+
path: string
|
|
14
|
+
source: string | null
|
|
15
|
+
sourceType: string | null
|
|
16
|
+
sourceUrl: string | null
|
|
17
|
+
ref: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SkillsListResult {
|
|
21
|
+
skills: NativeSkill[]
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SkillsAddOptions {
|
|
26
|
+
source: string
|
|
27
|
+
skill: string
|
|
28
|
+
scope?: SkillScope
|
|
29
|
+
cwd: string
|
|
30
|
+
env: NodeJS.ProcessEnv
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NativeSkills {
|
|
34
|
+
/** Launch the native interactive `npx skills add` flow. */
|
|
35
|
+
add(options: SkillsAddOptions): Promise<number>
|
|
36
|
+
/** Ask the native installer for its managed install inventory. */
|
|
37
|
+
list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface JsonSkill {
|
|
41
|
+
name?: unknown
|
|
42
|
+
path?: unknown
|
|
43
|
+
scope?: unknown
|
|
44
|
+
source?: unknown
|
|
45
|
+
sourceType?: unknown
|
|
46
|
+
sourceUrl?: unknown
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LockEntry {
|
|
50
|
+
source?: unknown
|
|
51
|
+
sourceType?: unknown
|
|
52
|
+
sourceUrl?: unknown
|
|
53
|
+
ref?: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LockFile {
|
|
57
|
+
skills?: Record<string, LockEntry>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function skillLockPath(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): string {
|
|
61
|
+
if (scope === 'project') return path.join(cwd, 'skills-lock.json')
|
|
62
|
+
const stateHome = env['XDG_STATE_HOME']
|
|
63
|
+
const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
|
|
64
|
+
return stateHome !== undefined && stateHome !== ''
|
|
65
|
+
? path.join(stateHome, 'skills', '.skill-lock.json')
|
|
66
|
+
: path.join(home, '.agents', '.skill-lock.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockFile {
|
|
70
|
+
const file = skillLockPath(scope, cwd, env)
|
|
71
|
+
if (!existsSync(file)) return {}
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as LockFile
|
|
74
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
75
|
+
} catch {
|
|
76
|
+
return {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function lockEntryFor(lock: LockFile, name: string): LockEntry | undefined {
|
|
81
|
+
const direct = lock.skills?.[name]
|
|
82
|
+
if (direct) return direct
|
|
83
|
+
const normalized = name.toLowerCase().replace(/[\s_]+/g, '-')
|
|
84
|
+
const key = Object.keys(lock.skills ?? {}).find(
|
|
85
|
+
(candidate) => candidate.toLowerCase().replace(/[\s_]+/g, '-') === normalized,
|
|
86
|
+
)
|
|
87
|
+
return key === undefined ? undefined : lock.skills?.[key]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function jsonArray(stdout: string): unknown[] | null {
|
|
91
|
+
const start = stdout.indexOf('[')
|
|
92
|
+
const end = stdout.lastIndexOf(']')
|
|
93
|
+
if (start < 0 || end < start) return null
|
|
94
|
+
try {
|
|
95
|
+
const parsed: unknown = JSON.parse(stdout.slice(start, end + 1))
|
|
96
|
+
return Array.isArray(parsed) ? parsed : null
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function managedSkills(stdout: string, scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] | null {
|
|
103
|
+
const entries = jsonArray(stdout)
|
|
104
|
+
if (entries === null) return null
|
|
105
|
+
const lock = readLock(scope, cwd, env)
|
|
106
|
+
return entries.flatMap((entry): NativeSkill[] => {
|
|
107
|
+
if (entry === null || typeof entry !== 'object') return []
|
|
108
|
+
const value = entry as JsonSkill
|
|
109
|
+
if (
|
|
110
|
+
typeof value.name !== 'string' ||
|
|
111
|
+
typeof value.path !== 'string' ||
|
|
112
|
+
value.scope !== scope
|
|
113
|
+
) {
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
const lockEntry = lockEntryFor(lock, value.name)
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
name: value.name,
|
|
120
|
+
scope,
|
|
121
|
+
path: value.path,
|
|
122
|
+
source: typeof value.source === 'string' ? value.source : null,
|
|
123
|
+
sourceType: typeof value.sourceType === 'string' ? value.sourceType : null,
|
|
124
|
+
sourceUrl: typeof value.sourceUrl === 'string' ? value.sourceUrl : null,
|
|
125
|
+
ref: typeof lockEntry?.ref === 'string' ? lockEntry.ref : null,
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function run(
|
|
132
|
+
args: string[],
|
|
133
|
+
options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'inherit' | 'capture' },
|
|
134
|
+
): Promise<{ code: number; stdout: string }> {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const child = spawn('npx', args, {
|
|
137
|
+
cwd: options.cwd,
|
|
138
|
+
env: options.env,
|
|
139
|
+
stdio: options.stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'ignore'],
|
|
140
|
+
})
|
|
141
|
+
if (options.stdio === 'capture') {
|
|
142
|
+
let stdout = ''
|
|
143
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
144
|
+
stdout += chunk.toString()
|
|
145
|
+
})
|
|
146
|
+
child.on('error', () => resolve({ code: 1, stdout }))
|
|
147
|
+
child.on('exit', (code) => resolve({ code: code ?? 1, stdout }))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
child.on('error', () => resolve({ code: 1, stdout: '' }))
|
|
151
|
+
child.on('exit', (code) => resolve({ code: code ?? 1, stdout: '' }))
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The only process/filesystem adapter Notifai needs for the external installer. */
|
|
156
|
+
export const nativeSkills: NativeSkills = {
|
|
157
|
+
async add(options) {
|
|
158
|
+
const args = ['-y', 'skills', 'add', options.source, '--skill', options.skill]
|
|
159
|
+
if (options.scope === 'global') args.push('--global')
|
|
160
|
+
// An explicit scope is the unattended contract. Native `--yes` keeps all
|
|
161
|
+
// remaining installer prompts non-interactive after the scope is chosen.
|
|
162
|
+
if (options.scope !== undefined) args.push('--yes')
|
|
163
|
+
return (await run(args, { cwd: options.cwd, env: options.env, stdio: 'inherit' })).code
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async list(scope, cwd, env) {
|
|
167
|
+
const result = await run(
|
|
168
|
+
['-y', 'skills', 'list', '--json', ...(scope === 'global' ? ['--global'] : [])],
|
|
169
|
+
{ cwd, env, stdio: 'capture' },
|
|
170
|
+
)
|
|
171
|
+
if (result.code !== 0) return { skills: [], error: `npx skills list exited with code ${result.code}` }
|
|
172
|
+
const skills = managedSkills(result.stdout, scope, cwd, env)
|
|
173
|
+
return skills === null
|
|
174
|
+
? { skills: [], error: 'npx skills list returned invalid JSON' }
|
|
175
|
+
: { skills }
|
|
176
|
+
},
|
|
177
|
+
}
|