@raidiant/notifai 0.5.1 → 1.0.1
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 +173 -0
- package/README.md +11 -4
- package/dist/commands.d.ts +16 -1
- package/dist/commands.js +385 -158
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +2 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +19 -8
- package/dist/config.js +50 -7
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +1 -0
- package/dist/harnesses.js +6 -0
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/install-hooks.d.ts +60 -20
- package/dist/install-hooks.js +246 -100
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +163 -47
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +8 -8
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/package.json +3 -2
- package/src/commands.test.ts +411 -42
- package/src/commands.ts +468 -178
- 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 +180 -8
- package/src/install-hooks.ts +227 -47
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +8 -8
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
package/src/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,7 +46,7 @@ import {
|
|
|
46
46
|
type LogLevel,
|
|
47
47
|
} from './config.js'
|
|
48
48
|
import { acceptedValues, configInfo } from './config-schema.js'
|
|
49
|
-
import { skillsSource } from './release.js'
|
|
49
|
+
import { packageVersion, skillsSource } from './release.js'
|
|
50
50
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
51
51
|
import { withTargetFileLock } from './file-lock.js'
|
|
52
52
|
import {
|
|
@@ -57,8 +57,8 @@ import {
|
|
|
57
57
|
import type { CredentialStore, MachineCredential } from './credentials.js'
|
|
58
58
|
import {
|
|
59
59
|
firstBlocker,
|
|
60
|
-
openItems,
|
|
61
60
|
type Readiness,
|
|
61
|
+
type ReadinessRefresh,
|
|
62
62
|
type ReadinessState,
|
|
63
63
|
type StateStatus,
|
|
64
64
|
} from './readiness.js'
|
|
@@ -100,12 +100,18 @@ import {
|
|
|
100
100
|
applyPlan,
|
|
101
101
|
buildCursorHookConfig,
|
|
102
102
|
buildHookConfig,
|
|
103
|
+
collapseUnusedCodexRepresentation,
|
|
103
104
|
codexLayerDir,
|
|
105
|
+
codexLayerPaths,
|
|
106
|
+
codexRepresentationProblems,
|
|
104
107
|
codexTrustProblems,
|
|
105
108
|
codexProjectRoot,
|
|
106
109
|
detectHarness,
|
|
110
|
+
detectedHarnesses,
|
|
107
111
|
findInstallations,
|
|
108
112
|
handlerEvent,
|
|
113
|
+
hookDefinitionFiles,
|
|
114
|
+
inspectCodexLayer,
|
|
109
115
|
loadCursorSettings,
|
|
110
116
|
loadSettings,
|
|
111
117
|
mergeCursorHooks,
|
|
@@ -116,7 +122,7 @@ import {
|
|
|
116
122
|
type Harness,
|
|
117
123
|
type Installation,
|
|
118
124
|
} from './install-hooks.js'
|
|
119
|
-
import { HARNESS_CAPABILITIES } from './harnesses.js'
|
|
125
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
|
|
120
126
|
import {
|
|
121
127
|
claudeWakeRoute,
|
|
122
128
|
inspectClaudeInbox,
|
|
@@ -131,6 +137,8 @@ import {
|
|
|
131
137
|
import {
|
|
132
138
|
inspectHookAdapter,
|
|
133
139
|
installHookAdapter,
|
|
140
|
+
isNpxAdapterTarget,
|
|
141
|
+
type HookAdapterTarget,
|
|
134
142
|
} from './hook-adapter.js'
|
|
135
143
|
import {
|
|
136
144
|
isOurOpencodePlugin,
|
|
@@ -162,6 +170,12 @@ export interface CommandIo {
|
|
|
162
170
|
*/
|
|
163
171
|
interactive?: boolean
|
|
164
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>
|
|
165
179
|
intro?(title: string): Promise<void>
|
|
166
180
|
outro?(message: string): Promise<void>
|
|
167
181
|
note?(message: string, title?: string): Promise<void>
|
|
@@ -188,6 +202,8 @@ export interface CommandDeps {
|
|
|
188
202
|
cwd: string
|
|
189
203
|
/** Test seam; production fixes the hook adapter under os.homedir(). */
|
|
190
204
|
hookAdapterHome?: string
|
|
205
|
+
/** Test seam; production uses this process's Node and CLI paths. */
|
|
206
|
+
hookInstallTarget?: HookAdapterTarget
|
|
191
207
|
/** Test seam; production uses fetch against base_url. */
|
|
192
208
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
193
209
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
@@ -2125,20 +2141,97 @@ export interface HooksInstallFlags {
|
|
|
2125
2141
|
scriptPath?: string
|
|
2126
2142
|
}
|
|
2127
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
|
+
|
|
2128
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
|
+
}
|
|
2129
2205
|
const harness = resolveHarness(deps, flags.harness)
|
|
2130
2206
|
if (!harness) return EXIT.usage
|
|
2131
|
-
const
|
|
2132
|
-
const scriptPath =
|
|
2207
|
+
const adapterTarget = resolveHookAdapterTarget(deps, flags)
|
|
2208
|
+
const scriptPath =
|
|
2209
|
+
flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2133
2210
|
let adapterPath: string
|
|
2134
2211
|
try {
|
|
2135
|
-
adapterPath = installHookAdapter(
|
|
2212
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path
|
|
2136
2213
|
} catch (err) {
|
|
2137
2214
|
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
2138
2215
|
return EXIT.failed
|
|
2139
2216
|
}
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2217
|
+
const wantGlobal = flags.global === true
|
|
2218
|
+
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter(
|
|
2219
|
+
(installation) => installation.harness === harness,
|
|
2220
|
+
)
|
|
2221
|
+
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
2222
|
+
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
2223
|
+
const globalFile = otherScope.find((installation) => installation.global)?.file
|
|
2224
|
+
deps.io.out(
|
|
2225
|
+
`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
|
|
2226
|
+
)
|
|
2227
|
+
return EXIT.ok
|
|
2228
|
+
}
|
|
2229
|
+
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
2230
|
+
if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
|
|
2231
|
+
return EXIT.failed
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env)
|
|
2142
2235
|
|
|
2143
2236
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
2144
2237
|
// merged into a settings document, so it owns the whole file.
|
|
@@ -2150,9 +2243,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2150
2243
|
}
|
|
2151
2244
|
|
|
2152
2245
|
if (harness === 'cursor') {
|
|
2153
|
-
let merged: ReturnType<typeof mergeCursorHooks>
|
|
2154
2246
|
try {
|
|
2155
|
-
|
|
2247
|
+
withTargetFileLock(file, () => {
|
|
2156
2248
|
const document = loadCursorSettings(file)
|
|
2157
2249
|
const result = mergeCursorHooks(
|
|
2158
2250
|
document,
|
|
@@ -2169,26 +2261,16 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2169
2261
|
deps.io.err(String(err))
|
|
2170
2262
|
return EXIT.failed
|
|
2171
2263
|
}
|
|
2172
|
-
deps
|
|
2173
|
-
if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
|
|
2174
|
-
if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
|
|
2175
|
-
if (flags.global) {
|
|
2176
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2177
|
-
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
2178
|
-
} else {
|
|
2179
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2180
|
-
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
2181
|
-
}
|
|
2182
|
-
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
|
|
2183
|
-
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
|
|
2184
|
-
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
|
|
2264
|
+
printHooksInstallClose(deps, harness, file)
|
|
2185
2265
|
return EXIT.ok
|
|
2186
2266
|
}
|
|
2187
2267
|
|
|
2188
|
-
let
|
|
2268
|
+
let foreignStopCount = 0
|
|
2269
|
+
let collapsedFrom: string | null = null
|
|
2189
2270
|
try {
|
|
2190
|
-
|
|
2271
|
+
withTargetFileLock(file, () => {
|
|
2191
2272
|
const document = loadSettings(file)
|
|
2273
|
+
foreignStopCount = foreignStopHandlers(document).length
|
|
2192
2274
|
const result = mergeHooks(
|
|
2193
2275
|
document,
|
|
2194
2276
|
buildHookConfig({
|
|
@@ -2200,62 +2282,47 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2200
2282
|
applyPlan(file, result.document)
|
|
2201
2283
|
return result
|
|
2202
2284
|
})
|
|
2285
|
+
if (harness === 'codex') {
|
|
2286
|
+
collapsedFrom = collapseUnusedCodexRepresentation(
|
|
2287
|
+
file,
|
|
2288
|
+
scriptPath,
|
|
2289
|
+
codexLayerPaths(flags.global ?? false, deps.cwd, deps.env),
|
|
2290
|
+
)
|
|
2291
|
+
}
|
|
2203
2292
|
} catch (err) {
|
|
2204
2293
|
deps.io.err(String(err))
|
|
2205
2294
|
return EXIT.failed
|
|
2206
2295
|
}
|
|
2207
2296
|
|
|
2208
|
-
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
2209
|
-
if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
|
|
2210
|
-
if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
|
|
2211
|
-
if (merged.removed.length > 0) {
|
|
2212
|
-
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
|
|
2213
|
-
}
|
|
2214
|
-
deps.io.out('')
|
|
2215
|
-
deps.io.out(
|
|
2216
|
-
config.ask_grace_seconds.value === 0
|
|
2217
|
-
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
2218
|
-
: `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.`,
|
|
2219
|
-
)
|
|
2220
2297
|
if (harness === 'codex') {
|
|
2221
2298
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
2222
|
-
if (layer !== null) {
|
|
2223
|
-
// Codex reads project hooks from the main repository but only looks when
|
|
2224
|
-
// a `.codex` directory sits at or above cwd, so a worktree install has to
|
|
2225
|
-
// write one file and create one directory in two different places. Doing
|
|
2226
|
-
// it silently would leave the next person deriving this the hard way
|
|
2227
|
-
//.
|
|
2228
|
-
mkdirSync(layer, { recursive: true })
|
|
2229
|
-
deps.io.out('')
|
|
2230
|
-
deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,')
|
|
2231
|
-
deps.io.out(`so they were written to ${file}. ${layer} was created so this`)
|
|
2232
|
-
deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`')
|
|
2233
|
-
deps.io.out('directory; rerun this installer from that worktree to create it.')
|
|
2234
|
-
}
|
|
2299
|
+
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
2235
2300
|
}
|
|
2236
|
-
deps
|
|
2237
|
-
if (
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2301
|
+
printHooksInstallClose(deps, harness, file)
|
|
2302
|
+
if (collapsedFrom !== null) {
|
|
2303
|
+
deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`)
|
|
2304
|
+
}
|
|
2305
|
+
if (foreignStopCount > 0) {
|
|
2241
2306
|
deps.io.out(
|
|
2242
|
-
|
|
2307
|
+
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
2243
2308
|
)
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
} else if (harness === 'claude-code') {
|
|
2250
|
-
deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has')
|
|
2251
|
-
deps.io.out('not fired, start a new Claude Code session and try one prompt again.')
|
|
2252
|
-
} else {
|
|
2253
|
-
deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
2254
|
-
deps.io.out('start a new Codex session and try one prompt again.')
|
|
2309
|
+
}
|
|
2310
|
+
if (harness === 'codex') {
|
|
2311
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
|
|
2312
|
+
deps.io.out(problem)
|
|
2313
|
+
}
|
|
2255
2314
|
}
|
|
2256
2315
|
return EXIT.ok
|
|
2257
2316
|
}
|
|
2258
2317
|
|
|
2318
|
+
function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { command: string }[] }[]> }): { command: string }[] {
|
|
2319
|
+
const groups = document.hooks?.['Stop']
|
|
2320
|
+
if (!Array.isArray(groups)) return []
|
|
2321
|
+
return groups
|
|
2322
|
+
.flatMap((group) => group.hooks ?? [])
|
|
2323
|
+
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command))
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2259
2326
|
/**
|
|
2260
2327
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
2261
2328
|
* including one a different checkout wrote, matched on the managed marker for
|
|
@@ -2285,16 +2352,7 @@ function installOpencodePlugin(
|
|
|
2285
2352
|
deps.io.err(String(err))
|
|
2286
2353
|
return EXIT.failed
|
|
2287
2354
|
}
|
|
2288
|
-
deps
|
|
2289
|
-
deps.io.out('')
|
|
2290
|
-
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
2291
|
-
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
2292
|
-
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
|
|
2293
|
-
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
|
|
2294
|
-
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
|
|
2295
|
-
deps.io.out('the answer must return to the agent without another human prompt.')
|
|
2296
|
-
deps.io.out('')
|
|
2297
|
-
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
2355
|
+
printHooksInstallClose(deps, 'opencode', file)
|
|
2298
2356
|
return EXIT.ok
|
|
2299
2357
|
}
|
|
2300
2358
|
|
|
@@ -2350,28 +2408,33 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2350
2408
|
)
|
|
2351
2409
|
return EXIT.ok
|
|
2352
2410
|
}
|
|
2353
|
-
|
|
2411
|
+
const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
2412
|
+
const existing = files.filter((candidate) => existsSync(candidate))
|
|
2413
|
+
if (existing.length === 0) {
|
|
2414
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2415
|
+
return EXIT.ok
|
|
2416
|
+
}
|
|
2417
|
+
let removedAny = false
|
|
2354
2418
|
try {
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2419
|
+
for (const candidate of existing) {
|
|
2420
|
+
const stripped = withTargetFileLock(candidate, () => {
|
|
2421
|
+
const document = loadSettings(candidate)
|
|
2422
|
+
const result = removeHooks(document, scriptPath)
|
|
2423
|
+
applyPlan(candidate, result.document)
|
|
2424
|
+
return result
|
|
2425
|
+
})
|
|
2426
|
+
if (stripped.replaced.length > 0) {
|
|
2427
|
+
removedAny = true
|
|
2428
|
+
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2362
2431
|
} catch (err) {
|
|
2363
2432
|
deps.io.err(String(err))
|
|
2364
2433
|
return EXIT.failed
|
|
2365
2434
|
}
|
|
2366
|
-
if (
|
|
2367
|
-
deps.io.out(`
|
|
2368
|
-
return EXIT.ok
|
|
2435
|
+
if (!removedAny) {
|
|
2436
|
+
deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`)
|
|
2369
2437
|
}
|
|
2370
|
-
deps.io.out(
|
|
2371
|
-
stripped.replaced.length > 0
|
|
2372
|
-
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
2373
|
-
: `No Notifai hooks found in ${file}`,
|
|
2374
|
-
)
|
|
2375
2438
|
return EXIT.ok
|
|
2376
2439
|
}
|
|
2377
2440
|
|
|
@@ -2386,6 +2449,13 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2386
2449
|
}
|
|
2387
2450
|
}
|
|
2388
2451
|
|
|
2452
|
+
const HARNESS_LABEL: Record<Harness, string> = {
|
|
2453
|
+
'claude-code': 'Claude Code',
|
|
2454
|
+
codex: 'Codex',
|
|
2455
|
+
cursor: 'Cursor',
|
|
2456
|
+
opencode: 'OpenCode',
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2389
2459
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
2390
2460
|
if (requested !== undefined) {
|
|
2391
2461
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -2394,7 +2464,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2394
2464
|
)
|
|
2395
2465
|
return null
|
|
2396
2466
|
}
|
|
2397
|
-
const detected = detectHarness(deps.cwd)
|
|
2467
|
+
const detected = detectHarness(deps.cwd, deps.env)
|
|
2398
2468
|
if (!detected) {
|
|
2399
2469
|
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
|
|
2400
2470
|
return null
|
|
@@ -2402,6 +2472,48 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2402
2472
|
return detected
|
|
2403
2473
|
}
|
|
2404
2474
|
|
|
2475
|
+
/**
|
|
2476
|
+
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
2477
|
+
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
2478
|
+
* or names more than one.
|
|
2479
|
+
*/
|
|
2480
|
+
async function pickHarnessesToInstall(
|
|
2481
|
+
deps: CommandDeps,
|
|
2482
|
+
requested?: string,
|
|
2483
|
+
): Promise<Harness[] | null> {
|
|
2484
|
+
if (requested !== undefined) {
|
|
2485
|
+
const harness = resolveHarness(deps, requested)
|
|
2486
|
+
return harness === null ? null : [harness]
|
|
2487
|
+
}
|
|
2488
|
+
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
2489
|
+
if (detected.length === 1) return detected
|
|
2490
|
+
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2491
|
+
const picked = await deps.io.multiselect(
|
|
2492
|
+
'Which agent harnesses should Notifai wire here?',
|
|
2493
|
+
HARNESSES.map((name) => ({
|
|
2494
|
+
value: name,
|
|
2495
|
+
label: HARNESS_LABEL[name],
|
|
2496
|
+
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2497
|
+
})),
|
|
2498
|
+
detected,
|
|
2499
|
+
)
|
|
2500
|
+
if (picked === null) return null
|
|
2501
|
+
const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
|
|
2502
|
+
if (unknown.length > 0) {
|
|
2503
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
|
|
2504
|
+
return null
|
|
2505
|
+
}
|
|
2506
|
+
return picked as Harness[]
|
|
2507
|
+
}
|
|
2508
|
+
if (detected.length === 0) {
|
|
2509
|
+
deps.io.err(
|
|
2510
|
+
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
2511
|
+
)
|
|
2512
|
+
return null
|
|
2513
|
+
}
|
|
2514
|
+
return detected
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2405
2517
|
// ---------------------------------------------------------------------------
|
|
2406
2518
|
// config show / set / unset
|
|
2407
2519
|
// ---------------------------------------------------------------------------
|
|
@@ -2413,7 +2525,14 @@ export function configShowCommand(
|
|
|
2413
2525
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2414
2526
|
if (flags.json) {
|
|
2415
2527
|
const output = Object.fromEntries(
|
|
2416
|
-
CONFIG_KEYS.map((key) => [
|
|
2528
|
+
CONFIG_KEYS.map((key) => [
|
|
2529
|
+
key,
|
|
2530
|
+
{
|
|
2531
|
+
value: config[key].value,
|
|
2532
|
+
source: config[key].source,
|
|
2533
|
+
summary: configInfo(key).summary,
|
|
2534
|
+
},
|
|
2535
|
+
]),
|
|
2417
2536
|
)
|
|
2418
2537
|
deps.io.out(JSON.stringify(output, null, 2))
|
|
2419
2538
|
return EXIT.ok
|
|
@@ -2622,7 +2741,7 @@ async function configMutationTarget(
|
|
|
2622
2741
|
const selected = await deps.io.select('Where should this setting live?', [
|
|
2623
2742
|
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
2624
2743
|
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
2625
|
-
{ value: 'local', label: 'This project (personal)', hint: '
|
|
2744
|
+
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
2626
2745
|
])
|
|
2627
2746
|
if (selected === null) {
|
|
2628
2747
|
deps.io.err('No configuration layer selected.')
|
|
@@ -2634,7 +2753,7 @@ async function configMutationTarget(
|
|
|
2634
2753
|
const targetPath = flags.session
|
|
2635
2754
|
? sessionConfigPath(flags.session, deps.env)
|
|
2636
2755
|
: layer === 'local'
|
|
2637
|
-
? (
|
|
2756
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
2638
2757
|
: layer === 'project'
|
|
2639
2758
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
2640
2759
|
: globalConfigPath(deps.env)
|
|
@@ -3127,8 +3246,8 @@ function observedCompanionReceipt(
|
|
|
3127
3246
|
* gap, so re-running is how you check the setup as much as how you create it.
|
|
3128
3247
|
* With a human at a terminal it walks them through the missing pieces; run by
|
|
3129
3248
|
* an agent it never prompts — each optional step is answered by a flag, and
|
|
3130
|
-
* whatever only the user can do (signing in, pairing a companion device) is
|
|
3131
|
-
* the
|
|
3249
|
+
* whatever only the user can do (signing in, pairing a companion device) is
|
|
3250
|
+
* printed as the next human action. An agent runs every CLI command itself.
|
|
3132
3251
|
*/
|
|
3133
3252
|
/**
|
|
3134
3253
|
* Close a gap the CLI is allowed to close on its own, without asking.
|
|
@@ -3159,21 +3278,13 @@ async function closeGap(
|
|
|
3159
3278
|
}
|
|
3160
3279
|
|
|
3161
3280
|
if (state.id === 'hooks') {
|
|
3162
|
-
|
|
3163
|
-
if (
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
)
|
|
3168
|
-
if (picked !== null) harness = picked as Harness
|
|
3169
|
-
}
|
|
3170
|
-
if (harness === null) {
|
|
3171
|
-
deps.io.err(
|
|
3172
|
-
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
3173
|
-
)
|
|
3174
|
-
return 'failed'
|
|
3281
|
+
const harnesses = await pickHarnessesToInstall(deps)
|
|
3282
|
+
if (harnesses === null || harnesses.length === 0) return 'failed'
|
|
3283
|
+
let ok = true
|
|
3284
|
+
for (const harness of harnesses) {
|
|
3285
|
+
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
|
|
3175
3286
|
}
|
|
3176
|
-
return
|
|
3287
|
+
return ok ? 'closed' : 'failed'
|
|
3177
3288
|
}
|
|
3178
3289
|
|
|
3179
3290
|
if (state.id === 'skill') {
|
|
@@ -3517,6 +3628,13 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
3517
3628
|
return 'pending'
|
|
3518
3629
|
}
|
|
3519
3630
|
|
|
3631
|
+
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
3632
|
+
function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
|
|
3633
|
+
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
3634
|
+
? ['local']
|
|
3635
|
+
: undefined
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3520
3638
|
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
3521
3639
|
function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
|
|
3522
3640
|
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
@@ -3576,12 +3694,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3576
3694
|
}
|
|
3577
3695
|
await deps.io.intro?.('Notifai setup')
|
|
3578
3696
|
|
|
3579
|
-
const
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3697
|
+
const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
|
|
3698
|
+
let readiness = await assessReadiness(deps, skillOpts)
|
|
3699
|
+
const reassess = (refresh?: readonly ReadinessRefresh[]) =>
|
|
3700
|
+
assessReadiness(deps, {
|
|
3701
|
+
...skillOpts,
|
|
3702
|
+
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
3703
|
+
})
|
|
3585
3704
|
let failed = false
|
|
3586
3705
|
const attempted = new Set<string>()
|
|
3587
3706
|
|
|
@@ -3612,7 +3731,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3612
3731
|
const result = await closeGap(deps, state, flags)
|
|
3613
3732
|
if (result === 'failed') failed = true
|
|
3614
3733
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
3615
|
-
readiness = await reassess()
|
|
3734
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3616
3735
|
advanced = true
|
|
3617
3736
|
break
|
|
3618
3737
|
}
|
|
@@ -3620,7 +3739,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3620
3739
|
stop = true
|
|
3621
3740
|
break
|
|
3622
3741
|
}
|
|
3623
|
-
readiness = await reassess()
|
|
3742
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3624
3743
|
advanced = true
|
|
3625
3744
|
break
|
|
3626
3745
|
}
|
|
@@ -3633,7 +3752,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3633
3752
|
stop = true
|
|
3634
3753
|
break
|
|
3635
3754
|
}
|
|
3636
|
-
readiness = await reassess()
|
|
3755
|
+
readiness = await reassess(refreshAfterClose(state.id))
|
|
3637
3756
|
advanced = true
|
|
3638
3757
|
break
|
|
3639
3758
|
}
|
|
@@ -3682,37 +3801,81 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
3682
3801
|
if (stop || !advanced) break
|
|
3683
3802
|
}
|
|
3684
3803
|
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3804
|
+
await printInitClose(deps, readiness, flags)
|
|
3805
|
+
const blocker = firstBlocker(readiness)
|
|
3806
|
+
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
3807
|
+
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
function isHookSubstate(id: string): boolean {
|
|
3811
|
+
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
|
|
3815
|
+
return readiness.states.filter((state) => {
|
|
3816
|
+
if (state.status !== 'optional-gap') return false
|
|
3817
|
+
if (isHookSubstate(state.id)) return false
|
|
3818
|
+
if (state.id === 'skill' && flags.skills === false) return false
|
|
3819
|
+
return true
|
|
3820
|
+
})
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
|
|
3824
|
+
for (const state of leftovers) {
|
|
3825
|
+
deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
3688
3826
|
}
|
|
3827
|
+
}
|
|
3689
3828
|
|
|
3829
|
+
function questionsWillRoute(readiness: Readiness): boolean {
|
|
3830
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
3831
|
+
const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
|
|
3832
|
+
return hooks?.status === 'ready' && settings?.status !== 'gap'
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
async function printInitClose(
|
|
3836
|
+
deps: CommandDeps,
|
|
3837
|
+
readiness: Readiness,
|
|
3838
|
+
flags: InitFlags,
|
|
3839
|
+
): Promise<void> {
|
|
3690
3840
|
const blocker = firstBlocker(readiness)
|
|
3841
|
+
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
3842
|
+
const questions = questionsWillRoute(readiness)
|
|
3843
|
+
const leftovers = leftoverOptionals(readiness, flags)
|
|
3844
|
+
|
|
3845
|
+
if (deps.io.interactive === true) {
|
|
3846
|
+
if (blocker === null) {
|
|
3847
|
+
const lines = [
|
|
3848
|
+
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
3849
|
+
questions
|
|
3850
|
+
? 'Questions will reach your devices.'
|
|
3851
|
+
: 'Questions stay in the terminal until hooks are installed.',
|
|
3852
|
+
]
|
|
3853
|
+
await deps.io.note?.(lines.join('\n'), 'Ready')
|
|
3854
|
+
printOptionalLeftovers(deps, leftovers)
|
|
3855
|
+
deps.io.out('All set. Agents in this project can notify you and ask questions.')
|
|
3856
|
+
await deps.io.outro?.('All set ✨')
|
|
3857
|
+
return
|
|
3858
|
+
}
|
|
3859
|
+
await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
|
|
3860
|
+
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
3861
|
+
deps.io.out(` ${remedyLine(blocker)}`)
|
|
3862
|
+
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3863
|
+
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
3864
|
+
}
|
|
3865
|
+
await deps.io.outro?.('One step remains (above)')
|
|
3866
|
+
return
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3691
3869
|
if (blocker === null) {
|
|
3692
|
-
|
|
3693
|
-
(state) => !(state.id === 'skill' && flags.skills === false),
|
|
3694
|
-
)
|
|
3695
|
-
for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
3870
|
+
printOptionalLeftovers(deps, leftovers)
|
|
3696
3871
|
deps.io.out('All set. Agents in this project can notify you and ask questions.')
|
|
3697
|
-
|
|
3698
|
-
return failed ? EXIT.failed : EXIT.ok
|
|
3872
|
+
return
|
|
3699
3873
|
}
|
|
3700
|
-
|
|
3701
|
-
// Exactly one. Everything else waits until this is done, because the next
|
|
3702
|
-
// gap is frequently a consequence of this one and naming it now would send
|
|
3703
|
-
// the reader off to fix something that is not actually wrong.
|
|
3704
|
-
deps.io.out('')
|
|
3705
3874
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
3706
3875
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
3707
|
-
// Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
|
|
3708
|
-
// how the rest is recovered either way.
|
|
3709
3876
|
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3710
3877
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
3711
3878
|
}
|
|
3712
|
-
await deps.io.outro?.('One step remains (above)')
|
|
3713
|
-
// An agent must be able to branch on setup being blocked without parsing
|
|
3714
|
-
// prose. A present human may deliberately leave and resume later.
|
|
3715
|
-
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
3716
3879
|
}
|
|
3717
3880
|
|
|
3718
3881
|
// ---------------------------------------------------------------------------
|
|
@@ -3758,6 +3921,66 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
3758
3921
|
}
|
|
3759
3922
|
}
|
|
3760
3923
|
|
|
3924
|
+
function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
|
|
3925
|
+
const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
|
|
3926
|
+
const projectSlug = config.project.value
|
|
3927
|
+
return projectSlug !== null
|
|
3928
|
+
? {
|
|
3929
|
+
id: 'project',
|
|
3930
|
+
title: 'Project identity',
|
|
3931
|
+
status: 'ready',
|
|
3932
|
+
detail: `"${projectSlug}" (${config.project.source})`,
|
|
3933
|
+
}
|
|
3934
|
+
: {
|
|
3935
|
+
id: 'project',
|
|
3936
|
+
title: 'Project identity',
|
|
3937
|
+
// Not a blocker: a send without a project simply carries no project
|
|
3938
|
+
// identity. init always sets one because it is free and reversible,
|
|
3939
|
+
// but an unlabelled setup works, so this must not go red.
|
|
3940
|
+
status: 'optional-gap',
|
|
3941
|
+
detail: `not set in ${configPath} — sends from here carry no project identity`,
|
|
3942
|
+
remedy: {
|
|
3943
|
+
by: 'cli',
|
|
3944
|
+
summary: 'name this project after its directory',
|
|
3945
|
+
command: 'notifai init',
|
|
3946
|
+
},
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3950
|
+
function remoteStatesFrom(previous: Readiness): {
|
|
3951
|
+
credential: ReadinessState
|
|
3952
|
+
server: ReadinessState
|
|
3953
|
+
contract: ReadinessState
|
|
3954
|
+
auth: ReadinessState
|
|
3955
|
+
devices: ReadinessState
|
|
3956
|
+
proof: ReadinessState
|
|
3957
|
+
} | null {
|
|
3958
|
+
const pick = (id: string): ReadinessState | undefined => previous.states.find((state) => state.id === id)
|
|
3959
|
+
const credential = pick('credential')
|
|
3960
|
+
const server = pick('server')
|
|
3961
|
+
const contract = pick('contract')
|
|
3962
|
+
const auth = pick('auth')
|
|
3963
|
+
const devices = pick('devices')
|
|
3964
|
+
const proof = pick('proof')
|
|
3965
|
+
if (
|
|
3966
|
+
credential === undefined ||
|
|
3967
|
+
server === undefined ||
|
|
3968
|
+
contract === undefined ||
|
|
3969
|
+
auth === undefined ||
|
|
3970
|
+
devices === undefined ||
|
|
3971
|
+
proof === undefined
|
|
3972
|
+
) {
|
|
3973
|
+
return null
|
|
3974
|
+
}
|
|
3975
|
+
return { credential, server, contract, auth, devices, proof }
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
|
|
3979
|
+
if (config.base_url.source === 'default') return false
|
|
3980
|
+
const server = previous.states.find((state) => state.id === 'server')
|
|
3981
|
+
return server === undefined || !server.detail.includes(config.base_url.value)
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3761
3984
|
/**
|
|
3762
3985
|
* Read the whole setup once, in dependency order.
|
|
3763
3986
|
*
|
|
@@ -3769,38 +3992,46 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
3769
3992
|
*/
|
|
3770
3993
|
export async function assessReadiness(
|
|
3771
3994
|
deps: CommandDeps,
|
|
3772
|
-
options: {
|
|
3995
|
+
options: {
|
|
3996
|
+
skillScope?: SkillScope
|
|
3997
|
+
previous?: Readiness
|
|
3998
|
+
refresh?: readonly ReadinessRefresh[]
|
|
3999
|
+
} = {},
|
|
3773
4000
|
): Promise<Readiness> {
|
|
3774
4001
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
4002
|
+
const previous = options.previous
|
|
4003
|
+
const refresh = options.refresh
|
|
4004
|
+
if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
|
|
4005
|
+
|
|
4006
|
+
const reuseRemote =
|
|
4007
|
+
previous !== undefined &&
|
|
4008
|
+
refresh !== undefined &&
|
|
4009
|
+
!refresh.includes('remote') &&
|
|
4010
|
+
!remoteInvalidatedByConfig(previous, config)
|
|
4011
|
+
if (reuseRemote) {
|
|
4012
|
+
const reused = remoteStatesFrom(previous)
|
|
4013
|
+
if (reused !== null) {
|
|
4014
|
+
return {
|
|
4015
|
+
states: [
|
|
4016
|
+
projectReadiness(deps, config),
|
|
4017
|
+
reused.credential,
|
|
4018
|
+
reused.server,
|
|
4019
|
+
reused.contract,
|
|
4020
|
+
reused.auth,
|
|
4021
|
+
...hookStates(deps),
|
|
4022
|
+
await skillReadiness(deps, options.skillScope),
|
|
4023
|
+
reused.devices,
|
|
4024
|
+
reused.proof,
|
|
4025
|
+
],
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
|
|
3775
4030
|
const states: ReadinessState[] = []
|
|
3776
4031
|
let accountClient: ApiClient | null = null
|
|
3777
4032
|
let accountDevices: RoutableDevice[] | null = null
|
|
3778
4033
|
|
|
3779
|
-
|
|
3780
|
-
const projectSlug = config.project.value
|
|
3781
|
-
states.push(
|
|
3782
|
-
projectSlug !== null
|
|
3783
|
-
? {
|
|
3784
|
-
id: 'project',
|
|
3785
|
-
title: 'Project identity',
|
|
3786
|
-
status: 'ready',
|
|
3787
|
-
detail: `"${projectSlug}" (${config.project.source})`,
|
|
3788
|
-
}
|
|
3789
|
-
: {
|
|
3790
|
-
id: 'project',
|
|
3791
|
-
title: 'Project identity',
|
|
3792
|
-
// Not a blocker: a send without a project simply carries no project
|
|
3793
|
-
// identity. init always sets one because it is free and reversible,
|
|
3794
|
-
// but an unlabelled setup works, so this must not go red.
|
|
3795
|
-
status: 'optional-gap',
|
|
3796
|
-
detail: `not set in ${configPath} — sends from here carry no project identity`,
|
|
3797
|
-
remedy: {
|
|
3798
|
-
by: 'cli',
|
|
3799
|
-
summary: 'name this project after its directory',
|
|
3800
|
-
command: 'notifai init',
|
|
3801
|
-
},
|
|
3802
|
-
},
|
|
3803
|
-
)
|
|
4034
|
+
states.push(projectReadiness(deps, config))
|
|
3804
4035
|
|
|
3805
4036
|
const credential = deps.store.load()
|
|
3806
4037
|
states.push(
|
|
@@ -3843,10 +4074,10 @@ export async function assessReadiness(
|
|
|
3843
4074
|
id: 'server',
|
|
3844
4075
|
title: 'Service',
|
|
3845
4076
|
status: 'gap',
|
|
3846
|
-
detail: `cannot reach ${baseUrl}
|
|
4077
|
+
detail: `cannot reach ${baseUrl}`,
|
|
3847
4078
|
remedy: {
|
|
3848
4079
|
by: 'user-here',
|
|
3849
|
-
summary: 'check your network
|
|
4080
|
+
summary: 'check your network',
|
|
3850
4081
|
command: 'notifai doctor',
|
|
3851
4082
|
},
|
|
3852
4083
|
},
|
|
@@ -4072,8 +4303,12 @@ async function setupProofState(
|
|
|
4072
4303
|
}
|
|
4073
4304
|
}
|
|
4074
4305
|
|
|
4075
|
-
export async function doctorCommand(
|
|
4076
|
-
|
|
4306
|
+
export async function doctorCommand(
|
|
4307
|
+
deps: CommandDeps,
|
|
4308
|
+
flags: { json?: boolean },
|
|
4309
|
+
options: { readiness?: Readiness } = {},
|
|
4310
|
+
): Promise<number> {
|
|
4311
|
+
const readiness = options.readiness ?? (await assessReadiness(deps))
|
|
4077
4312
|
const blocker = firstBlocker(readiness)
|
|
4078
4313
|
const ok = blocker === null
|
|
4079
4314
|
|
|
@@ -4317,6 +4552,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4317
4552
|
.join(', '),
|
|
4318
4553
|
})
|
|
4319
4554
|
|
|
4555
|
+
const wired = new Set(installations.map((installation) => installation.harness))
|
|
4556
|
+
const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness))
|
|
4557
|
+
if (unwired.length > 0) {
|
|
4558
|
+
checks.push({
|
|
4559
|
+
name: 'hooks (detected)',
|
|
4560
|
+
ok: false,
|
|
4561
|
+
informational: true,
|
|
4562
|
+
detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
|
|
4563
|
+
remedy: {
|
|
4564
|
+
summary: 'install hooks for every detected harness',
|
|
4565
|
+
command: 'notifai hooks install',
|
|
4566
|
+
},
|
|
4567
|
+
})
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4320
4570
|
const { active, contested } = resolveActiveHarness(
|
|
4321
4571
|
deps.env,
|
|
4322
4572
|
deps.cwd,
|
|
@@ -4456,7 +4706,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4456
4706
|
harness,
|
|
4457
4707
|
installations: installations.filter((i) => i.harness === harness),
|
|
4458
4708
|
}))
|
|
4459
|
-
.filter(
|
|
4709
|
+
.filter(
|
|
4710
|
+
(entry) =>
|
|
4711
|
+
entry.installations.some((installation) => installation.global) &&
|
|
4712
|
+
entry.installations.some((installation) => !installation.global),
|
|
4713
|
+
)
|
|
4460
4714
|
if (duplicated.length > 0) {
|
|
4461
4715
|
checks.push({
|
|
4462
4716
|
name: 'hooks (duplicates)',
|
|
@@ -4470,6 +4724,26 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4470
4724
|
})
|
|
4471
4725
|
}
|
|
4472
4726
|
|
|
4727
|
+
const representationProblems = codexRepresentationProblems(deps.cwd, deps.env)
|
|
4728
|
+
if (representationProblems.length > 0) {
|
|
4729
|
+
const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
|
|
4730
|
+
? 'notifai hooks install --harness codex --global'
|
|
4731
|
+
: 'notifai hooks install --harness codex'
|
|
4732
|
+
checks.push({
|
|
4733
|
+
name: 'hooks (codex representation)',
|
|
4734
|
+
ok: false,
|
|
4735
|
+
detail: representationProblems.join('; '),
|
|
4736
|
+
...(representationProblems.some((problem) => problem.includes('to collapse onto'))
|
|
4737
|
+
? {
|
|
4738
|
+
remedy: {
|
|
4739
|
+
summary: 'collapse this Codex layer onto a single hook file',
|
|
4740
|
+
command: collapseCommand,
|
|
4741
|
+
},
|
|
4742
|
+
}
|
|
4743
|
+
: {}),
|
|
4744
|
+
})
|
|
4745
|
+
}
|
|
4746
|
+
|
|
4473
4747
|
if (active !== null && activeInstallations.length > 0) {
|
|
4474
4748
|
const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
4475
4749
|
checks.push({
|
|
@@ -4669,14 +4943,19 @@ function codexStrayWorktreeCheck(
|
|
|
4669
4943
|
const layer = codexLayerDir(deps.cwd)
|
|
4670
4944
|
if (layer === null) return null
|
|
4671
4945
|
const root = codexProjectRoot(deps.cwd)
|
|
4672
|
-
const
|
|
4673
|
-
if (
|
|
4946
|
+
const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env))
|
|
4947
|
+
if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0) return null
|
|
4948
|
+
const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json')
|
|
4949
|
+
const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml')
|
|
4674
4950
|
const problems: string[] = []
|
|
4675
4951
|
if (!existsSync(layer)) {
|
|
4676
4952
|
problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
|
|
4677
4953
|
}
|
|
4678
|
-
if (existsSync(
|
|
4679
|
-
problems.push(`${
|
|
4954
|
+
if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
|
|
4955
|
+
problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`)
|
|
4956
|
+
}
|
|
4957
|
+
if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
|
|
4958
|
+
problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`)
|
|
4680
4959
|
}
|
|
4681
4960
|
return {
|
|
4682
4961
|
name: 'hooks (codex worktree)',
|
|
@@ -4741,6 +5020,17 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
4741
5020
|
const answer = await p.select({ message, options })
|
|
4742
5021
|
return p.isCancel(answer) ? null : (answer as string)
|
|
4743
5022
|
},
|
|
5023
|
+
multiselect: async (message, options, initial) => {
|
|
5024
|
+
if (!interactive()) return null
|
|
5025
|
+
const p = await clack()
|
|
5026
|
+
const answer = await p.multiselect({
|
|
5027
|
+
message,
|
|
5028
|
+
options,
|
|
5029
|
+
required: true,
|
|
5030
|
+
...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
|
|
5031
|
+
})
|
|
5032
|
+
return p.isCancel(answer) ? null : (answer as string[])
|
|
5033
|
+
},
|
|
4744
5034
|
intro: async (title) => {
|
|
4745
5035
|
if (!interactive()) return
|
|
4746
5036
|
;(await clack()).intro(title)
|