@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/dist/commands.js
CHANGED
|
@@ -7,20 +7,20 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
|
7
7
|
import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
8
8
|
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
9
9
|
import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
|
|
10
|
-
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath,
|
|
10
|
+
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
11
11
|
import { acceptedValues, configInfo } from './config-schema.js';
|
|
12
|
-
import { skillsSource } from './release.js';
|
|
12
|
+
import { packageVersion, skillsSource } from './release.js';
|
|
13
13
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
14
14
|
import { withTargetFileLock } from './file-lock.js';
|
|
15
15
|
import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
|
|
16
|
-
import { firstBlocker,
|
|
16
|
+
import { firstBlocker, } from './readiness.js';
|
|
17
17
|
import { WAITER_CEILING_SECONDS, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
|
|
18
18
|
import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
|
|
19
|
-
import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexTrustProblems, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
20
|
-
import { HARNESS_CAPABILITIES } from './harnesses.js';
|
|
19
|
+
import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, collapseUnusedCodexRepresentation, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
20
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
|
|
21
21
|
import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
|
|
22
22
|
import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
|
|
23
|
-
import { inspectHookAdapter, installHookAdapter, } from './hook-adapter.js';
|
|
23
|
+
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
|
|
24
24
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
25
25
|
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
|
|
26
26
|
/** The invocation's logger, or one that discards everything. */
|
|
@@ -1628,22 +1628,87 @@ export async function closeCommand(deps, requestId) {
|
|
|
1628
1628
|
return reportError(deps, err);
|
|
1629
1629
|
}
|
|
1630
1630
|
}
|
|
1631
|
+
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
1632
|
+
export function runningViaNpx(env, scriptPath) {
|
|
1633
|
+
if (env['npm_command'] === 'exec')
|
|
1634
|
+
return true;
|
|
1635
|
+
return scriptPath.includes(`${path.sep}_npx${path.sep}`);
|
|
1636
|
+
}
|
|
1637
|
+
function fileHookInstallTarget(target) {
|
|
1638
|
+
if (target === undefined || isNpxAdapterTarget(target))
|
|
1639
|
+
return undefined;
|
|
1640
|
+
return target;
|
|
1641
|
+
}
|
|
1642
|
+
function resolveHookAdapterTarget(deps, flags) {
|
|
1643
|
+
if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
|
|
1644
|
+
return deps.hookInstallTarget;
|
|
1645
|
+
}
|
|
1646
|
+
const fileTarget = fileHookInstallTarget(deps.hookInstallTarget);
|
|
1647
|
+
const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath;
|
|
1648
|
+
const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1649
|
+
if (runningViaNpx(deps.env, scriptPath)) {
|
|
1650
|
+
const version = packageVersion();
|
|
1651
|
+
const npmCli = deps.env['npm_execpath'];
|
|
1652
|
+
if (version === null) {
|
|
1653
|
+
throw new Error('Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1654
|
+
}
|
|
1655
|
+
if (typeof npmCli !== 'string' || npmCli === '') {
|
|
1656
|
+
throw new Error('This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1657
|
+
}
|
|
1658
|
+
return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` };
|
|
1659
|
+
}
|
|
1660
|
+
return { execPath, scriptPath };
|
|
1661
|
+
}
|
|
1662
|
+
function printHooksInstallClose(deps, harness, file) {
|
|
1663
|
+
const label = HARNESS_LABELS[harness];
|
|
1664
|
+
if (deps.io.interactive === true && deps.io.note) {
|
|
1665
|
+
void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
1669
|
+
deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
|
|
1670
|
+
}
|
|
1631
1671
|
export function hooksInstallCommand(deps, flags) {
|
|
1672
|
+
if (flags.harness === undefined) {
|
|
1673
|
+
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
1674
|
+
if (detected.length === 0) {
|
|
1675
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1676
|
+
return EXIT.usage;
|
|
1677
|
+
}
|
|
1678
|
+
let ok = true;
|
|
1679
|
+
for (const harness of detected) {
|
|
1680
|
+
if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok)
|
|
1681
|
+
ok = false;
|
|
1682
|
+
}
|
|
1683
|
+
return ok ? EXIT.ok : EXIT.failed;
|
|
1684
|
+
}
|
|
1632
1685
|
const harness = resolveHarness(deps, flags.harness);
|
|
1633
1686
|
if (!harness)
|
|
1634
1687
|
return EXIT.usage;
|
|
1635
|
-
const
|
|
1636
|
-
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1688
|
+
const adapterTarget = resolveHookAdapterTarget(deps, flags);
|
|
1689
|
+
const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1637
1690
|
let adapterPath;
|
|
1638
1691
|
try {
|
|
1639
|
-
adapterPath = installHookAdapter(
|
|
1692
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path;
|
|
1640
1693
|
}
|
|
1641
1694
|
catch (err) {
|
|
1642
1695
|
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
|
|
1643
1696
|
return EXIT.failed;
|
|
1644
1697
|
}
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1698
|
+
const wantGlobal = flags.global === true;
|
|
1699
|
+
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter((installation) => installation.harness === harness);
|
|
1700
|
+
const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
|
|
1701
|
+
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
1702
|
+
const globalFile = otherScope.find((installation) => installation.global)?.file;
|
|
1703
|
+
deps.io.out(`${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}`);
|
|
1704
|
+
return EXIT.ok;
|
|
1705
|
+
}
|
|
1706
|
+
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
1707
|
+
if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
|
|
1708
|
+
return EXIT.failed;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env);
|
|
1647
1712
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1648
1713
|
// merged into a settings document, so it owns the whole file.
|
|
1649
1714
|
if (harness === 'opencode') {
|
|
@@ -1653,9 +1718,8 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1653
1718
|
});
|
|
1654
1719
|
}
|
|
1655
1720
|
if (harness === 'cursor') {
|
|
1656
|
-
let merged;
|
|
1657
1721
|
try {
|
|
1658
|
-
|
|
1722
|
+
withTargetFileLock(file, () => {
|
|
1659
1723
|
const document = loadCursorSettings(file);
|
|
1660
1724
|
const result = mergeCursorHooks(document, buildCursorHookConfig({
|
|
1661
1725
|
adapterPath,
|
|
@@ -1669,28 +1733,15 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1669
1733
|
deps.io.err(String(err));
|
|
1670
1734
|
return EXIT.failed;
|
|
1671
1735
|
}
|
|
1672
|
-
deps
|
|
1673
|
-
if (merged.replaced.length > 0)
|
|
1674
|
-
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
1675
|
-
if (merged.added.length > 0)
|
|
1676
|
-
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
1677
|
-
if (flags.global) {
|
|
1678
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
1679
|
-
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
1680
|
-
}
|
|
1681
|
-
else {
|
|
1682
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
1683
|
-
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
1684
|
-
}
|
|
1685
|
-
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose');
|
|
1686
|
-
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore');
|
|
1687
|
-
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.');
|
|
1736
|
+
printHooksInstallClose(deps, harness, file);
|
|
1688
1737
|
return EXIT.ok;
|
|
1689
1738
|
}
|
|
1690
|
-
let
|
|
1739
|
+
let foreignStopCount = 0;
|
|
1740
|
+
let collapsedFrom = null;
|
|
1691
1741
|
try {
|
|
1692
|
-
|
|
1742
|
+
withTargetFileLock(file, () => {
|
|
1693
1743
|
const document = loadSettings(file);
|
|
1744
|
+
foreignStopCount = foreignStopHandlers(document).length;
|
|
1694
1745
|
const result = mergeHooks(document, buildHookConfig({
|
|
1695
1746
|
adapterPath,
|
|
1696
1747
|
harness,
|
|
@@ -1698,61 +1749,41 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1698
1749
|
applyPlan(file, result.document);
|
|
1699
1750
|
return result;
|
|
1700
1751
|
});
|
|
1752
|
+
if (harness === 'codex') {
|
|
1753
|
+
collapsedFrom = collapseUnusedCodexRepresentation(file, scriptPath, codexLayerPaths(flags.global ?? false, deps.cwd, deps.env));
|
|
1754
|
+
}
|
|
1701
1755
|
}
|
|
1702
1756
|
catch (err) {
|
|
1703
1757
|
deps.io.err(String(err));
|
|
1704
1758
|
return EXIT.failed;
|
|
1705
1759
|
}
|
|
1706
|
-
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
1707
|
-
if (merged.replaced.length > 0)
|
|
1708
|
-
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
1709
|
-
if (merged.added.length > 0)
|
|
1710
|
-
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
1711
|
-
if (merged.removed.length > 0) {
|
|
1712
|
-
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
|
|
1713
|
-
}
|
|
1714
|
-
deps.io.out('');
|
|
1715
|
-
deps.io.out(config.ask_grace_seconds.value === 0
|
|
1716
|
-
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
1717
|
-
: `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.`);
|
|
1718
1760
|
if (harness === 'codex') {
|
|
1719
1761
|
const layer = flags.global ? null : codexLayerDir(deps.cwd);
|
|
1720
|
-
if (layer !== null)
|
|
1721
|
-
// Codex reads project hooks from the main repository but only looks when
|
|
1722
|
-
// a `.codex` directory sits at or above cwd, so a worktree install has to
|
|
1723
|
-
// write one file and create one directory in two different places. Doing
|
|
1724
|
-
// it silently would leave the next person deriving this the hard way
|
|
1725
|
-
//.
|
|
1762
|
+
if (layer !== null)
|
|
1726
1763
|
mkdirSync(layer, { recursive: true });
|
|
1727
|
-
deps.io.out('');
|
|
1728
|
-
deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,');
|
|
1729
|
-
deps.io.out(`so they were written to ${file}. ${layer} was created so this`);
|
|
1730
|
-
deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`');
|
|
1731
|
-
deps.io.out('directory; rerun this installer from that worktree to create it.');
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
deps.io.out('');
|
|
1735
|
-
if (harness === 'claude-code') {
|
|
1736
|
-
// Codex deliberately gets no counterpart line: its Stop definition is
|
|
1737
|
-
// byte-identical to the one earlier builds wrote, so nothing about its
|
|
1738
|
-
// trust state changed and saying so would invent a gate that is not there.
|
|
1739
|
-
deps.io.out(`The Stop handler is asynchronous: it returns at once, so your turn ends normally, and it waits for your answer out of band for up to ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s before delivering it back into this session. That timeout is written explicitly because Claude Code kills a background hook at its own 600s default and reports nothing when it does.`);
|
|
1740
|
-
deps.io.out('');
|
|
1741
1764
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
deps.io.out(
|
|
1765
|
+
printHooksInstallClose(deps, harness, file);
|
|
1766
|
+
if (collapsedFrom !== null) {
|
|
1767
|
+
deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`);
|
|
1745
1768
|
}
|
|
1746
|
-
|
|
1747
|
-
deps.io.out(
|
|
1748
|
-
deps.io.out('not fired, start a new Claude Code session and try one prompt again.');
|
|
1769
|
+
if (foreignStopCount > 0) {
|
|
1770
|
+
deps.io.out("This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.");
|
|
1749
1771
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1772
|
+
if (harness === 'codex') {
|
|
1773
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
|
|
1774
|
+
deps.io.out(problem);
|
|
1775
|
+
}
|
|
1753
1776
|
}
|
|
1754
1777
|
return EXIT.ok;
|
|
1755
1778
|
}
|
|
1779
|
+
function foreignStopHandlers(document) {
|
|
1780
|
+
const groups = document.hooks?.['Stop'];
|
|
1781
|
+
if (!Array.isArray(groups))
|
|
1782
|
+
return [];
|
|
1783
|
+
return groups
|
|
1784
|
+
.flatMap((group) => group.hooks ?? [])
|
|
1785
|
+
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1786
|
+
}
|
|
1756
1787
|
/**
|
|
1757
1788
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
1758
1789
|
* including one a different checkout wrote, matched on the managed marker for
|
|
@@ -1779,16 +1810,7 @@ function installOpencodePlugin(deps, file, options) {
|
|
|
1779
1810
|
deps.io.err(String(err));
|
|
1780
1811
|
return EXIT.failed;
|
|
1781
1812
|
}
|
|
1782
|
-
deps
|
|
1783
|
-
deps.io.out('');
|
|
1784
|
-
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
|
|
1785
|
-
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
|
|
1786
|
-
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
|
|
1787
|
-
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,');
|
|
1788
|
-
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when');
|
|
1789
|
-
deps.io.out('the answer must return to the agent without another human prompt.');
|
|
1790
|
-
deps.io.out('');
|
|
1791
|
-
deps.io.out('Restart OpenCode: it loads plugins once at start.');
|
|
1813
|
+
printHooksInstallClose(deps, 'opencode', file);
|
|
1792
1814
|
return EXIT.ok;
|
|
1793
1815
|
}
|
|
1794
1816
|
export function hooksUninstallCommand(deps, flags) {
|
|
@@ -1845,28 +1867,34 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1845
1867
|
: `No Notifai hooks found in ${file}`);
|
|
1846
1868
|
return EXIT.ok;
|
|
1847
1869
|
}
|
|
1848
|
-
|
|
1870
|
+
const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
1871
|
+
const existing = files.filter((candidate) => existsSync(candidate));
|
|
1872
|
+
if (existing.length === 0) {
|
|
1873
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1874
|
+
return EXIT.ok;
|
|
1875
|
+
}
|
|
1876
|
+
let removedAny = false;
|
|
1849
1877
|
try {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1878
|
+
for (const candidate of existing) {
|
|
1879
|
+
const stripped = withTargetFileLock(candidate, () => {
|
|
1880
|
+
const document = loadSettings(candidate);
|
|
1881
|
+
const result = removeHooks(document, scriptPath);
|
|
1882
|
+
applyPlan(candidate, result.document);
|
|
1883
|
+
return result;
|
|
1884
|
+
});
|
|
1885
|
+
if (stripped.replaced.length > 0) {
|
|
1886
|
+
removedAny = true;
|
|
1887
|
+
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1858
1890
|
}
|
|
1859
1891
|
catch (err) {
|
|
1860
1892
|
deps.io.err(String(err));
|
|
1861
1893
|
return EXIT.failed;
|
|
1862
1894
|
}
|
|
1863
|
-
if (
|
|
1864
|
-
deps.io.out(`
|
|
1865
|
-
return EXIT.ok;
|
|
1895
|
+
if (!removedAny) {
|
|
1896
|
+
deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`);
|
|
1866
1897
|
}
|
|
1867
|
-
deps.io.out(stripped.replaced.length > 0
|
|
1868
|
-
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1869
|
-
: `No Notifai hooks found in ${file}`);
|
|
1870
1898
|
return EXIT.ok;
|
|
1871
1899
|
}
|
|
1872
1900
|
function assertOwnedRegularFile(file) {
|
|
@@ -1879,6 +1907,12 @@ function assertOwnedRegularFile(file) {
|
|
|
1879
1907
|
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
1880
1908
|
}
|
|
1881
1909
|
}
|
|
1910
|
+
const HARNESS_LABEL = {
|
|
1911
|
+
'claude-code': 'Claude Code',
|
|
1912
|
+
codex: 'Codex',
|
|
1913
|
+
cursor: 'Cursor',
|
|
1914
|
+
opencode: 'OpenCode',
|
|
1915
|
+
};
|
|
1882
1916
|
function resolveHarness(deps, requested) {
|
|
1883
1917
|
if (requested !== undefined) {
|
|
1884
1918
|
if (HARNESSES.includes(requested))
|
|
@@ -1886,20 +1920,61 @@ function resolveHarness(deps, requested) {
|
|
|
1886
1920
|
deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
|
|
1887
1921
|
return null;
|
|
1888
1922
|
}
|
|
1889
|
-
const detected = detectHarness(deps.cwd);
|
|
1923
|
+
const detected = detectHarness(deps.cwd, deps.env);
|
|
1890
1924
|
if (!detected) {
|
|
1891
1925
|
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1892
1926
|
return null;
|
|
1893
1927
|
}
|
|
1894
1928
|
return detected;
|
|
1895
1929
|
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
1932
|
+
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
1933
|
+
* or names more than one.
|
|
1934
|
+
*/
|
|
1935
|
+
async function pickHarnessesToInstall(deps, requested) {
|
|
1936
|
+
if (requested !== undefined) {
|
|
1937
|
+
const harness = resolveHarness(deps, requested);
|
|
1938
|
+
return harness === null ? null : [harness];
|
|
1939
|
+
}
|
|
1940
|
+
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
1941
|
+
if (detected.length === 1)
|
|
1942
|
+
return detected;
|
|
1943
|
+
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
1944
|
+
const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
|
|
1945
|
+
value: name,
|
|
1946
|
+
label: HARNESS_LABEL[name],
|
|
1947
|
+
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
1948
|
+
})), detected);
|
|
1949
|
+
if (picked === null)
|
|
1950
|
+
return null;
|
|
1951
|
+
const unknown = picked.filter((name) => !HARNESSES.includes(name));
|
|
1952
|
+
if (unknown.length > 0) {
|
|
1953
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`);
|
|
1954
|
+
return null;
|
|
1955
|
+
}
|
|
1956
|
+
return picked;
|
|
1957
|
+
}
|
|
1958
|
+
if (detected.length === 0) {
|
|
1959
|
+
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
return detected;
|
|
1963
|
+
}
|
|
1896
1964
|
// ---------------------------------------------------------------------------
|
|
1897
1965
|
// config show / set / unset
|
|
1898
1966
|
// ---------------------------------------------------------------------------
|
|
1899
1967
|
export function configShowCommand(deps, flags) {
|
|
1900
1968
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1901
1969
|
if (flags.json) {
|
|
1902
|
-
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
|
|
1970
|
+
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
|
|
1971
|
+
key,
|
|
1972
|
+
{
|
|
1973
|
+
value: config[key].value,
|
|
1974
|
+
source: config[key].source,
|
|
1975
|
+
summary: configInfo(key).summary,
|
|
1976
|
+
},
|
|
1977
|
+
]));
|
|
1903
1978
|
deps.io.out(JSON.stringify(output, null, 2));
|
|
1904
1979
|
return EXIT.ok;
|
|
1905
1980
|
}
|
|
@@ -2073,7 +2148,7 @@ async function configMutationTarget(deps, flags) {
|
|
|
2073
2148
|
const selected = await deps.io.select('Where should this setting live?', [
|
|
2074
2149
|
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
2075
2150
|
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
2076
|
-
{ value: 'local', label: 'This project (personal)', hint: '
|
|
2151
|
+
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
2077
2152
|
]);
|
|
2078
2153
|
if (selected === null) {
|
|
2079
2154
|
deps.io.err('No configuration layer selected.');
|
|
@@ -2084,7 +2159,7 @@ async function configMutationTarget(deps, flags) {
|
|
|
2084
2159
|
const targetPath = flags.session
|
|
2085
2160
|
? sessionConfigPath(flags.session, deps.env)
|
|
2086
2161
|
: layer === 'local'
|
|
2087
|
-
? (
|
|
2162
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
2088
2163
|
: layer === 'project'
|
|
2089
2164
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
2090
2165
|
: globalConfigPath(deps.env);
|
|
@@ -2491,17 +2566,15 @@ async function closeGap(deps, state, flags) {
|
|
|
2491
2566
|
return 'closed';
|
|
2492
2567
|
}
|
|
2493
2568
|
if (state.id === 'hooks') {
|
|
2494
|
-
|
|
2495
|
-
if (
|
|
2496
|
-
const picked = await deps.io.select('Which agent harness do you use here?', HARNESSES.map((name) => ({ value: name, label: name })));
|
|
2497
|
-
if (picked !== null)
|
|
2498
|
-
harness = picked;
|
|
2499
|
-
}
|
|
2500
|
-
if (harness === null) {
|
|
2501
|
-
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
2569
|
+
const harnesses = await pickHarnessesToInstall(deps);
|
|
2570
|
+
if (harnesses === null || harnesses.length === 0)
|
|
2502
2571
|
return 'failed';
|
|
2572
|
+
let ok = true;
|
|
2573
|
+
for (const harness of harnesses) {
|
|
2574
|
+
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok)
|
|
2575
|
+
ok = false;
|
|
2503
2576
|
}
|
|
2504
|
-
return
|
|
2577
|
+
return ok ? 'closed' : 'failed';
|
|
2505
2578
|
}
|
|
2506
2579
|
if (state.id === 'skill') {
|
|
2507
2580
|
if (deps.nativeSkills === undefined) {
|
|
@@ -2790,6 +2863,12 @@ async function runSetupProof(deps) {
|
|
|
2790
2863
|
'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
|
|
2791
2864
|
return 'pending';
|
|
2792
2865
|
}
|
|
2866
|
+
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
2867
|
+
function refreshAfterClose(id) {
|
|
2868
|
+
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
2869
|
+
? ['local']
|
|
2870
|
+
: undefined;
|
|
2871
|
+
}
|
|
2793
2872
|
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
2794
2873
|
function wantsOptional(deps, state, flags) {
|
|
2795
2874
|
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
@@ -2843,8 +2922,12 @@ export async function initCommand(deps, flags) {
|
|
|
2843
2922
|
return EXIT.usage;
|
|
2844
2923
|
}
|
|
2845
2924
|
await deps.io.intro?.('Notifai setup');
|
|
2846
|
-
const
|
|
2847
|
-
let readiness = await
|
|
2925
|
+
const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope };
|
|
2926
|
+
let readiness = await assessReadiness(deps, skillOpts);
|
|
2927
|
+
const reassess = (refresh) => assessReadiness(deps, {
|
|
2928
|
+
...skillOpts,
|
|
2929
|
+
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
2930
|
+
});
|
|
2848
2931
|
let failed = false;
|
|
2849
2932
|
const attempted = new Set();
|
|
2850
2933
|
// Re-assess after every successful action. This is how a browser approval or
|
|
@@ -2876,7 +2959,7 @@ export async function initCommand(deps, flags) {
|
|
|
2876
2959
|
if (result === 'failed')
|
|
2877
2960
|
failed = true;
|
|
2878
2961
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
2879
|
-
readiness = await reassess();
|
|
2962
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2880
2963
|
advanced = true;
|
|
2881
2964
|
break;
|
|
2882
2965
|
}
|
|
@@ -2884,7 +2967,7 @@ export async function initCommand(deps, flags) {
|
|
|
2884
2967
|
stop = true;
|
|
2885
2968
|
break;
|
|
2886
2969
|
}
|
|
2887
|
-
readiness = await reassess();
|
|
2970
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2888
2971
|
advanced = true;
|
|
2889
2972
|
break;
|
|
2890
2973
|
}
|
|
@@ -2897,7 +2980,7 @@ export async function initCommand(deps, flags) {
|
|
|
2897
2980
|
stop = true;
|
|
2898
2981
|
break;
|
|
2899
2982
|
}
|
|
2900
|
-
readiness = await reassess();
|
|
2983
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2901
2984
|
advanced = true;
|
|
2902
2985
|
break;
|
|
2903
2986
|
}
|
|
@@ -2939,34 +3022,74 @@ export async function initCommand(deps, flags) {
|
|
|
2939
3022
|
if (stop || !advanced)
|
|
2940
3023
|
break;
|
|
2941
3024
|
}
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
3025
|
+
await printInitClose(deps, readiness, flags);
|
|
3026
|
+
const blocker = firstBlocker(readiness);
|
|
3027
|
+
if (blocker === null)
|
|
3028
|
+
return failed ? EXIT.failed : EXIT.ok;
|
|
3029
|
+
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
|
|
3030
|
+
}
|
|
3031
|
+
function isHookSubstate(id) {
|
|
3032
|
+
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
|
|
3033
|
+
}
|
|
3034
|
+
function leftoverOptionals(readiness, flags) {
|
|
3035
|
+
return readiness.states.filter((state) => {
|
|
3036
|
+
if (state.status !== 'optional-gap')
|
|
3037
|
+
return false;
|
|
3038
|
+
if (isHookSubstate(state.id))
|
|
3039
|
+
return false;
|
|
3040
|
+
if (state.id === 'skill' && flags.skills === false)
|
|
3041
|
+
return false;
|
|
3042
|
+
return true;
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
function printOptionalLeftovers(deps, leftovers) {
|
|
3046
|
+
for (const state of leftovers) {
|
|
3047
|
+
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
2945
3048
|
}
|
|
3049
|
+
}
|
|
3050
|
+
function questionsWillRoute(readiness) {
|
|
3051
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks');
|
|
3052
|
+
const settings = readiness.states.find((state) => state.id === 'question-routing-settings');
|
|
3053
|
+
return hooks?.status === 'ready' && settings?.status !== 'gap';
|
|
3054
|
+
}
|
|
3055
|
+
async function printInitClose(deps, readiness, flags) {
|
|
2946
3056
|
const blocker = firstBlocker(readiness);
|
|
3057
|
+
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
|
|
3058
|
+
const questions = questionsWillRoute(readiness);
|
|
3059
|
+
const leftovers = leftoverOptionals(readiness, flags);
|
|
3060
|
+
if (deps.io.interactive === true) {
|
|
3061
|
+
if (blocker === null) {
|
|
3062
|
+
const lines = [
|
|
3063
|
+
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
3064
|
+
questions
|
|
3065
|
+
? 'Questions will reach your devices.'
|
|
3066
|
+
: 'Questions stay in the terminal until hooks are installed.',
|
|
3067
|
+
];
|
|
3068
|
+
await deps.io.note?.(lines.join('\n'), 'Ready');
|
|
3069
|
+
printOptionalLeftovers(deps, leftovers);
|
|
3070
|
+
deps.io.out('All set. Agents in this project can notify you and ask questions.');
|
|
3071
|
+
await deps.io.outro?.('All set ✨');
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
|
|
3075
|
+
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
3076
|
+
deps.io.out(` ${remedyLine(blocker)}`);
|
|
3077
|
+
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3078
|
+
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
3079
|
+
}
|
|
3080
|
+
await deps.io.outro?.('One step remains (above)');
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
2947
3083
|
if (blocker === null) {
|
|
2948
|
-
|
|
2949
|
-
for (const state of skipped)
|
|
2950
|
-
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
3084
|
+
printOptionalLeftovers(deps, leftovers);
|
|
2951
3085
|
deps.io.out('All set. Agents in this project can notify you and ask questions.');
|
|
2952
|
-
|
|
2953
|
-
return failed ? EXIT.failed : EXIT.ok;
|
|
3086
|
+
return;
|
|
2954
3087
|
}
|
|
2955
|
-
// Exactly one. Everything else waits until this is done, because the next
|
|
2956
|
-
// gap is frequently a consequence of this one and naming it now would send
|
|
2957
|
-
// the reader off to fix something that is not actually wrong.
|
|
2958
|
-
deps.io.out('');
|
|
2959
3088
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
2960
3089
|
deps.io.out(` ${remedyLine(blocker)}`);
|
|
2961
|
-
// Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
|
|
2962
|
-
// how the rest is recovered either way.
|
|
2963
3090
|
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
2964
3091
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
2965
3092
|
}
|
|
2966
|
-
await deps.io.outro?.('One step remains (above)');
|
|
2967
|
-
// An agent must be able to branch on setup being blocked without parsing
|
|
2968
|
-
// prose. A present human may deliberately leave and resume later.
|
|
2969
|
-
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
|
|
2970
3093
|
}
|
|
2971
3094
|
// ---------------------------------------------------------------------------
|
|
2972
3095
|
// doctor
|
|
@@ -3009,23 +3132,10 @@ async function contractCheck(client) {
|
|
|
3009
3132
|
};
|
|
3010
3133
|
}
|
|
3011
3134
|
}
|
|
3012
|
-
|
|
3013
|
-
* Read the whole setup once, in dependency order.
|
|
3014
|
-
*
|
|
3015
|
-
* Descent stops where a prerequisite is missing: without a credential there is
|
|
3016
|
-
* nothing to ask the server with, and without a reachable server a contract
|
|
3017
|
-
* mismatch is unknowable rather than absent. Those downstream states report
|
|
3018
|
-
* `unknown`, which is the honest answer and keeps a network outage from
|
|
3019
|
-
* looking like a broken install.
|
|
3020
|
-
*/
|
|
3021
|
-
export async function assessReadiness(deps, options = {}) {
|
|
3022
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3023
|
-
const states = [];
|
|
3024
|
-
let accountClient = null;
|
|
3025
|
-
let accountDevices = null;
|
|
3135
|
+
function projectReadiness(deps, config) {
|
|
3026
3136
|
const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
|
|
3027
3137
|
const projectSlug = config.project.value;
|
|
3028
|
-
|
|
3138
|
+
return projectSlug !== null
|
|
3029
3139
|
? {
|
|
3030
3140
|
id: 'project',
|
|
3031
3141
|
title: 'Project identity',
|
|
@@ -3045,7 +3155,73 @@ export async function assessReadiness(deps, options = {}) {
|
|
|
3045
3155
|
summary: 'name this project after its directory',
|
|
3046
3156
|
command: 'notifai init',
|
|
3047
3157
|
},
|
|
3048
|
-
}
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
function remoteStatesFrom(previous) {
|
|
3161
|
+
const pick = (id) => previous.states.find((state) => state.id === id);
|
|
3162
|
+
const credential = pick('credential');
|
|
3163
|
+
const server = pick('server');
|
|
3164
|
+
const contract = pick('contract');
|
|
3165
|
+
const auth = pick('auth');
|
|
3166
|
+
const devices = pick('devices');
|
|
3167
|
+
const proof = pick('proof');
|
|
3168
|
+
if (credential === undefined ||
|
|
3169
|
+
server === undefined ||
|
|
3170
|
+
contract === undefined ||
|
|
3171
|
+
auth === undefined ||
|
|
3172
|
+
devices === undefined ||
|
|
3173
|
+
proof === undefined) {
|
|
3174
|
+
return null;
|
|
3175
|
+
}
|
|
3176
|
+
return { credential, server, contract, auth, devices, proof };
|
|
3177
|
+
}
|
|
3178
|
+
function remoteInvalidatedByConfig(previous, config) {
|
|
3179
|
+
if (config.base_url.source === 'default')
|
|
3180
|
+
return false;
|
|
3181
|
+
const server = previous.states.find((state) => state.id === 'server');
|
|
3182
|
+
return server === undefined || !server.detail.includes(config.base_url.value);
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Read the whole setup once, in dependency order.
|
|
3186
|
+
*
|
|
3187
|
+
* Descent stops where a prerequisite is missing: without a credential there is
|
|
3188
|
+
* nothing to ask the server with, and without a reachable server a contract
|
|
3189
|
+
* mismatch is unknowable rather than absent. Those downstream states report
|
|
3190
|
+
* `unknown`, which is the honest answer and keeps a network outage from
|
|
3191
|
+
* looking like a broken install.
|
|
3192
|
+
*/
|
|
3193
|
+
export async function assessReadiness(deps, options = {}) {
|
|
3194
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3195
|
+
const previous = options.previous;
|
|
3196
|
+
const refresh = options.refresh;
|
|
3197
|
+
if (previous !== undefined && refresh !== undefined && refresh.length === 0)
|
|
3198
|
+
return previous;
|
|
3199
|
+
const reuseRemote = previous !== undefined &&
|
|
3200
|
+
refresh !== undefined &&
|
|
3201
|
+
!refresh.includes('remote') &&
|
|
3202
|
+
!remoteInvalidatedByConfig(previous, config);
|
|
3203
|
+
if (reuseRemote) {
|
|
3204
|
+
const reused = remoteStatesFrom(previous);
|
|
3205
|
+
if (reused !== null) {
|
|
3206
|
+
return {
|
|
3207
|
+
states: [
|
|
3208
|
+
projectReadiness(deps, config),
|
|
3209
|
+
reused.credential,
|
|
3210
|
+
reused.server,
|
|
3211
|
+
reused.contract,
|
|
3212
|
+
reused.auth,
|
|
3213
|
+
...hookStates(deps),
|
|
3214
|
+
await skillReadiness(deps, options.skillScope),
|
|
3215
|
+
reused.devices,
|
|
3216
|
+
reused.proof,
|
|
3217
|
+
],
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
const states = [];
|
|
3222
|
+
let accountClient = null;
|
|
3223
|
+
let accountDevices = null;
|
|
3224
|
+
states.push(projectReadiness(deps, config));
|
|
3049
3225
|
const credential = deps.store.load();
|
|
3050
3226
|
states.push(credential
|
|
3051
3227
|
? {
|
|
@@ -3084,10 +3260,10 @@ export async function assessReadiness(deps, options = {}) {
|
|
|
3084
3260
|
id: 'server',
|
|
3085
3261
|
title: 'Service',
|
|
3086
3262
|
status: 'gap',
|
|
3087
|
-
detail: `cannot reach ${baseUrl}
|
|
3263
|
+
detail: `cannot reach ${baseUrl}`,
|
|
3088
3264
|
remedy: {
|
|
3089
3265
|
by: 'user-here',
|
|
3090
|
-
summary: 'check your network
|
|
3266
|
+
summary: 'check your network',
|
|
3091
3267
|
command: 'notifai doctor',
|
|
3092
3268
|
},
|
|
3093
3269
|
});
|
|
@@ -3293,8 +3469,8 @@ async function setupProofState(deps, config, client, devices) {
|
|
|
3293
3469
|
};
|
|
3294
3470
|
}
|
|
3295
3471
|
}
|
|
3296
|
-
export async function doctorCommand(deps, flags) {
|
|
3297
|
-
const readiness = await assessReadiness(deps);
|
|
3472
|
+
export async function doctorCommand(deps, flags, options = {}) {
|
|
3473
|
+
const readiness = options.readiness ?? (await assessReadiness(deps));
|
|
3298
3474
|
const blocker = firstBlocker(readiness);
|
|
3299
3475
|
const ok = blocker === null;
|
|
3300
3476
|
if (flags.json) {
|
|
@@ -3511,6 +3687,20 @@ function hookChecks(deps) {
|
|
|
3511
3687
|
.map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
|
|
3512
3688
|
.join(', '),
|
|
3513
3689
|
});
|
|
3690
|
+
const wired = new Set(installations.map((installation) => installation.harness));
|
|
3691
|
+
const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness));
|
|
3692
|
+
if (unwired.length > 0) {
|
|
3693
|
+
checks.push({
|
|
3694
|
+
name: 'hooks (detected)',
|
|
3695
|
+
ok: false,
|
|
3696
|
+
informational: true,
|
|
3697
|
+
detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
|
|
3698
|
+
remedy: {
|
|
3699
|
+
summary: 'install hooks for every detected harness',
|
|
3700
|
+
command: 'notifai hooks install',
|
|
3701
|
+
},
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3514
3704
|
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
|
|
3515
3705
|
const activeInstallations = active === null
|
|
3516
3706
|
? []
|
|
@@ -3621,7 +3811,8 @@ function hookChecks(deps) {
|
|
|
3621
3811
|
harness,
|
|
3622
3812
|
installations: installations.filter((i) => i.harness === harness),
|
|
3623
3813
|
}))
|
|
3624
|
-
.filter((entry) => entry.installations.
|
|
3814
|
+
.filter((entry) => entry.installations.some((installation) => installation.global) &&
|
|
3815
|
+
entry.installations.some((installation) => !installation.global));
|
|
3625
3816
|
if (duplicated.length > 0) {
|
|
3626
3817
|
checks.push({
|
|
3627
3818
|
name: 'hooks (duplicates)',
|
|
@@ -3631,6 +3822,25 @@ function hookChecks(deps) {
|
|
|
3631
3822
|
.join('; '),
|
|
3632
3823
|
});
|
|
3633
3824
|
}
|
|
3825
|
+
const representationProblems = codexRepresentationProblems(deps.cwd, deps.env);
|
|
3826
|
+
if (representationProblems.length > 0) {
|
|
3827
|
+
const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
|
|
3828
|
+
? 'notifai hooks install --harness codex --global'
|
|
3829
|
+
: 'notifai hooks install --harness codex';
|
|
3830
|
+
checks.push({
|
|
3831
|
+
name: 'hooks (codex representation)',
|
|
3832
|
+
ok: false,
|
|
3833
|
+
detail: representationProblems.join('; '),
|
|
3834
|
+
...(representationProblems.some((problem) => problem.includes('to collapse onto'))
|
|
3835
|
+
? {
|
|
3836
|
+
remedy: {
|
|
3837
|
+
summary: 'collapse this Codex layer onto a single hook file',
|
|
3838
|
+
command: collapseCommand,
|
|
3839
|
+
},
|
|
3840
|
+
}
|
|
3841
|
+
: {}),
|
|
3842
|
+
});
|
|
3843
|
+
}
|
|
3634
3844
|
if (active !== null && activeInstallations.length > 0) {
|
|
3635
3845
|
const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
3636
3846
|
checks.push({
|
|
@@ -3795,15 +4005,20 @@ function codexStrayWorktreeCheck(deps) {
|
|
|
3795
4005
|
if (layer === null)
|
|
3796
4006
|
return null;
|
|
3797
4007
|
const root = codexProjectRoot(deps.cwd);
|
|
3798
|
-
const
|
|
3799
|
-
if (
|
|
4008
|
+
const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env));
|
|
4009
|
+
if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0)
|
|
3800
4010
|
return null;
|
|
4011
|
+
const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json');
|
|
4012
|
+
const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml');
|
|
3801
4013
|
const problems = [];
|
|
3802
4014
|
if (!existsSync(layer)) {
|
|
3803
4015
|
problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
|
|
3804
4016
|
}
|
|
3805
|
-
if (existsSync(
|
|
3806
|
-
problems.push(`${
|
|
4017
|
+
if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
|
|
4018
|
+
problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`);
|
|
4019
|
+
}
|
|
4020
|
+
if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
|
|
4021
|
+
problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`);
|
|
3807
4022
|
}
|
|
3808
4023
|
return {
|
|
3809
4024
|
name: 'hooks (codex worktree)',
|
|
@@ -3863,6 +4078,18 @@ export function realIo(env = process.env) {
|
|
|
3863
4078
|
const answer = await p.select({ message, options });
|
|
3864
4079
|
return p.isCancel(answer) ? null : answer;
|
|
3865
4080
|
},
|
|
4081
|
+
multiselect: async (message, options, initial) => {
|
|
4082
|
+
if (!interactive())
|
|
4083
|
+
return null;
|
|
4084
|
+
const p = await clack();
|
|
4085
|
+
const answer = await p.multiselect({
|
|
4086
|
+
message,
|
|
4087
|
+
options,
|
|
4088
|
+
required: true,
|
|
4089
|
+
...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
|
|
4090
|
+
});
|
|
4091
|
+
return p.isCancel(answer) ? null : answer;
|
|
4092
|
+
},
|
|
3866
4093
|
intro: async (title) => {
|
|
3867
4094
|
if (!interactive())
|
|
3868
4095
|
return;
|