@raidiant/notifai 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/src/commands.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
configBounds,
|
|
37
37
|
configDefaultValue,
|
|
38
38
|
findProjectConfigPath,
|
|
39
|
-
|
|
39
|
+
personalProjectConfigPath,
|
|
40
40
|
globalConfigPath,
|
|
41
41
|
loadConfig,
|
|
42
42
|
sessionConfigPath,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type LogLevel,
|
|
47
47
|
} from './config.js'
|
|
48
48
|
import { acceptedValues, configInfo } from './config-schema.js'
|
|
49
|
+
import { packageVersion, skillsSource } from './release.js'
|
|
49
50
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
50
51
|
import { withTargetFileLock } from './file-lock.js'
|
|
51
52
|
import {
|
|
@@ -56,8 +57,8 @@ import {
|
|
|
56
57
|
import type { CredentialStore, MachineCredential } from './credentials.js'
|
|
57
58
|
import {
|
|
58
59
|
firstBlocker,
|
|
59
|
-
openItems,
|
|
60
60
|
type Readiness,
|
|
61
|
+
type ReadinessRefresh,
|
|
61
62
|
type ReadinessState,
|
|
62
63
|
type StateStatus,
|
|
63
64
|
} from './readiness.js'
|
|
@@ -99,12 +100,18 @@ import {
|
|
|
99
100
|
applyPlan,
|
|
100
101
|
buildCursorHookConfig,
|
|
101
102
|
buildHookConfig,
|
|
103
|
+
collapseUnusedCodexRepresentation,
|
|
102
104
|
codexLayerDir,
|
|
105
|
+
codexLayerPaths,
|
|
106
|
+
codexRepresentationProblems,
|
|
103
107
|
codexTrustProblems,
|
|
104
108
|
codexProjectRoot,
|
|
105
109
|
detectHarness,
|
|
110
|
+
detectedHarnesses,
|
|
106
111
|
findInstallations,
|
|
107
112
|
handlerEvent,
|
|
113
|
+
hookDefinitionFiles,
|
|
114
|
+
inspectCodexLayer,
|
|
108
115
|
loadCursorSettings,
|
|
109
116
|
loadSettings,
|
|
110
117
|
mergeCursorHooks,
|
|
@@ -115,7 +122,7 @@ import {
|
|
|
115
122
|
type Harness,
|
|
116
123
|
type Installation,
|
|
117
124
|
} from './install-hooks.js'
|
|
118
|
-
import { HARNESS_CAPABILITIES } from './harnesses.js'
|
|
125
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
|
|
119
126
|
import {
|
|
120
127
|
claudeWakeRoute,
|
|
121
128
|
inspectClaudeInbox,
|
|
@@ -130,6 +137,8 @@ import {
|
|
|
130
137
|
import {
|
|
131
138
|
inspectHookAdapter,
|
|
132
139
|
installHookAdapter,
|
|
140
|
+
isNpxAdapterTarget,
|
|
141
|
+
type HookAdapterTarget,
|
|
133
142
|
} from './hook-adapter.js'
|
|
134
143
|
import {
|
|
135
144
|
isOurOpencodePlugin,
|
|
@@ -161,6 +170,12 @@ export interface CommandIo {
|
|
|
161
170
|
*/
|
|
162
171
|
interactive?: boolean
|
|
163
172
|
select?(message: string, options: { value: string; label: string; hint?: string }[]): Promise<string | null>
|
|
173
|
+
/** Interactive multi-select. `initial` are pre-selected values. */
|
|
174
|
+
multiselect?(
|
|
175
|
+
message: string,
|
|
176
|
+
options: { value: string; label: string; hint?: string }[],
|
|
177
|
+
initial?: string[],
|
|
178
|
+
): Promise<string[] | null>
|
|
164
179
|
intro?(title: string): Promise<void>
|
|
165
180
|
outro?(message: string): Promise<void>
|
|
166
181
|
note?(message: string, title?: string): Promise<void>
|
|
@@ -187,6 +202,8 @@ export interface CommandDeps {
|
|
|
187
202
|
cwd: string
|
|
188
203
|
/** Test seam; production fixes the hook adapter under os.homedir(). */
|
|
189
204
|
hookAdapterHome?: string
|
|
205
|
+
/** Test seam; production uses this process's Node and CLI paths. */
|
|
206
|
+
hookInstallTarget?: HookAdapterTarget
|
|
190
207
|
/** Test seam; production uses fetch against base_url. */
|
|
191
208
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
192
209
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
@@ -2124,19 +2141,79 @@ export interface HooksInstallFlags {
|
|
|
2124
2141
|
scriptPath?: string
|
|
2125
2142
|
}
|
|
2126
2143
|
|
|
2144
|
+
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
2145
|
+
export function runningViaNpx(env: NodeJS.ProcessEnv, scriptPath: string): boolean {
|
|
2146
|
+
if (env['npm_command'] === 'exec') return true
|
|
2147
|
+
return scriptPath.includes(`${path.sep}_npx${path.sep}`)
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function fileHookInstallTarget(
|
|
2151
|
+
target: HookAdapterTarget | undefined,
|
|
2152
|
+
): { execPath: string; scriptPath: string } | undefined {
|
|
2153
|
+
if (target === undefined || isNpxAdapterTarget(target)) return undefined
|
|
2154
|
+
return target
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags): HookAdapterTarget {
|
|
2158
|
+
if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
|
|
2159
|
+
return deps.hookInstallTarget
|
|
2160
|
+
}
|
|
2161
|
+
const fileTarget = fileHookInstallTarget(deps.hookInstallTarget)
|
|
2162
|
+
const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath
|
|
2163
|
+
const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2164
|
+
if (runningViaNpx(deps.env, scriptPath)) {
|
|
2165
|
+
const version = packageVersion()
|
|
2166
|
+
const npmCli = deps.env['npm_execpath']
|
|
2167
|
+
if (version === null) {
|
|
2168
|
+
throw new Error(
|
|
2169
|
+
'Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
|
|
2170
|
+
)
|
|
2171
|
+
}
|
|
2172
|
+
if (typeof npmCli !== 'string' || npmCli === '') {
|
|
2173
|
+
throw new Error(
|
|
2174
|
+
'This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
|
|
2175
|
+
)
|
|
2176
|
+
}
|
|
2177
|
+
return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` }
|
|
2178
|
+
}
|
|
2179
|
+
return { execPath, scriptPath }
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
|
|
2183
|
+
const label = HARNESS_LABELS[harness]
|
|
2184
|
+
if (deps.io.interactive === true && deps.io.note) {
|
|
2185
|
+
void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`)
|
|
2186
|
+
return
|
|
2187
|
+
}
|
|
2188
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
2189
|
+
deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`)
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2127
2192
|
export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
2193
|
+
if (flags.harness === undefined) {
|
|
2194
|
+
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
2195
|
+
if (detected.length === 0) {
|
|
2196
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
|
|
2197
|
+
return EXIT.usage
|
|
2198
|
+
}
|
|
2199
|
+
let ok = true
|
|
2200
|
+
for (const harness of detected) {
|
|
2201
|
+
if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok) ok = false
|
|
2202
|
+
}
|
|
2203
|
+
return ok ? EXIT.ok : EXIT.failed
|
|
2204
|
+
}
|
|
2128
2205
|
const harness = resolveHarness(deps, flags.harness)
|
|
2129
2206
|
if (!harness) return EXIT.usage
|
|
2130
|
-
const
|
|
2131
|
-
const scriptPath =
|
|
2207
|
+
const adapterTarget = resolveHookAdapterTarget(deps, flags)
|
|
2208
|
+
const scriptPath =
|
|
2209
|
+
flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2132
2210
|
let adapterPath: string
|
|
2133
2211
|
try {
|
|
2134
|
-
adapterPath = installHookAdapter(
|
|
2212
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path
|
|
2135
2213
|
} catch (err) {
|
|
2136
2214
|
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
2137
2215
|
return EXIT.failed
|
|
2138
2216
|
}
|
|
2139
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2140
2217
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
2141
2218
|
|
|
2142
2219
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
@@ -2149,9 +2226,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2149
2226
|
}
|
|
2150
2227
|
|
|
2151
2228
|
if (harness === 'cursor') {
|
|
2152
|
-
let merged: ReturnType<typeof mergeCursorHooks>
|
|
2153
2229
|
try {
|
|
2154
|
-
|
|
2230
|
+
withTargetFileLock(file, () => {
|
|
2155
2231
|
const document = loadCursorSettings(file)
|
|
2156
2232
|
const result = mergeCursorHooks(
|
|
2157
2233
|
document,
|
|
@@ -2168,26 +2244,16 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2168
2244
|
deps.io.err(String(err))
|
|
2169
2245
|
return EXIT.failed
|
|
2170
2246
|
}
|
|
2171
|
-
deps
|
|
2172
|
-
if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
|
|
2173
|
-
if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
|
|
2174
|
-
if (flags.global) {
|
|
2175
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2176
|
-
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
2177
|
-
} else {
|
|
2178
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2179
|
-
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
2180
|
-
}
|
|
2181
|
-
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
|
|
2182
|
-
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
|
|
2183
|
-
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
|
|
2247
|
+
printHooksInstallClose(deps, harness, file)
|
|
2184
2248
|
return EXIT.ok
|
|
2185
2249
|
}
|
|
2186
2250
|
|
|
2187
|
-
let
|
|
2251
|
+
let foreignStopCount = 0
|
|
2252
|
+
let collapsedFrom: string | null = null
|
|
2188
2253
|
try {
|
|
2189
|
-
|
|
2254
|
+
withTargetFileLock(file, () => {
|
|
2190
2255
|
const document = loadSettings(file)
|
|
2256
|
+
foreignStopCount = foreignStopHandlers(document).length
|
|
2191
2257
|
const result = mergeHooks(
|
|
2192
2258
|
document,
|
|
2193
2259
|
buildHookConfig({
|
|
@@ -2199,62 +2265,47 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2199
2265
|
applyPlan(file, result.document)
|
|
2200
2266
|
return result
|
|
2201
2267
|
})
|
|
2268
|
+
if (harness === 'codex') {
|
|
2269
|
+
collapsedFrom = collapseUnusedCodexRepresentation(
|
|
2270
|
+
file,
|
|
2271
|
+
scriptPath,
|
|
2272
|
+
codexLayerPaths(flags.global ?? false, deps.cwd, deps.env),
|
|
2273
|
+
)
|
|
2274
|
+
}
|
|
2202
2275
|
} catch (err) {
|
|
2203
2276
|
deps.io.err(String(err))
|
|
2204
2277
|
return EXIT.failed
|
|
2205
2278
|
}
|
|
2206
2279
|
|
|
2207
|
-
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
2208
|
-
if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
|
|
2209
|
-
if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
|
|
2210
|
-
if (merged.removed.length > 0) {
|
|
2211
|
-
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
|
|
2212
|
-
}
|
|
2213
|
-
deps.io.out('')
|
|
2214
|
-
deps.io.out(
|
|
2215
|
-
config.ask_grace_seconds.value === 0
|
|
2216
|
-
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
2217
|
-
: `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
|
|
2218
|
-
)
|
|
2219
2280
|
if (harness === 'codex') {
|
|
2220
2281
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
2221
|
-
if (layer !== null) {
|
|
2222
|
-
// Codex reads project hooks from the main repository but only looks when
|
|
2223
|
-
// a `.codex` directory sits at or above cwd, so a worktree install has to
|
|
2224
|
-
// write one file and create one directory in two different places. Doing
|
|
2225
|
-
// it silently would leave the next person deriving this the hard way
|
|
2226
|
-
//.
|
|
2227
|
-
mkdirSync(layer, { recursive: true })
|
|
2228
|
-
deps.io.out('')
|
|
2229
|
-
deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,')
|
|
2230
|
-
deps.io.out(`so they were written to ${file}. ${layer} was created so this`)
|
|
2231
|
-
deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`')
|
|
2232
|
-
deps.io.out('directory; rerun this installer from that worktree to create it.')
|
|
2233
|
-
}
|
|
2282
|
+
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
2234
2283
|
}
|
|
2235
|
-
deps
|
|
2236
|
-
if (
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2284
|
+
printHooksInstallClose(deps, harness, file)
|
|
2285
|
+
if (collapsedFrom !== null) {
|
|
2286
|
+
deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`)
|
|
2287
|
+
}
|
|
2288
|
+
if (foreignStopCount > 0) {
|
|
2240
2289
|
deps.io.out(
|
|
2241
|
-
|
|
2290
|
+
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
2242
2291
|
)
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
} else if (harness === 'claude-code') {
|
|
2249
|
-
deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has')
|
|
2250
|
-
deps.io.out('not fired, start a new Claude Code session and try one prompt again.')
|
|
2251
|
-
} else {
|
|
2252
|
-
deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2253
|
-
deps.io.out('start a new Codex session and try one prompt again.')
|
|
2292
|
+
}
|
|
2293
|
+
if (harness === 'codex') {
|
|
2294
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
|
|
2295
|
+
deps.io.out(problem)
|
|
2296
|
+
}
|
|
2254
2297
|
}
|
|
2255
2298
|
return EXIT.ok
|
|
2256
2299
|
}
|
|
2257
2300
|
|
|
2301
|
+
function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { command: string }[] }[]> }): { command: string }[] {
|
|
2302
|
+
const groups = document.hooks?.['Stop']
|
|
2303
|
+
if (!Array.isArray(groups)) return []
|
|
2304
|
+
return groups
|
|
2305
|
+
.flatMap((group) => group.hooks ?? [])
|
|
2306
|
+
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command))
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2258
2309
|
/**
|
|
2259
2310
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
2260
2311
|
* including one a different checkout wrote, matched on the managed marker for
|
|
@@ -2284,16 +2335,7 @@ function installOpencodePlugin(
|
|
|
2284
2335
|
deps.io.err(String(err))
|
|
2285
2336
|
return EXIT.failed
|
|
2286
2337
|
}
|
|
2287
|
-
deps
|
|
2288
|
-
deps.io.out('')
|
|
2289
|
-
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
2290
|
-
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
2291
|
-
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
|
|
2292
|
-
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
|
|
2293
|
-
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
|
|
2294
|
-
deps.io.out('the answer must return to the agent without another human prompt.')
|
|
2295
|
-
deps.io.out('')
|
|
2296
|
-
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
2338
|
+
printHooksInstallClose(deps, 'opencode', file)
|
|
2297
2339
|
return EXIT.ok
|
|
2298
2340
|
}
|
|
2299
2341
|
|
|
@@ -2349,28 +2391,33 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2349
2391
|
)
|
|
2350
2392
|
return EXIT.ok
|
|
2351
2393
|
}
|
|
2352
|
-
|
|
2394
|
+
const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
2395
|
+
const existing = files.filter((candidate) => existsSync(candidate))
|
|
2396
|
+
if (existing.length === 0) {
|
|
2397
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2398
|
+
return EXIT.ok
|
|
2399
|
+
}
|
|
2400
|
+
let removedAny = false
|
|
2353
2401
|
try {
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2402
|
+
for (const candidate of existing) {
|
|
2403
|
+
const stripped = withTargetFileLock(candidate, () => {
|
|
2404
|
+
const document = loadSettings(candidate)
|
|
2405
|
+
const result = removeHooks(document, scriptPath)
|
|
2406
|
+
applyPlan(candidate, result.document)
|
|
2407
|
+
return result
|
|
2408
|
+
})
|
|
2409
|
+
if (stripped.replaced.length > 0) {
|
|
2410
|
+
removedAny = true
|
|
2411
|
+
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2361
2414
|
} catch (err) {
|
|
2362
2415
|
deps.io.err(String(err))
|
|
2363
2416
|
return EXIT.failed
|
|
2364
2417
|
}
|
|
2365
|
-
if (
|
|
2366
|
-
deps.io.out(`
|
|
2367
|
-
return EXIT.ok
|
|
2418
|
+
if (!removedAny) {
|
|
2419
|
+
deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`)
|
|
2368
2420
|
}
|
|
2369
|
-
deps.io.out(
|
|
2370
|
-
stripped.replaced.length > 0
|
|
2371
|
-
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
2372
|
-
: `No Notifai hooks found in ${file}`,
|
|
2373
|
-
)
|
|
2374
2421
|
return EXIT.ok
|
|
2375
2422
|
}
|
|
2376
2423
|
|
|
@@ -2385,6 +2432,13 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2385
2432
|
}
|
|
2386
2433
|
}
|
|
2387
2434
|
|
|
2435
|
+
const HARNESS_LABEL: Record<Harness, string> = {
|
|
2436
|
+
'claude-code': 'Claude Code',
|
|
2437
|
+
codex: 'Codex',
|
|
2438
|
+
cursor: 'Cursor',
|
|
2439
|
+
opencode: 'OpenCode',
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2388
2442
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
2389
2443
|
if (requested !== undefined) {
|
|
2390
2444
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -2393,7 +2447,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2393
2447
|
)
|
|
2394
2448
|
return null
|
|
2395
2449
|
}
|
|
2396
|
-
const detected = detectHarness(deps.cwd)
|
|
2450
|
+
const detected = detectHarness(deps.cwd, deps.env)
|
|
2397
2451
|
if (!detected) {
|
|
2398
2452
|
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
|
|
2399
2453
|
return null
|
|
@@ -2401,6 +2455,48 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2401
2455
|
return detected
|
|
2402
2456
|
}
|
|
2403
2457
|
|
|
2458
|
+
/**
|
|
2459
|
+
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
2460
|
+
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
2461
|
+
* or names more than one.
|
|
2462
|
+
*/
|
|
2463
|
+
async function pickHarnessesToInstall(
|
|
2464
|
+
deps: CommandDeps,
|
|
2465
|
+
requested?: string,
|
|
2466
|
+
): Promise<Harness[] | null> {
|
|
2467
|
+
if (requested !== undefined) {
|
|
2468
|
+
const harness = resolveHarness(deps, requested)
|
|
2469
|
+
return harness === null ? null : [harness]
|
|
2470
|
+
}
|
|
2471
|
+
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
2472
|
+
if (detected.length === 1) return detected
|
|
2473
|
+
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2474
|
+
const picked = await deps.io.multiselect(
|
|
2475
|
+
'Which agent harnesses should Notifai wire here?',
|
|
2476
|
+
HARNESSES.map((name) => ({
|
|
2477
|
+
value: name,
|
|
2478
|
+
label: HARNESS_LABEL[name],
|
|
2479
|
+
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2480
|
+
})),
|
|
2481
|
+
detected,
|
|
2482
|
+
)
|
|
2483
|
+
if (picked === null) return null
|
|
2484
|
+
const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
|
|
2485
|
+
if (unknown.length > 0) {
|
|
2486
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
|
|
2487
|
+
return null
|
|
2488
|
+
}
|
|
2489
|
+
return picked as Harness[]
|
|
2490
|
+
}
|
|
2491
|
+
if (detected.length === 0) {
|
|
2492
|
+
deps.io.err(
|
|
2493
|
+
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
2494
|
+
)
|
|
2495
|
+
return null
|
|
2496
|
+
}
|
|
2497
|
+
return detected
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2404
2500
|
// ---------------------------------------------------------------------------
|
|
2405
2501
|
// config show / set / unset
|
|
2406
2502
|
// ---------------------------------------------------------------------------
|
|
@@ -2412,7 +2508,14 @@ export function configShowCommand(
|
|
|
2412
2508
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2413
2509
|
if (flags.json) {
|
|
2414
2510
|
const output = Object.fromEntries(
|
|
2415
|
-
CONFIG_KEYS.map((key) => [
|
|
2511
|
+
CONFIG_KEYS.map((key) => [
|
|
2512
|
+
key,
|
|
2513
|
+
{
|
|
2514
|
+
value: config[key].value,
|
|
2515
|
+
source: config[key].source,
|
|
2516
|
+
summary: configInfo(key).summary,
|
|
2517
|
+
},
|
|
2518
|
+
]),
|
|
2416
2519
|
)
|
|
2417
2520
|
deps.io.out(JSON.stringify(output, null, 2))
|
|
2418
2521
|
return EXIT.ok
|
|
@@ -2621,7 +2724,7 @@ async function configMutationTarget(
|
|
|
2621
2724
|
const selected = await deps.io.select('Where should this setting live?', [
|
|
2622
2725
|
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
2623
2726
|
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
2624
|
-
{ value: 'local', label: 'This project (personal)', hint: '
|
|
2727
|
+
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
2625
2728
|
])
|
|
2626
2729
|
if (selected === null) {
|
|
2627
2730
|
deps.io.err('No configuration layer selected.')
|
|
@@ -2633,7 +2736,7 @@ async function configMutationTarget(
|
|
|
2633
2736
|
const targetPath = flags.session
|
|
2634
2737
|
? sessionConfigPath(flags.session, deps.env)
|
|
2635
2738
|
: layer === 'local'
|
|
2636
|
-
? (
|
|
2739
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
2637
2740
|
: layer === 'project'
|
|
2638
2741
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
2639
2742
|
: globalConfigPath(deps.env)
|
|
@@ -2912,13 +3015,22 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
|
2912
3015
|
// ---------------------------------------------------------------------------
|
|
2913
3016
|
|
|
2914
3017
|
/**
|
|
2915
|
-
* Where `npx skills add` fetches the optional agent skill from
|
|
2916
|
-
*
|
|
2917
|
-
*
|
|
3018
|
+
* Where `npx skills add` fetches the optional agent skill from, derived from
|
|
3019
|
+
* this build's own version so the pin cannot drift from the release it names.
|
|
3020
|
+
* Null when the build cannot establish its version; see `./release.js`.
|
|
3021
|
+
*/
|
|
3022
|
+
export const SKILLS_SOURCE: string | null = skillsSource()
|
|
3023
|
+
|
|
3024
|
+
/**
|
|
3025
|
+
* How to refer to the pin in user-facing text.
|
|
3026
|
+
*
|
|
3027
|
+
* Only reached in a corrupted install, where naming the tag is impossible but
|
|
3028
|
+
* saying nothing would be worse — the sentence still has to read as English.
|
|
2918
3029
|
*/
|
|
2919
|
-
|
|
3030
|
+
const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI'
|
|
2920
3031
|
|
|
2921
3032
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
3033
|
+
if (SKILLS_SOURCE === null) return null
|
|
2922
3034
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
2923
3035
|
return match === null ? null : { source: match[1]!, ref: match[2]! }
|
|
2924
3036
|
}
|
|
@@ -2955,7 +3067,7 @@ async function skillReadiness(
|
|
|
2955
3067
|
id: 'skill',
|
|
2956
3068
|
title: 'Agent guidance skill',
|
|
2957
3069
|
status: 'ready',
|
|
2958
|
-
detail: `installed from ${
|
|
3070
|
+
detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
|
|
2959
3071
|
}
|
|
2960
3072
|
}
|
|
2961
3073
|
|
|
@@ -2970,7 +3082,7 @@ async function skillReadiness(
|
|
|
2970
3082
|
detail:
|
|
2971
3083
|
errors.length > 0
|
|
2972
3084
|
? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
|
|
2973
|
-
: `not installed from ${
|
|
3085
|
+
: `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
|
|
2974
3086
|
remedy: {
|
|
2975
3087
|
by: 'cli',
|
|
2976
3088
|
summary: 'install the skill agents follow when deciding to notify',
|
|
@@ -3117,8 +3229,8 @@ function observedCompanionReceipt(
|
|
|
3117
3229
|
* gap, so re-running is how you check the setup as much as how you create it.
|
|
3118
3230
|
* With a human at a terminal it walks them through the missing pieces; run by
|
|
3119
3231
|
* an agent it never prompts — each optional step is answered by a flag, and
|
|
3120
|
-
* whatever only the user can do (signing in, pairing a companion device) is
|
|
3121
|
-
* the
|
|
3232
|
+
* whatever only the user can do (signing in, pairing a companion device) is
|
|
3233
|
+
* printed as the next human action. An agent runs every CLI command itself.
|
|
3122
3234
|
*/
|
|
3123
3235
|
/**
|
|
3124
3236
|
* Close a gap the CLI is allowed to close on its own, without asking.
|
|
@@ -3149,21 +3261,13 @@ async function closeGap(
|
|
|
3149
3261
|
}
|
|
3150
3262
|
|
|
3151
3263
|
if (state.id === 'hooks') {
|
|
3152
|
-
|
|
3153
|
-
if (
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
)
|
|
3158
|
-
if (picked !== null) harness = picked as Harness
|
|
3264
|
+
const harnesses = await pickHarnessesToInstall(deps)
|
|
3265
|
+
if (harnesses === null || harnesses.length === 0) return 'failed'
|
|
3266
|
+
let ok = true
|
|
3267
|
+
for (const harness of harnesses) {
|
|
3268
|
+
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
|
|
3159
3269
|
}
|
|
3160
|
-
|
|
3161
|
-
deps.io.err(
|
|
3162
|
-
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
3163
|
-
)
|
|
3164
|
-
return 'failed'
|
|
3165
|
-
}
|
|
3166
|
-
return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed'
|
|
3270
|
+
return ok ? 'closed' : 'failed'
|
|
3167
3271
|
}
|
|
3168
3272
|
|
|
3169
3273
|
if (state.id === 'skill') {
|
|
@@ -3171,6 +3275,14 @@ async function closeGap(
|
|
|
3171
3275
|
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
|
|
3172
3276
|
return 'failed'
|
|
3173
3277
|
}
|
|
3278
|
+
// Refuse rather than reach for a mutable ref: installing the skill from a
|
|
3279
|
+
// moving branch is the one outcome the pin exists to prevent.
|
|
3280
|
+
if (SKILLS_SOURCE === null) {
|
|
3281
|
+
deps.io.err(
|
|
3282
|
+
'Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.',
|
|
3283
|
+
)
|
|
3284
|
+
return 'failed'
|
|
3285
|
+
}
|
|
3174
3286
|
const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
|
|
3175
3287
|
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
|
|
3176
3288
|
const addOptions = {
|
|
@@ -3499,6 +3611,13 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
3499
3611
|
return 'pending'
|
|
3500
3612
|
}
|
|
3501
3613
|
|
|
3614
|
+
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
3615
|
+
function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
|
|
3616
|
+
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
3617
|
+
? ['local']
|
|
3618
|
+
: undefined
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3502
3621
|
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
3503
3622
|
function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
|
|
3504
3623
|
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
@@ -3558,12 +3677,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3558
3677
|
}
|
|
3559
3678
|
await deps.io.intro?.('Notifai setup')
|
|
3560
3679
|
|
|
3561
|
-
const
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3680
|
+
const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
|
|
3681
|
+
let readiness = await assessReadiness(deps, skillOpts)
|
|
3682
|
+
const reassess = (refresh?: readonly ReadinessRefresh[]) =>
|
|
3683
|
+
assessReadiness(deps, {
|
|
3684
|
+
...skillOpts,
|
|
3685
|
+
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
3686
|
+
})
|
|
3567
3687
|
let failed = false
|
|
3568
3688
|
const attempted = new Set<string>()
|
|
3569
3689
|
|
|
@@ -3594,7 +3714,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3594
3714
|
const result = await closeGap(deps, state, flags)
|
|
3595
3715
|
if (result === 'failed') failed = true
|
|
3596
3716
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
3597
|
-
readiness = await reassess()
|
|
3717
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3598
3718
|
advanced = true
|
|
3599
3719
|
break
|
|
3600
3720
|
}
|
|
@@ -3602,7 +3722,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3602
3722
|
stop = true
|
|
3603
3723
|
break
|
|
3604
3724
|
}
|
|
3605
|
-
readiness = await reassess()
|
|
3725
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3606
3726
|
advanced = true
|
|
3607
3727
|
break
|
|
3608
3728
|
}
|
|
@@ -3615,7 +3735,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3615
3735
|
stop = true
|
|
3616
3736
|
break
|
|
3617
3737
|
}
|
|
3618
|
-
readiness = await reassess()
|
|
3738
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3619
3739
|
advanced = true
|
|
3620
3740
|
break
|
|
3621
3741
|
}
|
|
@@ -3664,37 +3784,81 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3664
3784
|
if (stop || !advanced) break
|
|
3665
3785
|
}
|
|
3666
3786
|
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3787
|
+
await printInitClose(deps, readiness, flags)
|
|
3788
|
+
const blocker = firstBlocker(readiness)
|
|
3789
|
+
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
3790
|
+
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
function isHookSubstate(id: string): boolean {
|
|
3794
|
+
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
|
|
3798
|
+
return readiness.states.filter((state) => {
|
|
3799
|
+
if (state.status !== 'optional-gap') return false
|
|
3800
|
+
if (isHookSubstate(state.id)) return false
|
|
3801
|
+
if (state.id === 'skill' && flags.skills === false) return false
|
|
3802
|
+
return true
|
|
3803
|
+
})
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
|
|
3807
|
+
for (const state of leftovers) {
|
|
3808
|
+
deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
3670
3809
|
}
|
|
3810
|
+
}
|
|
3671
3811
|
|
|
3812
|
+
function questionsWillRoute(readiness: Readiness): boolean {
|
|
3813
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
3814
|
+
const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
|
|
3815
|
+
return hooks?.status === 'ready' && settings?.status !== 'gap'
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
async function printInitClose(
|
|
3819
|
+
deps: CommandDeps,
|
|
3820
|
+
readiness: Readiness,
|
|
3821
|
+
flags: InitFlags,
|
|
3822
|
+
): Promise<void> {
|
|
3672
3823
|
const blocker = firstBlocker(readiness)
|
|
3824
|
+
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
3825
|
+
const questions = questionsWillRoute(readiness)
|
|
3826
|
+
const leftovers = leftoverOptionals(readiness, flags)
|
|
3827
|
+
|
|
3828
|
+
if (deps.io.interactive === true) {
|
|
3829
|
+
if (blocker === null) {
|
|
3830
|
+
const lines = [
|
|
3831
|
+
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
3832
|
+
questions
|
|
3833
|
+
? 'Questions will reach your devices.'
|
|
3834
|
+
: 'Questions stay in the terminal until hooks are installed.',
|
|
3835
|
+
]
|
|
3836
|
+
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
3837
|
+
printOptionalLeftovers(deps, leftovers)
|
|
3838
|
+
deps.io.out('All set. Agents in this project can notify you and ask questions.')
|
|
3839
|
+
await deps.io.outro?.('All set ✨')
|
|
3840
|
+
return
|
|
3841
|
+
}
|
|
3842
|
+
await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
|
|
3843
|
+
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
3844
|
+
deps.io.out(` ${remedyLine(blocker)}`)
|
|
3845
|
+
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3846
|
+
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
3847
|
+
}
|
|
3848
|
+
await deps.io.outro?.('One step remains (above)')
|
|
3849
|
+
return
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3673
3852
|
if (blocker === null) {
|
|
3674
|
-
|
|
3675
|
-
(state) => !(state.id === 'skill' && flags.skills === false),
|
|
3676
|
-
)
|
|
3677
|
-
for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
3853
|
+
printOptionalLeftovers(deps, leftovers)
|
|
3678
3854
|
deps.io.out('All set. Agents in this project can notify you and ask questions.')
|
|
3679
|
-
|
|
3680
|
-
return failed ? EXIT.failed : EXIT.ok
|
|
3855
|
+
return
|
|
3681
3856
|
}
|
|
3682
|
-
|
|
3683
|
-
// Exactly one. Everything else waits until this is done, because the next
|
|
3684
|
-
// gap is frequently a consequence of this one and naming it now would send
|
|
3685
|
-
// the reader off to fix something that is not actually wrong.
|
|
3686
|
-
deps.io.out('')
|
|
3687
3857
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
3688
3858
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
3689
|
-
// Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
|
|
3690
|
-
// how the rest is recovered either way.
|
|
3691
3859
|
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3692
3860
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
3693
3861
|
}
|
|
3694
|
-
await deps.io.outro?.('One step remains (above)')
|
|
3695
|
-
// An agent must be able to branch on setup being blocked without parsing
|
|
3696
|
-
// prose. A present human may deliberately leave and resume later.
|
|
3697
|
-
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
3698
3862
|
}
|
|
3699
3863
|
|
|
3700
3864
|
// ---------------------------------------------------------------------------
|
|
@@ -3740,6 +3904,66 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
3740
3904
|
}
|
|
3741
3905
|
}
|
|
3742
3906
|
|
|
3907
|
+
function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
|
|
3908
|
+
const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
|
|
3909
|
+
const projectSlug = config.project.value
|
|
3910
|
+
return projectSlug !== null
|
|
3911
|
+
? {
|
|
3912
|
+
id: 'project',
|
|
3913
|
+
title: 'Project identity',
|
|
3914
|
+
status: 'ready',
|
|
3915
|
+
detail: `"${projectSlug}" (${config.project.source})`,
|
|
3916
|
+
}
|
|
3917
|
+
: {
|
|
3918
|
+
id: 'project',
|
|
3919
|
+
title: 'Project identity',
|
|
3920
|
+
// Not a blocker: a send without a project simply carries no project
|
|
3921
|
+
// identity. init always sets one because it is free and reversible,
|
|
3922
|
+
// but an unlabelled setup works, so this must not go red.
|
|
3923
|
+
status: 'optional-gap',
|
|
3924
|
+
detail: `not set in ${configPath} — sends from here carry no project identity`,
|
|
3925
|
+
remedy: {
|
|
3926
|
+
by: 'cli',
|
|
3927
|
+
summary: 'name this project after its directory',
|
|
3928
|
+
command: 'notifai init',
|
|
3929
|
+
},
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
function remoteStatesFrom(previous: Readiness): {
|
|
3934
|
+
credential: ReadinessState
|
|
3935
|
+
server: ReadinessState
|
|
3936
|
+
contract: ReadinessState
|
|
3937
|
+
auth: ReadinessState
|
|
3938
|
+
devices: ReadinessState
|
|
3939
|
+
proof: ReadinessState
|
|
3940
|
+
} | null {
|
|
3941
|
+
const pick = (id: string): ReadinessState | undefined => previous.states.find((state) => state.id === id)
|
|
3942
|
+
const credential = pick('credential')
|
|
3943
|
+
const server = pick('server')
|
|
3944
|
+
const contract = pick('contract')
|
|
3945
|
+
const auth = pick('auth')
|
|
3946
|
+
const devices = pick('devices')
|
|
3947
|
+
const proof = pick('proof')
|
|
3948
|
+
if (
|
|
3949
|
+
credential === undefined ||
|
|
3950
|
+
server === undefined ||
|
|
3951
|
+
contract === undefined ||
|
|
3952
|
+
auth === undefined ||
|
|
3953
|
+
devices === undefined ||
|
|
3954
|
+
proof === undefined
|
|
3955
|
+
) {
|
|
3956
|
+
return null
|
|
3957
|
+
}
|
|
3958
|
+
return { credential, server, contract, auth, devices, proof }
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
|
|
3962
|
+
if (config.base_url.source === 'default') return false
|
|
3963
|
+
const server = previous.states.find((state) => state.id === 'server')
|
|
3964
|
+
return server === undefined || !server.detail.includes(config.base_url.value)
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3743
3967
|
/**
|
|
3744
3968
|
* Read the whole setup once, in dependency order.
|
|
3745
3969
|
*
|
|
@@ -3751,38 +3975,46 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
3751
3975
|
*/
|
|
3752
3976
|
export async function assessReadiness(
|
|
3753
3977
|
deps: CommandDeps,
|
|
3754
|
-
options: {
|
|
3978
|
+
options: {
|
|
3979
|
+
skillScope?: SkillScope
|
|
3980
|
+
previous?: Readiness
|
|
3981
|
+
refresh?: readonly ReadinessRefresh[]
|
|
3982
|
+
} = {},
|
|
3755
3983
|
): Promise<Readiness> {
|
|
3756
3984
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
3985
|
+
const previous = options.previous
|
|
3986
|
+
const refresh = options.refresh
|
|
3987
|
+
if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
|
|
3988
|
+
|
|
3989
|
+
const reuseRemote =
|
|
3990
|
+
previous !== undefined &&
|
|
3991
|
+
refresh !== undefined &&
|
|
3992
|
+
!refresh.includes('remote') &&
|
|
3993
|
+
!remoteInvalidatedByConfig(previous, config)
|
|
3994
|
+
if (reuseRemote) {
|
|
3995
|
+
const reused = remoteStatesFrom(previous)
|
|
3996
|
+
if (reused !== null) {
|
|
3997
|
+
return {
|
|
3998
|
+
states: [
|
|
3999
|
+
projectReadiness(deps, config),
|
|
4000
|
+
reused.credential,
|
|
4001
|
+
reused.server,
|
|
4002
|
+
reused.contract,
|
|
4003
|
+
reused.auth,
|
|
4004
|
+
...hookStates(deps),
|
|
4005
|
+
await skillReadiness(deps, options.skillScope),
|
|
4006
|
+
reused.devices,
|
|
4007
|
+
reused.proof,
|
|
4008
|
+
],
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
|
|
3757
4013
|
const states: ReadinessState[] = []
|
|
3758
4014
|
let accountClient: ApiClient | null = null
|
|
3759
4015
|
let accountDevices: RoutableDevice[] | null = null
|
|
3760
4016
|
|
|
3761
|
-
|
|
3762
|
-
const projectSlug = config.project.value
|
|
3763
|
-
states.push(
|
|
3764
|
-
projectSlug !== null
|
|
3765
|
-
? {
|
|
3766
|
-
id: 'project',
|
|
3767
|
-
title: 'Project identity',
|
|
3768
|
-
status: 'ready',
|
|
3769
|
-
detail: `"${projectSlug}" (${config.project.source})`,
|
|
3770
|
-
}
|
|
3771
|
-
: {
|
|
3772
|
-
id: 'project',
|
|
3773
|
-
title: 'Project identity',
|
|
3774
|
-
// Not a blocker: a send without a project simply carries no project
|
|
3775
|
-
// identity. init always sets one because it is free and reversible,
|
|
3776
|
-
// but an unlabelled setup works, so this must not go red.
|
|
3777
|
-
status: 'optional-gap',
|
|
3778
|
-
detail: `not set in ${configPath} — sends from here carry no project identity`,
|
|
3779
|
-
remedy: {
|
|
3780
|
-
by: 'cli',
|
|
3781
|
-
summary: 'name this project after its directory',
|
|
3782
|
-
command: 'notifai init',
|
|
3783
|
-
},
|
|
3784
|
-
},
|
|
3785
|
-
)
|
|
4017
|
+
states.push(projectReadiness(deps, config))
|
|
3786
4018
|
|
|
3787
4019
|
const credential = deps.store.load()
|
|
3788
4020
|
states.push(
|
|
@@ -3825,10 +4057,10 @@ export async function assessReadiness(
|
|
|
3825
4057
|
id: 'server',
|
|
3826
4058
|
title: 'Service',
|
|
3827
4059
|
status: 'gap',
|
|
3828
|
-
detail: `cannot reach ${baseUrl}
|
|
4060
|
+
detail: `cannot reach ${baseUrl}`,
|
|
3829
4061
|
remedy: {
|
|
3830
4062
|
by: 'user-here',
|
|
3831
|
-
summary: 'check your network
|
|
4063
|
+
summary: 'check your network',
|
|
3832
4064
|
command: 'notifai doctor',
|
|
3833
4065
|
},
|
|
3834
4066
|
},
|
|
@@ -4054,8 +4286,12 @@ async function setupProofState(
|
|
|
4054
4286
|
}
|
|
4055
4287
|
}
|
|
4056
4288
|
|
|
4057
|
-
export async function doctorCommand(
|
|
4058
|
-
|
|
4289
|
+
export async function doctorCommand(
|
|
4290
|
+
deps: CommandDeps,
|
|
4291
|
+
flags: { json?: boolean },
|
|
4292
|
+
options: { readiness?: Readiness } = {},
|
|
4293
|
+
): Promise<number> {
|
|
4294
|
+
const readiness = options.readiness ?? (await assessReadiness(deps))
|
|
4059
4295
|
const blocker = firstBlocker(readiness)
|
|
4060
4296
|
const ok = blocker === null
|
|
4061
4297
|
|
|
@@ -4299,6 +4535,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4299
4535
|
.join(', '),
|
|
4300
4536
|
})
|
|
4301
4537
|
|
|
4538
|
+
const wired = new Set(installations.map((installation) => installation.harness))
|
|
4539
|
+
const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness))
|
|
4540
|
+
if (unwired.length > 0) {
|
|
4541
|
+
checks.push({
|
|
4542
|
+
name: 'hooks (detected)',
|
|
4543
|
+
ok: false,
|
|
4544
|
+
informational: true,
|
|
4545
|
+
detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
|
|
4546
|
+
remedy: {
|
|
4547
|
+
summary: 'install hooks for every detected harness',
|
|
4548
|
+
command: 'notifai hooks install',
|
|
4549
|
+
},
|
|
4550
|
+
})
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4302
4553
|
const { active, contested } = resolveActiveHarness(
|
|
4303
4554
|
deps.env,
|
|
4304
4555
|
deps.cwd,
|
|
@@ -4438,7 +4689,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4438
4689
|
harness,
|
|
4439
4690
|
installations: installations.filter((i) => i.harness === harness),
|
|
4440
4691
|
}))
|
|
4441
|
-
.filter(
|
|
4692
|
+
.filter(
|
|
4693
|
+
(entry) =>
|
|
4694
|
+
entry.installations.some((installation) => installation.global) &&
|
|
4695
|
+
entry.installations.some((installation) => !installation.global),
|
|
4696
|
+
)
|
|
4442
4697
|
if (duplicated.length > 0) {
|
|
4443
4698
|
checks.push({
|
|
4444
4699
|
name: 'hooks (duplicates)',
|
|
@@ -4452,6 +4707,26 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4452
4707
|
})
|
|
4453
4708
|
}
|
|
4454
4709
|
|
|
4710
|
+
const representationProblems = codexRepresentationProblems(deps.cwd, deps.env)
|
|
4711
|
+
if (representationProblems.length > 0) {
|
|
4712
|
+
const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
|
|
4713
|
+
? 'notifai hooks install --harness codex --global'
|
|
4714
|
+
: 'notifai hooks install --harness codex'
|
|
4715
|
+
checks.push({
|
|
4716
|
+
name: 'hooks (codex representation)',
|
|
4717
|
+
ok: false,
|
|
4718
|
+
detail: representationProblems.join('; '),
|
|
4719
|
+
...(representationProblems.some((problem) => problem.includes('to collapse onto'))
|
|
4720
|
+
? {
|
|
4721
|
+
remedy: {
|
|
4722
|
+
summary: 'collapse this Codex layer onto a single hook file',
|
|
4723
|
+
command: collapseCommand,
|
|
4724
|
+
},
|
|
4725
|
+
}
|
|
4726
|
+
: {}),
|
|
4727
|
+
})
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4455
4730
|
if (active !== null && activeInstallations.length > 0) {
|
|
4456
4731
|
const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
4457
4732
|
checks.push({
|
|
@@ -4651,14 +4926,19 @@ function codexStrayWorktreeCheck(
|
|
|
4651
4926
|
const layer = codexLayerDir(deps.cwd)
|
|
4652
4927
|
if (layer === null) return null
|
|
4653
4928
|
const root = codexProjectRoot(deps.cwd)
|
|
4654
|
-
const
|
|
4655
|
-
if (
|
|
4929
|
+
const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env))
|
|
4930
|
+
if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0) return null
|
|
4931
|
+
const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json')
|
|
4932
|
+
const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml')
|
|
4656
4933
|
const problems: string[] = []
|
|
4657
4934
|
if (!existsSync(layer)) {
|
|
4658
4935
|
problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
|
|
4659
4936
|
}
|
|
4660
|
-
if (existsSync(
|
|
4661
|
-
problems.push(`${
|
|
4937
|
+
if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
|
|
4938
|
+
problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`)
|
|
4939
|
+
}
|
|
4940
|
+
if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
|
|
4941
|
+
problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`)
|
|
4662
4942
|
}
|
|
4663
4943
|
return {
|
|
4664
4944
|
name: 'hooks (codex worktree)',
|
|
@@ -4723,6 +5003,17 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
4723
5003
|
const answer = await p.select({ message, options })
|
|
4724
5004
|
return p.isCancel(answer) ? null : (answer as string)
|
|
4725
5005
|
},
|
|
5006
|
+
multiselect: async (message, options, initial) => {
|
|
5007
|
+
if (!interactive()) return null
|
|
5008
|
+
const p = await clack()
|
|
5009
|
+
const answer = await p.multiselect({
|
|
5010
|
+
message,
|
|
5011
|
+
options,
|
|
5012
|
+
required: true,
|
|
5013
|
+
...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
|
|
5014
|
+
})
|
|
5015
|
+
return p.isCancel(answer) ? null : (answer as string[])
|
|
5016
|
+
},
|
|
4726
5017
|
intro: async (title) => {
|
|
4727
5018
|
if (!interactive()) return
|
|
4728
5019
|
;(await clack()).intro(title)
|