@raidiant/notifai 0.2.0 → 0.3.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/dist/commands.d.ts +33 -3
- package/dist/commands.js +283 -59
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +2 -1
- package/src/commands.test.ts +153 -11
- package/src/commands.ts +328 -60
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/commands.ts
CHANGED
|
@@ -41,8 +41,21 @@ import {
|
|
|
41
41
|
type ConfigKey,
|
|
42
42
|
type FlagOverrides,
|
|
43
43
|
} from './config.js'
|
|
44
|
+
import { acceptedValues, configInfo } from './config-schema.js'
|
|
45
|
+
import {
|
|
46
|
+
renderConfigExplain,
|
|
47
|
+
renderConfigList,
|
|
48
|
+
renderConfigPlain,
|
|
49
|
+
} from './ui/config-view.js'
|
|
44
50
|
import type { CredentialStore, MachineCredential } from './credentials.js'
|
|
45
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
firstBlocker,
|
|
53
|
+
openItems,
|
|
54
|
+
type Readiness,
|
|
55
|
+
type ReadinessState,
|
|
56
|
+
type StateStatus,
|
|
57
|
+
} from './readiness.js'
|
|
58
|
+
import type { Tone } from './ui/theme.js'
|
|
46
59
|
import {
|
|
47
60
|
handleSessionEnd,
|
|
48
61
|
handleStop,
|
|
@@ -113,7 +126,13 @@ export interface CommandIo {
|
|
|
113
126
|
outro?(message: string): Promise<void>
|
|
114
127
|
note?(message: string, title?: string): Promise<void>
|
|
115
128
|
spinner?(message: string): Promise<CommandSpinner | null>
|
|
116
|
-
|
|
129
|
+
/**
|
|
130
|
+
* A report line. `tone` distinguishes the two states that are neither pass
|
|
131
|
+
* nor fail — something optional the user declined, and something that was
|
|
132
|
+
* never evaluated — which a boolean has to round to one or the other.
|
|
133
|
+
* Optional so existing implementations keep type-checking.
|
|
134
|
+
*/
|
|
135
|
+
check?(ok: boolean, message: string, tone?: Tone): Promise<void>
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
export interface CommandSpinner {
|
|
@@ -387,6 +406,32 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
387
406
|
}
|
|
388
407
|
}
|
|
389
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Devices as data, for surfaces that render them themselves.
|
|
411
|
+
*
|
|
412
|
+
* `devicesCommand` prints and returns an exit code, which is the right shape
|
|
413
|
+
* for a command and the wrong one for a menu that wants to show names beside
|
|
414
|
+
* checkboxes. Silent on every failure by design: the interactive app already
|
|
415
|
+
* shows the credential and connectivity state on its status card, so a second
|
|
416
|
+
* error line here would be reporting the same fault twice.
|
|
417
|
+
*/
|
|
418
|
+
export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
|
|
419
|
+
if (!deps.store.load()) return null
|
|
420
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
421
|
+
const authed = authedClient(deps, config)
|
|
422
|
+
if (!authed) return null
|
|
423
|
+
try {
|
|
424
|
+
return (await authed.client.listDevices()).devices
|
|
425
|
+
} catch {
|
|
426
|
+
return null
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Whether a device is in a state where a notification would actually arrive. */
|
|
431
|
+
export function canDeviceReceive(device: RoutableDevice): boolean {
|
|
432
|
+
return deviceCanReceive(device)
|
|
433
|
+
}
|
|
434
|
+
|
|
390
435
|
export async function capabilitiesCommand(
|
|
391
436
|
deps: CommandDeps,
|
|
392
437
|
flags: { json?: boolean; platform?: Platform },
|
|
@@ -637,20 +682,25 @@ export async function repliesCommand(
|
|
|
637
682
|
deps.io.err('Pass a request id or --pending, not both.')
|
|
638
683
|
return EXIT.usage
|
|
639
684
|
}
|
|
640
|
-
let
|
|
685
|
+
let requestIds = requestedId === undefined ? [] : [requestedId]
|
|
641
686
|
if (flags.pending === true) {
|
|
642
687
|
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
643
688
|
if (sessionId === null) {
|
|
644
689
|
deps.io.err('No active session pointer is available in this directory.')
|
|
645
690
|
return EXIT.noReply
|
|
646
691
|
}
|
|
647
|
-
|
|
648
|
-
|
|
692
|
+
// Every delivered question in the session's queue, in registration order —
|
|
693
|
+
// an agent may have several outstanding at once.
|
|
694
|
+
const state = readSessionState(sessionId, deps.env)
|
|
695
|
+
requestIds = (Array.isArray(state.pending) ? state.pending : [])
|
|
696
|
+
.map((entry) => entry.request_id)
|
|
697
|
+
.filter((id): id is string => id !== undefined)
|
|
698
|
+
if (requestIds.length === 0) {
|
|
649
699
|
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
650
700
|
return EXIT.noReply
|
|
651
701
|
}
|
|
652
702
|
}
|
|
653
|
-
if (
|
|
703
|
+
if (requestIds.length === 0) {
|
|
654
704
|
deps.io.err('Pass a request id or --pending.')
|
|
655
705
|
return EXIT.usage
|
|
656
706
|
}
|
|
@@ -659,24 +709,40 @@ export async function repliesCommand(
|
|
|
659
709
|
const authed = authedClient(deps, config)
|
|
660
710
|
if (!authed) return EXIT.auth
|
|
661
711
|
try {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
712
|
+
let anyReplies = false
|
|
713
|
+
let anyDegraded = false
|
|
714
|
+
let allTimedOut = true
|
|
715
|
+
const jsonBodies: object[] = []
|
|
716
|
+
for (const requestId of requestIds) {
|
|
717
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
718
|
+
timeoutSeconds: waitSeconds,
|
|
719
|
+
afterSeq,
|
|
720
|
+
now: deps.now,
|
|
721
|
+
sleep: deps.sleep,
|
|
722
|
+
})
|
|
723
|
+
if (flags.json) {
|
|
724
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded })
|
|
725
|
+
} else if (result.response.replies.length > 0) {
|
|
726
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
727
|
+
printReplies(deps, result.response.replies)
|
|
728
|
+
} else {
|
|
729
|
+
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
730
|
+
}
|
|
731
|
+
anyReplies ||= result.response.replies.length > 0
|
|
732
|
+
anyDegraded ||= result.degraded
|
|
733
|
+
allTimedOut &&= result.timedOut
|
|
734
|
+
}
|
|
668
735
|
if (flags.json) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
printReplies(deps, result.response.replies)
|
|
736
|
+
// One request keeps the response shape agents already parse; several
|
|
737
|
+
// (only possible via --pending) arrive as an array in queue order.
|
|
738
|
+
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
673
739
|
}
|
|
674
|
-
|
|
675
|
-
if (result.degraded) {
|
|
740
|
+
if (anyDegraded) {
|
|
676
741
|
deps.io.err(DEGRADED_WAIT_WARNING)
|
|
677
742
|
return EXIT.network
|
|
678
743
|
}
|
|
679
|
-
|
|
744
|
+
if (anyReplies) return EXIT.ok
|
|
745
|
+
return allTimedOut ? EXIT.noReply : EXIT.ok
|
|
680
746
|
} catch (err) {
|
|
681
747
|
return reportError(deps, err)
|
|
682
748
|
}
|
|
@@ -1729,7 +1795,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
1729
1795
|
|
|
1730
1796
|
export function configShowCommand(
|
|
1731
1797
|
deps: CommandDeps,
|
|
1732
|
-
flags: { json?: boolean; explain?: boolean },
|
|
1798
|
+
flags: { json?: boolean; explain?: boolean; plain?: boolean },
|
|
1733
1799
|
): number {
|
|
1734
1800
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1735
1801
|
if (flags.json) {
|
|
@@ -1739,14 +1805,111 @@ export function configShowCommand(
|
|
|
1739
1805
|
deps.io.out(JSON.stringify(output, null, 2))
|
|
1740
1806
|
return EXIT.ok
|
|
1741
1807
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1808
|
+
// Anything that is not a person at a terminal keeps the flat `key = value`
|
|
1809
|
+
// form it has always had. Scripts parse this, and a prettier layout for an
|
|
1810
|
+
// audience that cannot see it would only be a breaking change.
|
|
1811
|
+
if (deps.io.interactive !== true || flags.plain === true) {
|
|
1812
|
+
for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
|
|
1813
|
+
return EXIT.ok
|
|
1814
|
+
}
|
|
1815
|
+
for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
|
|
1816
|
+
deps.io.out(line)
|
|
1817
|
+
}
|
|
1818
|
+
return EXIT.ok
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* One setting, explained in full.
|
|
1823
|
+
*
|
|
1824
|
+
* The gap this closes: `config show` prints `require_idle = true` and there was
|
|
1825
|
+
* nowhere to go from there. Every key already had a careful explanation — in a
|
|
1826
|
+
* TypeScript comment, read by everyone except the person who needed it.
|
|
1827
|
+
*/
|
|
1828
|
+
export function configExplainCommand(
|
|
1829
|
+
deps: CommandDeps,
|
|
1830
|
+
key: string,
|
|
1831
|
+
flags: { json?: boolean } = {},
|
|
1832
|
+
): number {
|
|
1833
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
1834
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
1835
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
1836
|
+
return EXIT.usage
|
|
1837
|
+
}
|
|
1838
|
+
const configKey = key as ConfigKey
|
|
1839
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1840
|
+
const info = configInfo(configKey)
|
|
1841
|
+
const entry = config[configKey]
|
|
1842
|
+
|
|
1843
|
+
if (flags.json) {
|
|
1844
|
+
deps.io.out(
|
|
1845
|
+
JSON.stringify(
|
|
1846
|
+
{
|
|
1847
|
+
key: configKey,
|
|
1848
|
+
label: info.label,
|
|
1849
|
+
group: info.group,
|
|
1850
|
+
kind: info.kind,
|
|
1851
|
+
summary: info.summary,
|
|
1852
|
+
detail: info.detail,
|
|
1853
|
+
accepts: acceptedValues(configKey),
|
|
1854
|
+
...(info.choices !== undefined ? { choices: info.choices } : {}),
|
|
1855
|
+
value: entry.value,
|
|
1856
|
+
source: entry.source,
|
|
1857
|
+
},
|
|
1858
|
+
null,
|
|
1859
|
+
2,
|
|
1860
|
+
),
|
|
1861
|
+
)
|
|
1862
|
+
return EXIT.ok
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if (deps.io.interactive !== true) {
|
|
1866
|
+
deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
|
|
1867
|
+
deps.io.out(info.detail.replace(/\n\n/g, '\n'))
|
|
1868
|
+
deps.io.out(`accepts: ${acceptedValues(configKey)}`)
|
|
1869
|
+
return EXIT.ok
|
|
1746
1870
|
}
|
|
1871
|
+
for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
|
|
1747
1872
|
return EXIT.ok
|
|
1748
1873
|
}
|
|
1749
1874
|
|
|
1875
|
+
/**
|
|
1876
|
+
* Closest config key by edit distance, or null when nothing is close.
|
|
1877
|
+
*
|
|
1878
|
+
* The threshold matters more than the algorithm: suggesting a key that shares
|
|
1879
|
+
* three letters with the typo sends the reader to the wrong setting with
|
|
1880
|
+
* confidence, which is worse than listing all fourteen and letting them look.
|
|
1881
|
+
*/
|
|
1882
|
+
function nearestKey(input: string): string | null {
|
|
1883
|
+
let best: string | null = null
|
|
1884
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
1885
|
+
for (const candidate of CONFIG_KEYS) {
|
|
1886
|
+
const distance = editDistance(input.toLowerCase(), candidate)
|
|
1887
|
+
if (distance < bestDistance) {
|
|
1888
|
+
bestDistance = distance
|
|
1889
|
+
best = candidate
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function editDistance(a: string, b: string): number {
|
|
1896
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
1897
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1898
|
+
let diagonal = previous[0]!
|
|
1899
|
+
previous[0] = i
|
|
1900
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1901
|
+
const above = previous[j]!
|
|
1902
|
+
previous[j] = Math.min(
|
|
1903
|
+
previous[j]! + 1,
|
|
1904
|
+
previous[j - 1]! + 1,
|
|
1905
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
1906
|
+
)
|
|
1907
|
+
diagonal = above
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return previous[b.length]!
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1750
1913
|
export async function configSetCommand(
|
|
1751
1914
|
deps: CommandDeps,
|
|
1752
1915
|
key: string,
|
|
@@ -1754,30 +1917,43 @@ export async function configSetCommand(
|
|
|
1754
1917
|
flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
1755
1918
|
): Promise<number> {
|
|
1756
1919
|
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
1757
|
-
deps.io.err(`Unknown
|
|
1920
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
1921
|
+
const near = nearestKey(key)
|
|
1922
|
+
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
1923
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
1924
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
1758
1925
|
return EXIT.usage
|
|
1759
1926
|
}
|
|
1927
|
+
const configKey = key as ConfigKey
|
|
1928
|
+
const info = configInfo(configKey)
|
|
1760
1929
|
let value: unknown = rawValue
|
|
1761
|
-
if (NUMERIC_CONFIG_KEYS.includes(
|
|
1930
|
+
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
1762
1931
|
const numeric = Number(rawValue)
|
|
1763
1932
|
if (!Number.isInteger(numeric)) {
|
|
1764
|
-
deps.io.err(
|
|
1933
|
+
deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
|
|
1765
1934
|
return EXIT.usage
|
|
1766
1935
|
}
|
|
1767
|
-
const bounds = configBounds(
|
|
1936
|
+
const bounds = configBounds(configKey)
|
|
1768
1937
|
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
1769
1938
|
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
|
|
1770
1939
|
return EXIT.usage
|
|
1771
1940
|
}
|
|
1772
1941
|
value = numeric
|
|
1773
1942
|
}
|
|
1774
|
-
if (BOOLEAN_CONFIG_KEYS.includes(
|
|
1943
|
+
if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
|
|
1775
1944
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
1776
1945
|
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
|
|
1777
1946
|
return EXIT.usage
|
|
1778
1947
|
}
|
|
1779
1948
|
value = rawValue === 'true'
|
|
1780
1949
|
}
|
|
1950
|
+
// Enum keys were accepted unchecked, so `config set sound whatever` wrote a
|
|
1951
|
+
// value the sender would later reject — a typo that only surfaces at the
|
|
1952
|
+
// moment a notification fails to carry the sound you asked for.
|
|
1953
|
+
if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
|
|
1954
|
+
deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
|
|
1955
|
+
return EXIT.usage
|
|
1956
|
+
}
|
|
1781
1957
|
if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1782
1958
|
|
|
1783
1959
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
@@ -1836,7 +2012,7 @@ export async function configSetCommand(
|
|
|
1836
2012
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1837
2013
|
* Keep this immutable and public because the command is printed to users.
|
|
1838
2014
|
*/
|
|
1839
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2015
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0'
|
|
1840
2016
|
|
|
1841
2017
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1842
2018
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -2991,7 +3167,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
2991
3167
|
// costs nothing.
|
|
2992
3168
|
if (deps.io.interactive === true && deps.io.check) {
|
|
2993
3169
|
await deps.io.intro?.('Notifai doctor')
|
|
2994
|
-
for (const s of readiness.states)
|
|
3170
|
+
for (const s of readiness.states) {
|
|
3171
|
+
// Four states, not two. Rendering `optional-gap` and `unknown` as
|
|
3172
|
+
// successes made a green report that had never checked half of what it
|
|
3173
|
+
// listed, and put a tick beside things the reader had deliberately
|
|
3174
|
+
// declined.
|
|
3175
|
+
await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
|
|
3176
|
+
}
|
|
2995
3177
|
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
|
|
2996
3178
|
} else {
|
|
2997
3179
|
for (const s of readiness.states) {
|
|
@@ -3010,6 +3192,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
3010
3192
|
return ok ? EXIT.ok : EXIT.failed
|
|
3011
3193
|
}
|
|
3012
3194
|
|
|
3195
|
+
/** Readiness status as a report tone. */
|
|
3196
|
+
function doctorTone(status: StateStatus): Tone {
|
|
3197
|
+
switch (status) {
|
|
3198
|
+
case 'ready':
|
|
3199
|
+
return 'ok'
|
|
3200
|
+
case 'gap':
|
|
3201
|
+
return 'bad'
|
|
3202
|
+
case 'optional-gap':
|
|
3203
|
+
return 'warn'
|
|
3204
|
+
case 'unknown':
|
|
3205
|
+
return 'pending'
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3013
3209
|
/** One line telling the reader what to actually do about a state. */
|
|
3014
3210
|
function remedyLine(state: ReadinessState): string {
|
|
3015
3211
|
const remedy = state.remedy
|
|
@@ -3036,12 +3232,16 @@ function remedyLine(state: ReadinessState): string {
|
|
|
3036
3232
|
* way and is not worth re-deriving. The judgment added here is which failures
|
|
3037
3233
|
* actually stand in the way.
|
|
3038
3234
|
*
|
|
3039
|
-
*
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
*
|
|
3235
|
+
* Not everything failed is in the way. A pointer that has never been
|
|
3236
|
+
* published is the normal condition of an install thirty seconds old — the
|
|
3237
|
+
* next prompt fixes it and no command can — and OpenCode's inability to
|
|
3238
|
+
* resume an idle turn is a property of that harness rather than a fault in
|
|
3239
|
+
* this setup. Treating those as blocking would mean `init` could never
|
|
3240
|
+
* finish for an OpenCode user, or could only finish after a session had
|
|
3241
|
+
* already run — so they report as things worth knowing rather than things to
|
|
3242
|
+
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3243
|
+
* proof included. Which failures are informational, and what the true remedy
|
|
3244
|
+
* is, is each check's own call (`HookCheck`).
|
|
3045
3245
|
*/
|
|
3046
3246
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
3047
3247
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
@@ -3084,7 +3284,6 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3084
3284
|
|
|
3085
3285
|
/** Real but not in the way; see the note above. */
|
|
3086
3286
|
const informational = new Set(['hooks (opencode continuation)'])
|
|
3087
|
-
if (active === null) informational.add('hooks (fired)')
|
|
3088
3287
|
return [
|
|
3089
3288
|
...hookChecks(deps).map((check) => ({
|
|
3090
3289
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -3092,15 +3291,24 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3092
3291
|
check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
3093
3292
|
? 'Question routing'
|
|
3094
3293
|
: check.name,
|
|
3095
|
-
status: check.ok
|
|
3294
|
+
status: check.ok
|
|
3295
|
+
? 'ready' as const
|
|
3296
|
+
: check.informational === true || informational.has(check.name)
|
|
3297
|
+
? 'optional-gap' as const
|
|
3298
|
+
: 'gap' as const,
|
|
3096
3299
|
detail: check.detail,
|
|
3097
3300
|
...(check.ok
|
|
3098
3301
|
? {}
|
|
3099
3302
|
: {
|
|
3100
3303
|
remedy: {
|
|
3101
3304
|
by: 'user-here' as const,
|
|
3102
|
-
|
|
3103
|
-
|
|
3305
|
+
// The check's own remedy when it has one; the generic reinstall
|
|
3306
|
+
// line was wrong exactly where it mattered (an unfired pointer
|
|
3307
|
+
// needs a prompt, not `hooks install`).
|
|
3308
|
+
...(check.remedy ?? {
|
|
3309
|
+
summary: 'the detail above names what to change',
|
|
3310
|
+
command: 'notifai hooks install',
|
|
3311
|
+
}),
|
|
3104
3312
|
},
|
|
3105
3313
|
}),
|
|
3106
3314
|
})),
|
|
@@ -3108,8 +3316,18 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3108
3316
|
]
|
|
3109
3317
|
}
|
|
3110
3318
|
|
|
3111
|
-
|
|
3112
|
-
|
|
3319
|
+
interface HookCheck {
|
|
3320
|
+
name: string
|
|
3321
|
+
ok: boolean
|
|
3322
|
+
detail: string
|
|
3323
|
+
/** Real but not in the way: worth a line, never a blocker. */
|
|
3324
|
+
informational?: boolean
|
|
3325
|
+
/** A remedy truer than the generic `notifai hooks install`. */
|
|
3326
|
+
remedy?: { summary: string; command: string }
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
3330
|
+
const checks: HookCheck[] = []
|
|
3113
3331
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
3114
3332
|
|
|
3115
3333
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
@@ -3143,23 +3361,52 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
3143
3361
|
activeInstallations.length > 0
|
|
3144
3362
|
? `active ${active.label} session has a matching hook installation`
|
|
3145
3363
|
: `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
|
|
3364
|
+
...(activeInstallations.length > 0
|
|
3365
|
+
? {}
|
|
3366
|
+
: {
|
|
3367
|
+
remedy: {
|
|
3368
|
+
summary: `install hooks for the active ${active.label} session`,
|
|
3369
|
+
command: `notifai hooks install --harness ${active.harness}`,
|
|
3370
|
+
},
|
|
3371
|
+
}),
|
|
3146
3372
|
})
|
|
3147
3373
|
if (activeInstallations.length > 0) {
|
|
3148
3374
|
const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3375
|
+
if (pointer === null) {
|
|
3376
|
+
// The normal condition of hooks installed moments ago: the pointer
|
|
3377
|
+
// appears when the harness next fires a hook, and no command can
|
|
3378
|
+
// force that. Informational, so `init` walks on to the states it can
|
|
3379
|
+
// actually prove instead of exiting over evidence only time produces.
|
|
3380
|
+
checks.push({
|
|
3381
|
+
name: 'hooks (active session)',
|
|
3382
|
+
ok: false,
|
|
3383
|
+
informational: true,
|
|
3384
|
+
detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
|
|
3385
|
+
remedy: {
|
|
3386
|
+
summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
|
|
3387
|
+
command: 'notifai doctor',
|
|
3388
|
+
},
|
|
3389
|
+
})
|
|
3390
|
+
} else {
|
|
3391
|
+
const matches =
|
|
3392
|
+
pointer.harness === active.harness &&
|
|
3393
|
+
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3394
|
+
checks.push({
|
|
3395
|
+
name: 'hooks (active session)',
|
|
3396
|
+
ok: matches,
|
|
3397
|
+
detail: matches
|
|
3398
|
+
? `the project pointer belongs to the active ${active.label} session`
|
|
3399
|
+
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
3400
|
+
...(matches
|
|
3401
|
+
? {}
|
|
3402
|
+
: {
|
|
3403
|
+
remedy: {
|
|
3404
|
+
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
3405
|
+
command: 'notifai doctor',
|
|
3406
|
+
},
|
|
3407
|
+
}),
|
|
3408
|
+
})
|
|
3409
|
+
}
|
|
3163
3410
|
}
|
|
3164
3411
|
}
|
|
3165
3412
|
|
|
@@ -3252,9 +3499,20 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
3252
3499
|
checks.push({
|
|
3253
3500
|
name: 'hooks (fired)',
|
|
3254
3501
|
ok: fired,
|
|
3502
|
+
// Never-fired is evidence about the past, not a fault in the setup, and
|
|
3503
|
+
// the cure is a prompt no command can send.
|
|
3504
|
+
informational: true,
|
|
3255
3505
|
detail: fired
|
|
3256
3506
|
? 'a session in this directory has run them'
|
|
3257
3507
|
: `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
|
|
3508
|
+
...(fired
|
|
3509
|
+
? {}
|
|
3510
|
+
: {
|
|
3511
|
+
remedy: {
|
|
3512
|
+
summary: 'send one prompt in a session here, then re-check',
|
|
3513
|
+
command: 'notifai doctor',
|
|
3514
|
+
},
|
|
3515
|
+
}),
|
|
3258
3516
|
})
|
|
3259
3517
|
|
|
3260
3518
|
if (installations.some((installation) => installation.harness === 'opencode')) {
|
|
@@ -3380,11 +3638,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
3380
3638
|
error: (next) => progress.error(next),
|
|
3381
3639
|
}
|
|
3382
3640
|
},
|
|
3383
|
-
check: async (ok, message) => {
|
|
3641
|
+
check: async (ok, message, tone) => {
|
|
3384
3642
|
if (!interactive()) return
|
|
3385
3643
|
const { log } = await clack()
|
|
3386
|
-
|
|
3387
|
-
|
|
3644
|
+
switch (tone ?? (ok ? 'ok' : 'bad')) {
|
|
3645
|
+
case 'ok':
|
|
3646
|
+
return log.success(message)
|
|
3647
|
+
case 'warn':
|
|
3648
|
+
return log.warn(message)
|
|
3649
|
+
case 'pending':
|
|
3650
|
+
// Never checked. `log.info` reads as neutral, which is the honest
|
|
3651
|
+
// rendering for a state no evidence was gathered about.
|
|
3652
|
+
return log.info(message)
|
|
3653
|
+
case 'bad':
|
|
3654
|
+
return log.error(message)
|
|
3655
|
+
}
|
|
3388
3656
|
},
|
|
3389
3657
|
openUrl: (url) => {
|
|
3390
3658
|
try {
|