@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
|
@@ -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
|
+
}
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
|
|
|
30
30
|
export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
|
|
31
31
|
|
|
32
32
|
/** Bump when an installed generated file must be rewritten to remain functional. */
|
|
33
|
-
const OPENCODE_ADAPTER_VERSION =
|
|
33
|
+
const OPENCODE_ADAPTER_VERSION = 4
|
|
34
34
|
|
|
35
35
|
export function opencodePluginDir(
|
|
36
36
|
global: boolean,
|
|
@@ -88,7 +88,7 @@ function runHook(event, envelope) {
|
|
|
88
88
|
return new Promise((resolve) => {
|
|
89
89
|
let child
|
|
90
90
|
try {
|
|
91
|
-
child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai"], {
|
|
91
|
+
child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
|
|
92
92
|
env: process.env,
|
|
93
93
|
shell: false,
|
|
94
94
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -128,20 +128,51 @@ function runHook(event, envelope) {
|
|
|
128
128
|
})
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function additionalContext(stdout) {
|
|
132
|
+
if (typeof stdout !== "string" || stdout.trim() === "") return null
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(stdout)
|
|
135
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
136
|
+
return typeof context === "string" && context.length > 0 ? context : null
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
export const NotifAIPlugin = async ({ directory }) => {
|
|
132
143
|
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
133
144
|
|
|
134
145
|
return {
|
|
146
|
+
/** Exact active-harness identity for shell commands such as \`notifai ask\`. */
|
|
147
|
+
"shell.env": async (input, output) => {
|
|
148
|
+
output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
|
|
149
|
+
const sessionID = input?.sessionID
|
|
150
|
+
if (typeof sessionID === "string" && sessionID.length > 0) {
|
|
151
|
+
output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
135
155
|
/**
|
|
136
156
|
* Presence. The user typing is the signal every harness shares, and it is
|
|
137
157
|
* also what publishes the project -> session pointer \`notifai ask\` reads.
|
|
138
158
|
*/
|
|
139
|
-
"chat.message": async (input) => {
|
|
140
|
-
await runHook("user-prompt-submit", {
|
|
159
|
+
"chat.message": async (input, output) => {
|
|
160
|
+
const stdout = await runHook("user-prompt-submit", {
|
|
141
161
|
session_id: input?.sessionID ?? input?.info?.sessionID,
|
|
142
162
|
cwd,
|
|
143
163
|
hook_event_name: "UserPromptSubmit",
|
|
144
164
|
})
|
|
165
|
+
const context = additionalContext(stdout)
|
|
166
|
+
if (context !== null) {
|
|
167
|
+
output.parts.push({
|
|
168
|
+
id: "prt_notifai_" + Date.now(),
|
|
169
|
+
sessionID: input.sessionID,
|
|
170
|
+
messageID: output.message.id,
|
|
171
|
+
type: "text",
|
|
172
|
+
text: context,
|
|
173
|
+
synthetic: true,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
145
176
|
},
|
|
146
177
|
|
|
147
178
|
/** Session lifecycle events are delivered through OpenCode's event bus. */
|
|
@@ -185,17 +216,19 @@ export function isOurOpencodePlugin(contents: string): boolean {
|
|
|
185
216
|
*/
|
|
186
217
|
export function opencodePluginTarget(
|
|
187
218
|
contents: string,
|
|
188
|
-
): { exec: string; script: string; current: boolean } | null {
|
|
219
|
+
): { exec: string; script: string; current: boolean; timeoutSeconds?: number } | null {
|
|
189
220
|
if (!isOurOpencodePlugin(contents)) return null
|
|
190
221
|
const exec = /^const EXEC = (".*")$/m.exec(contents)?.[1]
|
|
191
222
|
const script = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
|
|
192
223
|
const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
|
|
224
|
+
const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
|
|
193
225
|
if (exec === undefined || script === undefined) return null
|
|
194
226
|
try {
|
|
195
227
|
return {
|
|
196
228
|
exec: JSON.parse(exec) as string,
|
|
197
229
|
script: JSON.parse(script) as string,
|
|
198
230
|
current: version === OPENCODE_ADAPTER_VERSION,
|
|
231
|
+
...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
|
|
199
232
|
}
|
|
200
233
|
} catch {
|
|
201
234
|
return null
|
package/src/send.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CLI_SOUNDS,
|
|
3
|
+
COLLAPSE_KEY_MAX_BYTES,
|
|
3
4
|
INTERRUPTION_LEVELS,
|
|
4
5
|
NOTIFICATION_SCHEMA_VERSION,
|
|
6
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
5
7
|
type IosOptionsT,
|
|
6
8
|
type LifecycleT,
|
|
7
9
|
type MacosOptionsT,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
NOTIFICATION_KINDS,
|
|
10
12
|
PLATFORMS,
|
|
11
13
|
type Platform,
|
|
14
|
+
type QuestionT,
|
|
12
15
|
type SubmissionReceipt,
|
|
13
16
|
} from '@raidiant/notifai-protocol'
|
|
14
17
|
import type { CliConfig } from './config.js'
|
|
@@ -31,11 +34,8 @@ export interface SendFlags {
|
|
|
31
34
|
ttl?: number
|
|
32
35
|
collapseKey?: string
|
|
33
36
|
sound?: string
|
|
34
|
-
badge?: number
|
|
35
37
|
threadId?: string
|
|
36
38
|
level?: string
|
|
37
|
-
relevance?: number
|
|
38
|
-
targetContentId?: string
|
|
39
39
|
data?: string[]
|
|
40
40
|
image?: string
|
|
41
41
|
/** Enable the inline reply action for this Notification Request. */
|
|
@@ -43,10 +43,17 @@ export interface SendFlags {
|
|
|
43
43
|
/** How long the server accepts a reply after submission. */
|
|
44
44
|
replyWindow?: number
|
|
45
45
|
/**
|
|
46
|
-
* Answer labels; turns the reply into a closed question. One
|
|
47
|
-
*
|
|
46
|
+
* Answer labels; turns the reply into a closed question. One flag occurrence
|
|
47
|
+
* per label, always — a label is a label, whatever characters it contains.
|
|
48
48
|
*/
|
|
49
|
-
replyChoice?: string
|
|
49
|
+
replyChoice?: string[]
|
|
50
|
+
/** The user may select several of the offered answers. */
|
|
51
|
+
replyMulti?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* A full question set, overriding the single question the flags describe.
|
|
54
|
+
* Not a user-facing flag: `ask --form` and the hooks build it.
|
|
55
|
+
*/
|
|
56
|
+
questions?: QuestionT[]
|
|
50
57
|
/** Platform whose optional notification fields these flags configure. */
|
|
51
58
|
platform?: string
|
|
52
59
|
/**
|
|
@@ -72,14 +79,21 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
72
79
|
const targets: NotificationDraftT['targets'] =
|
|
73
80
|
deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
|
|
74
81
|
|
|
75
|
-
const
|
|
82
|
+
const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
|
|
83
|
+
const profile = KIND_PROFILES[effectiveKind as keyof typeof KIND_PROFILES]
|
|
84
|
+
const configuredSound = config.sound.source === 'default' ? undefined : config.sound.value
|
|
85
|
+
const sound = flags.sound ?? configuredSound ?? profile?.sound
|
|
76
86
|
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
77
87
|
return {
|
|
78
88
|
ok: false,
|
|
79
89
|
error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
|
|
80
90
|
}
|
|
81
91
|
}
|
|
82
|
-
const
|
|
92
|
+
const configuredLevel =
|
|
93
|
+
config.interruption_level.source === 'default'
|
|
94
|
+
? undefined
|
|
95
|
+
: config.interruption_level.value
|
|
96
|
+
const level = flags.level ?? configuredLevel ?? profile?.level
|
|
83
97
|
if (
|
|
84
98
|
level !== null &&
|
|
85
99
|
level !== undefined &&
|
|
@@ -93,13 +107,10 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
93
107
|
else if (sound !== null && sound !== undefined) {
|
|
94
108
|
options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
|
|
95
109
|
}
|
|
96
|
-
if (flags.badge !== undefined) options.badge = flags.badge
|
|
97
110
|
if (flags.threadId !== undefined) options.thread_id = flags.threadId
|
|
98
111
|
if (level !== null && level !== undefined) {
|
|
99
112
|
options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
|
|
100
113
|
}
|
|
101
|
-
if (flags.relevance !== undefined) options.relevance_score = flags.relevance
|
|
102
|
-
if (flags.targetContentId !== undefined) options.target_content_id = flags.targetContentId
|
|
103
114
|
if (flags.data?.length) {
|
|
104
115
|
const data: Record<string, string> = {}
|
|
105
116
|
for (const pair of flags.data) {
|
|
@@ -122,6 +133,16 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
122
133
|
|
|
123
134
|
const ttl = flags.ttl ?? config.ttl_seconds.value
|
|
124
135
|
const collapse = flags.collapseKey ?? config.collapse_key.value
|
|
136
|
+
if (
|
|
137
|
+
collapse !== null &&
|
|
138
|
+
collapse !== undefined &&
|
|
139
|
+
Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
|
|
140
|
+
) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
125
146
|
|
|
126
147
|
const project = flags.project ?? config.project.value
|
|
127
148
|
// Harnesses can export NOTIFAI_SESSION once instead of passing the flag
|
|
@@ -137,6 +158,24 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
137
158
|
error: CHOICE_USAGE,
|
|
138
159
|
}
|
|
139
160
|
}
|
|
161
|
+
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
162
|
+
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
|
|
163
|
+
}
|
|
164
|
+
// The banner body doubles as the question text on a single-question send, so
|
|
165
|
+
// it inherits the question's readable-in-full bound; context beyond that
|
|
166
|
+
// belongs in --detail, which never rides the banner.
|
|
167
|
+
if (
|
|
168
|
+
flags.reply &&
|
|
169
|
+
flags.questions === undefined &&
|
|
170
|
+
flags.body.length > QUESTION_TEXT_MAX_LENGTH
|
|
171
|
+
) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error:
|
|
175
|
+
`A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
|
|
176
|
+
'characters and put the longer context in --detail.',
|
|
177
|
+
}
|
|
178
|
+
}
|
|
140
179
|
|
|
141
180
|
const draft: NotificationDraftT = {
|
|
142
181
|
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
@@ -161,20 +200,35 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
|
|
|
161
200
|
...(flags.reply
|
|
162
201
|
? {
|
|
163
202
|
reply: {
|
|
164
|
-
expires_in_seconds: flags.replyWindow ??
|
|
165
|
-
|
|
203
|
+
expires_in_seconds: flags.replyWindow ?? 3600,
|
|
204
|
+
questions: flags.questions ?? [
|
|
205
|
+
{
|
|
206
|
+
id: 'q1',
|
|
207
|
+
text: flags.body,
|
|
208
|
+
...(choices !== null ? { choices } : {}),
|
|
209
|
+
...(flags.replyMulti ? { multi: true } : {}),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
166
212
|
},
|
|
167
213
|
}
|
|
168
214
|
: {}),
|
|
169
215
|
...(Object.keys(options).length > 0
|
|
170
|
-
? platform ===
|
|
171
|
-
? { platform: { ios: options } }
|
|
172
|
-
:
|
|
216
|
+
? flags.platform === undefined
|
|
217
|
+
? { platform: { ios: options, macos: options } }
|
|
218
|
+
: platform === 'ios'
|
|
219
|
+
? { platform: { ios: options } }
|
|
220
|
+
: { platform: { macos: options } }
|
|
173
221
|
: {}),
|
|
174
222
|
}
|
|
175
223
|
return { ok: true, draft, platform }
|
|
176
224
|
}
|
|
177
225
|
|
|
226
|
+
const KIND_PROFILES = {
|
|
227
|
+
update: { sound: 'none', level: 'passive' },
|
|
228
|
+
done: { sound: 'done', level: 'passive' },
|
|
229
|
+
question: { sound: 'attention', level: 'active' },
|
|
230
|
+
} as const
|
|
231
|
+
|
|
178
232
|
function resolvePlatform(value: string | undefined): Platform | null {
|
|
179
233
|
if (value === undefined) return 'ios'
|
|
180
234
|
return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
|
|
@@ -209,23 +263,19 @@ export function receiptExitCode(receipt: SubmissionReceipt): number {
|
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
212
|
-
*
|
|
266
|
+
* One flag occurrence, one answer: `--choice Staging --choice Production` ->
|
|
267
|
+
* `[{id:'staging',label:'Staging'}, ...]`.
|
|
213
268
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* agent meant two.
|
|
269
|
+
* Commas are ordinary characters. The old single-value comma split produced
|
|
270
|
+
* buttons the agent never intended ("Nothing, I'll review" became two answers)
|
|
271
|
+
* and nothing downstream could detect it, so the delimiter form is gone
|
|
272
|
+
* rather than guarded: a label is a label.
|
|
219
273
|
*/
|
|
220
274
|
export function parseChoices(
|
|
221
|
-
value: string
|
|
275
|
+
value: string[] | undefined,
|
|
222
276
|
): { id: string; label: string }[] | null | 'invalid' {
|
|
223
|
-
if (value === undefined) return null
|
|
224
|
-
const
|
|
225
|
-
if (raw.length === 0) return null
|
|
226
|
-
const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
|
|
227
|
-
.map((part) => part.trim())
|
|
228
|
-
.filter((part) => part.length > 0)
|
|
277
|
+
if (value === undefined || value.length === 0) return null
|
|
278
|
+
const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
|
|
229
279
|
if (labels.length < 2 || labels.length > 6) return 'invalid'
|
|
230
280
|
const choices = labels.map((label) => ({ id: slugify(label), label }))
|
|
231
281
|
if (choices.some((choice) => choice.id === '')) return 'invalid'
|
|
@@ -235,38 +285,10 @@ export function parseChoices(
|
|
|
235
285
|
|
|
236
286
|
/** Message shared by every surface that accepts choice labels. */
|
|
237
287
|
export const CHOICE_USAGE =
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Catches the split that looked like it worked.
|
|
243
|
-
*
|
|
244
|
-
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
245
|
-
* buttons rather than three, because the third label contained a comma. Nothing
|
|
246
|
-
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
247
|
-
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
248
|
-
* never intended.
|
|
249
|
-
*
|
|
250
|
-
* The tell is a comma followed by a space. A delimiter list is written
|
|
251
|
-
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
252
|
-
* will occasionally be wrong, which is exactly why this warns and names the
|
|
253
|
-
* escape hatch rather than rejecting: the split may well have been intended.
|
|
254
|
-
*/
|
|
255
|
-
export function ambiguousChoiceSplit(value: string | string[] | undefined): string | null {
|
|
256
|
-
if (value === undefined || Array.isArray(value)) return null
|
|
257
|
-
if (!/,\s/.test(value)) return null
|
|
258
|
-
const parts = value
|
|
259
|
-
.split(',')
|
|
260
|
-
.map((part) => part.trim())
|
|
261
|
-
.filter((part) => part.length > 0)
|
|
262
|
-
return (
|
|
263
|
-
`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
|
|
264
|
-
`(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
|
|
265
|
-
'pass --choice once per answer instead.'
|
|
266
|
-
)
|
|
267
|
-
}
|
|
288
|
+
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
289
|
+
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
268
290
|
|
|
269
|
-
function slugify(label: string): string {
|
|
291
|
+
export function slugify(label: string): string {
|
|
270
292
|
return label
|
|
271
293
|
.toLowerCase()
|
|
272
294
|
.replace(/[^a-z0-9]+/g, '-')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const skill = readFileSync(new URL('../../../skills/notifai/SKILL.md', import.meta.url), 'utf8')
|
|
5
|
+
const harnessReference = readFileSync(
|
|
6
|
+
new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
|
|
7
|
+
'utf8',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
describe('Notifai agent skill', () => {
|
|
11
|
+
it('leads with the notification decision before message composition', () => {
|
|
12
|
+
expect(skill.indexOf('## When to notify')).toBeGreaterThan(0)
|
|
13
|
+
expect(skill.indexOf('## When to notify')).toBeLessThan(skill.indexOf('## Compose and send'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('teaches kind profiles and a correctly classified completion', () => {
|
|
17
|
+
expect(skill).toContain('--kind done')
|
|
18
|
+
expect(skill).toContain('| `update` (default) | `none` | `passive` |')
|
|
19
|
+
expect(skill).toContain('| `done` | `done` | `passive` |')
|
|
20
|
+
expect(skill).toContain('| question (`--reply`) | `attention` | `active` |')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('documents reply lifecycle, routing, session minting, and doctor exits', () => {
|
|
24
|
+
for (const required of [
|
|
25
|
+
'--reply-choice',
|
|
26
|
+
'notifai close <request_id>',
|
|
27
|
+
'notifai devices',
|
|
28
|
+
'--device',
|
|
29
|
+
'--all',
|
|
30
|
+
"require('node:crypto').randomUUID()",
|
|
31
|
+
'exit status is nonzero',
|
|
32
|
+
]) {
|
|
33
|
+
expect(skill).toContain(required)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
38
|
+
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
39
|
+
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
40
|
+
expect(harnessReference).toContain(`**${harness}:**`)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|