@raidiant/notifai 7.0.2 → 9.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 +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands-doctor.ts
CHANGED
|
@@ -40,15 +40,17 @@ import {
|
|
|
40
40
|
} from './readiness.js'
|
|
41
41
|
import { packageVersion } from './release.js'
|
|
42
42
|
import type { Tone } from './ui/theme.js'
|
|
43
|
+
import type { MachineCredential } from './credentials.js'
|
|
43
44
|
import {
|
|
44
45
|
EXIT,
|
|
45
46
|
UPDATE_CLI_COMMAND,
|
|
47
|
+
diagnoseIgnoredOriginOverride,
|
|
46
48
|
loadLoggedConfig,
|
|
47
49
|
makeClient,
|
|
48
50
|
resolvedBaseUrl,
|
|
49
51
|
type CommandDeps,
|
|
50
52
|
} from './commands-core.js'
|
|
51
|
-
import { deviceInstallRemedy,
|
|
53
|
+
import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
|
|
52
54
|
import {
|
|
53
55
|
activeHarnessSession,
|
|
54
56
|
claudeSessionPid,
|
|
@@ -56,8 +58,25 @@ import {
|
|
|
56
58
|
type ActiveHarnessSession,
|
|
57
59
|
} from './commands-harness-context.js'
|
|
58
60
|
import { stopShapeProblems } from './commands-hook-shape.js'
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
+
import {
|
|
62
|
+
HOOK_EVENTS,
|
|
63
|
+
activeQuestionRouteProblems,
|
|
64
|
+
hookActivationAdvice,
|
|
65
|
+
requiredHookEvents,
|
|
66
|
+
} from './commands-hooks.js'
|
|
67
|
+
import { cliBinReadiness } from './cli-bin.js'
|
|
68
|
+
import {
|
|
69
|
+
latestPublishedCliVersion,
|
|
70
|
+
newerPublishedCli,
|
|
71
|
+
shouldConsultCliRegistry,
|
|
72
|
+
thisCliVersion,
|
|
73
|
+
} from './cli-release.js'
|
|
74
|
+
import {
|
|
75
|
+
SETUP_PROOF_STALE_MS,
|
|
76
|
+
observedCompanionReceipt,
|
|
77
|
+
readSetupProof,
|
|
78
|
+
setupProofIsStale,
|
|
79
|
+
} from './commands-setup-proof.js'
|
|
61
80
|
import { skillReadiness } from './commands-skill.js'
|
|
62
81
|
|
|
63
82
|
// ---------------------------------------------------------------------------
|
|
@@ -236,10 +255,14 @@ function remoteStatesFrom(previous: Readiness): {
|
|
|
236
255
|
return { credential, server, contract, auth, devices, proof }
|
|
237
256
|
}
|
|
238
257
|
|
|
239
|
-
function remoteInvalidatedByConfig(
|
|
240
|
-
|
|
258
|
+
function remoteInvalidatedByConfig(
|
|
259
|
+
previous: Readiness,
|
|
260
|
+
config: CliConfig,
|
|
261
|
+
credential: MachineCredential | null,
|
|
262
|
+
): boolean {
|
|
263
|
+
const origin = resolvedBaseUrl(config, credential)
|
|
241
264
|
const server = previous.states.find((state) => state.id === 'server')
|
|
242
|
-
return server === undefined || !server.detail.includes(
|
|
265
|
+
return server === undefined || !server.detail.includes(origin)
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
/**
|
|
@@ -257,9 +280,12 @@ export async function assessReadiness(
|
|
|
257
280
|
skillScope?: SkillScope
|
|
258
281
|
previous?: Readiness
|
|
259
282
|
refresh?: readonly ReadinessRefresh[]
|
|
283
|
+
json?: boolean
|
|
260
284
|
} = {},
|
|
261
285
|
): Promise<Readiness> {
|
|
262
286
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
287
|
+
const credential = deps.store.load()
|
|
288
|
+
if (credential) diagnoseIgnoredOriginOverride(deps.io, config, credential)
|
|
263
289
|
const previous = options.previous
|
|
264
290
|
const refresh = options.refresh
|
|
265
291
|
if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
|
|
@@ -268,12 +294,13 @@ export async function assessReadiness(
|
|
|
268
294
|
previous !== undefined &&
|
|
269
295
|
refresh !== undefined &&
|
|
270
296
|
!refresh.includes('remote') &&
|
|
271
|
-
!remoteInvalidatedByConfig(previous, config)
|
|
297
|
+
!remoteInvalidatedByConfig(previous, config, credential)
|
|
272
298
|
if (reuseRemote) {
|
|
273
299
|
const reused = remoteStatesFrom(previous)
|
|
274
300
|
if (reused !== null) {
|
|
275
301
|
return {
|
|
276
302
|
states: [
|
|
303
|
+
cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform),
|
|
277
304
|
projectReadiness(deps, config),
|
|
278
305
|
reused.credential,
|
|
279
306
|
reused.server,
|
|
@@ -292,9 +319,9 @@ export async function assessReadiness(
|
|
|
292
319
|
let accountClient: ApiClient | null = null
|
|
293
320
|
let accountDevices: RoutableDevice[] | null = null
|
|
294
321
|
|
|
322
|
+
states.push(cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform))
|
|
295
323
|
states.push(projectReadiness(deps, config))
|
|
296
324
|
|
|
297
|
-
const credential = deps.store.load()
|
|
298
325
|
states.push(
|
|
299
326
|
credential
|
|
300
327
|
? {
|
|
@@ -418,15 +445,17 @@ export async function assessReadiness(
|
|
|
418
445
|
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
|
|
419
446
|
} else {
|
|
420
447
|
const devices = accountDevices
|
|
421
|
-
const
|
|
422
|
-
|
|
448
|
+
const companionDevices = devices.filter(
|
|
449
|
+
(device) => device.platform === 'ios' || device.platform === 'android',
|
|
450
|
+
)
|
|
451
|
+
const ready = readyCompanionDevices(companionDevices)
|
|
423
452
|
states.push(
|
|
424
453
|
ready.length > 0
|
|
425
454
|
? {
|
|
426
455
|
id: 'devices',
|
|
427
456
|
title: 'Your devices',
|
|
428
457
|
status: 'ready',
|
|
429
|
-
detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
|
|
458
|
+
detail: `${ready.map((d) => `${d.display_name} (${d.platform})`).join(', ')} ready to receive`,
|
|
430
459
|
}
|
|
431
460
|
: {
|
|
432
461
|
id: 'devices',
|
|
@@ -439,15 +468,15 @@ export async function assessReadiness(
|
|
|
439
468
|
// permission prompt. The live bridge is /support on the
|
|
440
469
|
// dashboard origin — not a placeholder, and not typed by hand.
|
|
441
470
|
detail:
|
|
442
|
-
|
|
443
|
-
? `no
|
|
444
|
-
: `${
|
|
471
|
+
companionDevices.length === 0
|
|
472
|
+
? `no active Companion device registered yet; install Notifai via ${supportPageUrl(baseUrl)}`
|
|
473
|
+
: `${companionDevices.map((d) => `${d.display_name} (${d.platform}, ${d.permission_status})`).join(', ')} — registered but not able to receive`,
|
|
445
474
|
remedy: {
|
|
446
475
|
by: 'user-elsewhere',
|
|
447
476
|
summary: deviceInstallRemedy({
|
|
448
477
|
baseUrl,
|
|
449
478
|
email: accountEmail,
|
|
450
|
-
devices:
|
|
479
|
+
devices: companionDevices,
|
|
451
480
|
}),
|
|
452
481
|
},
|
|
453
482
|
},
|
|
@@ -455,10 +484,39 @@ export async function assessReadiness(
|
|
|
455
484
|
}
|
|
456
485
|
|
|
457
486
|
states.push(await setupProofState(deps, config, accountClient, accountDevices))
|
|
487
|
+
await applyRegistryRecommendation(deps, options.json === true, states)
|
|
458
488
|
|
|
459
489
|
return { states }
|
|
460
490
|
}
|
|
461
491
|
|
|
492
|
+
async function applyRegistryRecommendation(
|
|
493
|
+
deps: CommandDeps,
|
|
494
|
+
json: boolean,
|
|
495
|
+
states: ReadinessState[],
|
|
496
|
+
): Promise<void> {
|
|
497
|
+
if (
|
|
498
|
+
!shouldConsultCliRegistry({
|
|
499
|
+
...(deps.io.interactive === undefined ? {} : { interactive: deps.io.interactive }),
|
|
500
|
+
json,
|
|
501
|
+
env: deps.env,
|
|
502
|
+
})
|
|
503
|
+
) {
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const latest = await latestPublishedCliVersion(deps.fetchImpl)
|
|
507
|
+
const newer = newerPublishedCli(thisCliVersion(), latest)
|
|
508
|
+
if (newer === null) return
|
|
509
|
+
const contract = states.find((state) => state.id === 'contract')
|
|
510
|
+
if (contract === undefined || contract.status === 'gap') return
|
|
511
|
+
contract.status = 'optional-gap'
|
|
512
|
+
contract.detail = 'A newer Notifai is available.'
|
|
513
|
+
contract.remedy = {
|
|
514
|
+
by: 'user-here',
|
|
515
|
+
summary: 'update Notifai',
|
|
516
|
+
command: UPDATE_CLI_COMMAND,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
462
520
|
async function setupProofState(
|
|
463
521
|
deps: CommandDeps,
|
|
464
522
|
config: CliConfig,
|
|
@@ -474,18 +532,21 @@ async function setupProofState(
|
|
|
474
532
|
}
|
|
475
533
|
}
|
|
476
534
|
|
|
477
|
-
const
|
|
478
|
-
if (
|
|
535
|
+
const companions = readyCompanionDevices(devices)
|
|
536
|
+
if (companions.length === 0) {
|
|
479
537
|
return {
|
|
480
538
|
id: 'proof',
|
|
481
539
|
title: 'Delivery proof',
|
|
482
540
|
status: 'unknown',
|
|
483
|
-
detail: 'not checked — no iPhone is ready',
|
|
541
|
+
detail: 'not checked — no iPhone or Android Companion App is ready',
|
|
484
542
|
}
|
|
485
543
|
}
|
|
486
544
|
|
|
487
545
|
const proof = readSetupProof(deps)
|
|
488
|
-
const target =
|
|
546
|
+
const target =
|
|
547
|
+
proof === null
|
|
548
|
+
? null
|
|
549
|
+
: companions.find((device) => device.device_id === proof.device_id)
|
|
489
550
|
if (proof === null || proof.project !== config.project.value || target === undefined) {
|
|
490
551
|
return {
|
|
491
552
|
id: 'proof',
|
|
@@ -513,6 +574,20 @@ async function setupProofState(
|
|
|
513
574
|
detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
|
|
514
575
|
}
|
|
515
576
|
}
|
|
577
|
+
const now = (deps.now ?? Date.now)()
|
|
578
|
+
if (setupProofIsStale(proof, now)) {
|
|
579
|
+
return {
|
|
580
|
+
id: 'proof',
|
|
581
|
+
title: 'Delivery proof',
|
|
582
|
+
status: 'gap',
|
|
583
|
+
detail: `${proof.request_id} is older than ${SETUP_PROOF_STALE_MS / 36e5}h with no Companion Receipt; init will send a replacement`,
|
|
584
|
+
remedy: {
|
|
585
|
+
by: 'cli',
|
|
586
|
+
summary: 'replace the stale verification notification',
|
|
587
|
+
command: 'notifai init',
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
}
|
|
516
591
|
return {
|
|
517
592
|
id: 'proof',
|
|
518
593
|
title: 'Delivery proof',
|
|
@@ -544,7 +619,8 @@ export async function doctorCommand(
|
|
|
544
619
|
flags: { json?: boolean },
|
|
545
620
|
options: { readiness?: Readiness } = {},
|
|
546
621
|
): Promise<number> {
|
|
547
|
-
const readiness =
|
|
622
|
+
const readiness =
|
|
623
|
+
options.readiness ?? (await assessReadiness(deps, flags.json === true ? { json: true } : {}))
|
|
548
624
|
const blocker = firstBlocker(readiness)
|
|
549
625
|
const ok = blocker === null
|
|
550
626
|
|
|
@@ -874,13 +950,25 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
874
950
|
})
|
|
875
951
|
.map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
|
|
876
952
|
)
|
|
953
|
+
const missing = installations.flatMap((installation) => {
|
|
954
|
+
const required = requiredHookEvents(installation.harness)
|
|
955
|
+
const installed = new Set(
|
|
956
|
+
installation.handlers
|
|
957
|
+
.map((handler) => handlerEvent(handler.command))
|
|
958
|
+
.filter((event): event is string => event !== null),
|
|
959
|
+
)
|
|
960
|
+
const absent = required.filter((event) => !installed.has(event))
|
|
961
|
+
return absent.length === 0
|
|
962
|
+
? []
|
|
963
|
+
: [`${installation.file} is missing ${absent.join(', ')}`]
|
|
964
|
+
})
|
|
877
965
|
checks.push({
|
|
878
966
|
name: 'hooks (stale)',
|
|
879
|
-
ok: stale.length === 0,
|
|
967
|
+
ok: stale.length === 0 && missing.length === 0,
|
|
880
968
|
detail:
|
|
881
|
-
stale.length === 0
|
|
882
|
-
? 'every installed handler names an event this build serves'
|
|
883
|
-
: `${stale.join('; ')} — rerun \`notifai hooks install\` to
|
|
969
|
+
stale.length === 0 && missing.length === 0
|
|
970
|
+
? 'every installed handler names an event this build serves, and every required lifecycle handler is present'
|
|
971
|
+
: `${[...stale, ...missing].join('; ')} — rerun \`notifai hooks install\` to refresh the complete lifecycle set`,
|
|
884
972
|
})
|
|
885
973
|
|
|
886
974
|
const adapterProblems = installations.flatMap((installation) =>
|
|
@@ -935,7 +1023,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
935
1023
|
// Project and global definitions for one harness both fire. Stable adapter
|
|
936
1024
|
// identity deliberately makes their command bytes equal, so comparing
|
|
937
1025
|
// command targets would now hide this duplicate rather than diagnose it.
|
|
938
|
-
//
|
|
1026
|
+
// Cursor is the exception to harness independence because it may also load
|
|
1027
|
+
// Claude's user hook file. The shared adapter detects Cursor's hook-only
|
|
1028
|
+
// environment and makes that compatibility copy a no-op; the native Cursor
|
|
1029
|
+
// definition remains the single routing owner.
|
|
939
1030
|
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
940
1031
|
.map((harness) => ({
|
|
941
1032
|
harness,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { describeSource } from './config-schema.js'
|
|
4
|
+
import { EXIT, type CommandDeps } from './commands-core.js'
|
|
5
|
+
import {
|
|
6
|
+
GUIDANCE_TRUST_PREAMBLE,
|
|
7
|
+
SHIPPED_GUIDANCE,
|
|
8
|
+
shippedGuidanceTopic,
|
|
9
|
+
} from './guidance-content.js'
|
|
10
|
+
import {
|
|
11
|
+
GUIDANCE_TOPIC_MAX_BYTES,
|
|
12
|
+
GUIDANCE_TOPIC_PATTERN,
|
|
13
|
+
findProjectGuidanceDir,
|
|
14
|
+
globalGuidanceDir,
|
|
15
|
+
personalProjectGuidanceDir,
|
|
16
|
+
resolveGuidance,
|
|
17
|
+
type GuidanceAuthority,
|
|
18
|
+
} from './guidance.js'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// guidance show / set / unset
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** How each authority is named in the marker above a topic. */
|
|
25
|
+
const AUTHORITY_LABEL: Record<GuidanceAuthority, string> = {
|
|
26
|
+
user: 'you',
|
|
27
|
+
repository: 'this repository',
|
|
28
|
+
shipped: 'shipped default',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The token that makes a marker line ours.
|
|
33
|
+
*
|
|
34
|
+
* Without it, a repository topic could open with a handwritten
|
|
35
|
+
* `<!-- when-to-notify · this machine · /Users/... -->` and everything after
|
|
36
|
+
* it would read as the User's own standing word. The marker therefore carries
|
|
37
|
+
* a fixed token, and any occurrence of that token inside file content is
|
|
38
|
+
* defanged below — so the only lines that can claim to be provenance are the
|
|
39
|
+
* ones this command wrote.
|
|
40
|
+
*/
|
|
41
|
+
const MARKER_TOKEN = 'notifai:guidance'
|
|
42
|
+
|
|
43
|
+
function markerSafe(content: string): string {
|
|
44
|
+
return content.replaceAll(MARKER_TOKEN, `${MARKER_TOKEN.replace(':', '-')} [not a provenance marker]`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The whole effective guidance, ready to be followed as read.
|
|
49
|
+
*
|
|
50
|
+
* The trust preamble comes first and belongs to the CLI: it says who supplied
|
|
51
|
+
* each topic and what no topic may ask for. Each topic then follows its own
|
|
52
|
+
* marker, so a reader can tell the User's standing word from a repository's
|
|
53
|
+
* house rules from the shipped default without the output stopping being
|
|
54
|
+
* Markdown.
|
|
55
|
+
*/
|
|
56
|
+
export function guidanceShowCommand(deps: CommandDeps, flags: { json?: boolean }): number {
|
|
57
|
+
const topics = resolveGuidance({ cwd: deps.cwd, env: deps.env })
|
|
58
|
+
if (flags.json) {
|
|
59
|
+
deps.io.out(
|
|
60
|
+
JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
trust: GUIDANCE_TRUST_PREAMBLE,
|
|
63
|
+
topics: topics.map((topic) => ({
|
|
64
|
+
name: topic.name,
|
|
65
|
+
source: topic.source,
|
|
66
|
+
authority: topic.authority,
|
|
67
|
+
summary: topic.summary,
|
|
68
|
+
content: topic.content,
|
|
69
|
+
})),
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
return EXIT.ok
|
|
76
|
+
}
|
|
77
|
+
const blocks = topics.map((topic) => {
|
|
78
|
+
const { path: filePath } = describeSource(topic.source)
|
|
79
|
+
const marker =
|
|
80
|
+
`<!-- ${MARKER_TOKEN} topic=${topic.name} from=${AUTHORITY_LABEL[topic.authority]}` +
|
|
81
|
+
`${filePath === null ? '' : ` file=${encodeURIComponent(filePath)}`} -->`
|
|
82
|
+
return `${marker}\n${markerSafe(topic.content).trimEnd()}`
|
|
83
|
+
})
|
|
84
|
+
deps.io.out([GUIDANCE_TRUST_PREAMBLE, ...blocks].join('\n\n'))
|
|
85
|
+
return EXIT.ok
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type GuidanceMutationFlags = {
|
|
89
|
+
project?: boolean | undefined
|
|
90
|
+
local?: boolean | undefined
|
|
91
|
+
yes?: boolean | undefined
|
|
92
|
+
}
|
|
93
|
+
type GuidanceLayer = 'global' | 'project' | 'local'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where a topic file for one layer lives. The project layer prefers an
|
|
97
|
+
* existing `.notifai/guidance` up the tree and otherwise starts one at cwd,
|
|
98
|
+
* the same anchoring `config set --project` uses for its file.
|
|
99
|
+
*/
|
|
100
|
+
function guidanceLayerDir(deps: CommandDeps, layer: GuidanceLayer): string {
|
|
101
|
+
if (layer === 'local') return personalProjectGuidanceDir(deps.cwd, deps.env)
|
|
102
|
+
if (layer === 'project') {
|
|
103
|
+
return findProjectGuidanceDir(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'guidance')
|
|
104
|
+
}
|
|
105
|
+
return globalGuidanceDir(deps.env)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function guidanceMutationTarget(
|
|
109
|
+
deps: CommandDeps,
|
|
110
|
+
topic: string,
|
|
111
|
+
flags: GuidanceMutationFlags,
|
|
112
|
+
): Promise<{ path: string; layer: GuidanceLayer } | null> {
|
|
113
|
+
if (!GUIDANCE_TOPIC_PATTERN.test(topic)) {
|
|
114
|
+
deps.io.err(
|
|
115
|
+
`"${topic}" is not a topic name. Use lowercase letters, digits, "-" and "_", e.g. ${SHIPPED_GUIDANCE.map((t) => t.name).join(', ')}.`,
|
|
116
|
+
)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
let layer: GuidanceLayer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
120
|
+
if (
|
|
121
|
+
flags.local !== true &&
|
|
122
|
+
flags.project !== true &&
|
|
123
|
+
flags.yes !== true &&
|
|
124
|
+
deps.io.interactive === true &&
|
|
125
|
+
deps.io.select
|
|
126
|
+
) {
|
|
127
|
+
const selected = await deps.io.select('Where should this guidance live?', [
|
|
128
|
+
{ value: 'global', label: 'This machine', hint: 'your standing word, applies across projects' },
|
|
129
|
+
{ value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/ — committed, read as project policy' },
|
|
130
|
+
{ value: 'local', label: 'This project (personal)', hint: 'your standing word here, stored outside the repo' },
|
|
131
|
+
])
|
|
132
|
+
if (selected === null) {
|
|
133
|
+
deps.io.err('No guidance layer selected.')
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
layer = selected as GuidanceLayer
|
|
137
|
+
}
|
|
138
|
+
return { path: path.join(guidanceLayerDir(deps, layer), `${topic}.md`), layer }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Writes one topic at one layer, replacing the shipped topic of that name for
|
|
143
|
+
* this scope. The confirmation gate is the same one `config set` holds: the
|
|
144
|
+
* user owns their standing word, so an agent writes it only with their words
|
|
145
|
+
* and their say-so.
|
|
146
|
+
*/
|
|
147
|
+
export async function guidanceSetCommand(
|
|
148
|
+
deps: CommandDeps,
|
|
149
|
+
topic: string,
|
|
150
|
+
text: string,
|
|
151
|
+
flags: GuidanceMutationFlags,
|
|
152
|
+
): Promise<number> {
|
|
153
|
+
if (text.trim() === '') {
|
|
154
|
+
deps.io.err('Guidance text is empty. Pass the text, or --file <path|-> to read it.')
|
|
155
|
+
return EXIT.usage
|
|
156
|
+
}
|
|
157
|
+
if (text.length > GUIDANCE_TOPIC_MAX_BYTES) {
|
|
158
|
+
deps.io.err(`Guidance for one topic is capped at ${GUIDANCE_TOPIC_MAX_BYTES} bytes.`)
|
|
159
|
+
return EXIT.usage
|
|
160
|
+
}
|
|
161
|
+
const target = await guidanceMutationTarget(deps, topic, flags)
|
|
162
|
+
if (target === null) return EXIT.usage
|
|
163
|
+
|
|
164
|
+
const shipped = shippedGuidanceTopic(topic)
|
|
165
|
+
if (!flags.yes) {
|
|
166
|
+
const replaces =
|
|
167
|
+
shipped === undefined
|
|
168
|
+
? `Add guidance topic "${topic}"`
|
|
169
|
+
: `Replace the shipped "${topic}" guidance for this scope`
|
|
170
|
+
const confirmed = await deps.io.confirm(`${replaces} at ${target.path}?`)
|
|
171
|
+
if (!confirmed) {
|
|
172
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
173
|
+
return EXIT.usage
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
mkdirSync(path.dirname(target.path), { recursive: true })
|
|
178
|
+
writeFileSync(target.path, text.endsWith('\n') ? text : `${text}\n`)
|
|
179
|
+
deps.io.out(`Wrote ${topic} to ${target.path}`)
|
|
180
|
+
if (shipped !== undefined) {
|
|
181
|
+
deps.io.out(`This file now replaces the shipped "${topic}" guidance wherever it applies.`)
|
|
182
|
+
}
|
|
183
|
+
// A shared file is committed and travels to everyone who clones this
|
|
184
|
+
// repository, where it is read as the project's policy rather than as
|
|
185
|
+
// anyone's personal standing word. Worth saying at the moment of writing:
|
|
186
|
+
// the same sentence means something different in the two locations.
|
|
187
|
+
if (target.layer === 'project') {
|
|
188
|
+
deps.io.out(
|
|
189
|
+
'This is shared project policy: it is committed, everyone working here reads it, and it is never read as the User\'s own standing word. Use --local for a personal preference.',
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
return EXIT.ok
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function guidanceUnsetCommand(
|
|
196
|
+
deps: CommandDeps,
|
|
197
|
+
topic: string,
|
|
198
|
+
flags: GuidanceMutationFlags,
|
|
199
|
+
): Promise<number> {
|
|
200
|
+
const target = await guidanceMutationTarget(deps, topic, flags)
|
|
201
|
+
if (target === null) return EXIT.usage
|
|
202
|
+
if (!existsSync(target.path)) {
|
|
203
|
+
deps.io.out(`${topic} is not set at ${target.path}`)
|
|
204
|
+
return EXIT.ok
|
|
205
|
+
}
|
|
206
|
+
if (!flags.yes) {
|
|
207
|
+
const confirmed = await deps.io.confirm(`Remove ${target.path}?`)
|
|
208
|
+
if (!confirmed) {
|
|
209
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
210
|
+
return EXIT.usage
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
rmSync(target.path, { force: true })
|
|
214
|
+
deps.io.out(`Removed ${target.path}; the inherited guidance now applies.`)
|
|
215
|
+
return EXIT.ok
|
|
216
|
+
}
|