@raidiant/notifai 0.2.1 → 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.
@@ -1,7 +1,8 @@
1
- import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView } from '@raidiant/notifai-protocol';
1
+ import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView, type RoutableDevice } from '@raidiant/notifai-protocol';
2
2
  import { type ApiClient, type ClientOptions } from './client.js';
3
3
  import type { CredentialStore } from './credentials.js';
4
4
  import { type Readiness } from './readiness.js';
5
+ import type { Tone } from './ui/theme.js';
5
6
  import { type HookHarness } from './hooks.js';
6
7
  import { type SendFlags } from './send.js';
7
8
  import type { NativeSkills, SkillScope } from './native-skills.js';
@@ -28,7 +29,13 @@ export interface CommandIo {
28
29
  outro?(message: string): Promise<void>;
29
30
  note?(message: string, title?: string): Promise<void>;
30
31
  spinner?(message: string): Promise<CommandSpinner | null>;
31
- check?(ok: boolean, message: string): Promise<void>;
32
+ /**
33
+ * A report line. `tone` distinguishes the two states that are neither pass
34
+ * nor fail — something optional the user declined, and something that was
35
+ * never evaluated — which a boolean has to round to one or the other.
36
+ * Optional so existing implementations keep type-checking.
37
+ */
38
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>;
32
39
  }
33
40
  export interface CommandSpinner {
34
41
  message(message: string): void;
@@ -75,6 +82,18 @@ export declare function accessStatusCommand(deps: CommandDeps, flags: {
75
82
  export declare function devicesCommand(deps: CommandDeps, flags: {
76
83
  json?: boolean;
77
84
  }): Promise<number>;
85
+ /**
86
+ * Devices as data, for surfaces that render them themselves.
87
+ *
88
+ * `devicesCommand` prints and returns an exit code, which is the right shape
89
+ * for a command and the wrong one for a menu that wants to show names beside
90
+ * checkboxes. Silent on every failure by design: the interactive app already
91
+ * shows the credential and connectivity state on its status card, so a second
92
+ * error line here would be reporting the same fault twice.
93
+ */
94
+ export declare function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null>;
95
+ /** Whether a device is in a state where a notification would actually arrive. */
96
+ export declare function canDeviceReceive(device: RoutableDevice): boolean;
78
97
  export declare function capabilitiesCommand(deps: CommandDeps, flags: {
79
98
  json?: boolean;
80
99
  platform?: Platform;
@@ -197,6 +216,17 @@ export declare function hooksUninstallCommand(deps: CommandDeps, flags: HooksIns
197
216
  export declare function configShowCommand(deps: CommandDeps, flags: {
198
217
  json?: boolean;
199
218
  explain?: boolean;
219
+ plain?: boolean;
220
+ }): number;
221
+ /**
222
+ * One setting, explained in full.
223
+ *
224
+ * The gap this closes: `config show` prints `require_idle = true` and there was
225
+ * nowhere to go from there. Every key already had a careful explanation — in a
226
+ * TypeScript comment, read by everyone except the person who needed it.
227
+ */
228
+ export declare function configExplainCommand(deps: CommandDeps, key: string, flags?: {
229
+ json?: boolean;
200
230
  }): number;
201
231
  export declare function configSetCommand(deps: CommandDeps, key: string, rawValue: string, flags: {
202
232
  project?: boolean;
@@ -209,7 +239,7 @@ export declare function configSetCommand(deps: CommandDeps, key: string, rawValu
209
239
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
210
240
  * Keep this immutable and public because the command is printed to users.
211
241
  */
212
- export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.1";
242
+ export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.3.0";
213
243
  /** Derive a contract-valid project slug from a directory name. */
214
244
  export declare function projectSlugFrom(name: string): string;
215
245
  export interface InitFlags {
package/dist/commands.js CHANGED
@@ -8,7 +8,9 @@ import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_M
8
8
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
9
  import { ApiCallError, NetworkError, createClient, } from './client.js';
10
10
  import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
11
- import { firstBlocker, openItems } from './readiness.js';
11
+ import { acceptedValues, configInfo } from './config-schema.js';
12
+ import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
13
+ import { firstBlocker, openItems, } from './readiness.js';
12
14
  import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
13
15
  import { readIdleSeconds } from './idle.js';
14
16
  import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
@@ -237,6 +239,33 @@ export async function devicesCommand(deps, flags) {
237
239
  return reportError(deps, err);
238
240
  }
239
241
  }
242
+ /**
243
+ * Devices as data, for surfaces that render them themselves.
244
+ *
245
+ * `devicesCommand` prints and returns an exit code, which is the right shape
246
+ * for a command and the wrong one for a menu that wants to show names beside
247
+ * checkboxes. Silent on every failure by design: the interactive app already
248
+ * shows the credential and connectivity state on its status card, so a second
249
+ * error line here would be reporting the same fault twice.
250
+ */
251
+ export async function deviceInventory(deps) {
252
+ if (!deps.store.load())
253
+ return null;
254
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
255
+ const authed = authedClient(deps, config);
256
+ if (!authed)
257
+ return null;
258
+ try {
259
+ return (await authed.client.listDevices()).devices;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /** Whether a device is in a state where a notification would actually arrive. */
266
+ export function canDeviceReceive(device) {
267
+ return deviceCanReceive(device);
268
+ }
240
269
  export async function capabilitiesCommand(deps, flags) {
241
270
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
242
271
  const credential = deps.store.load();
@@ -1400,39 +1429,133 @@ export function configShowCommand(deps, flags) {
1400
1429
  deps.io.out(JSON.stringify(output, null, 2));
1401
1430
  return EXIT.ok;
1402
1431
  }
1403
- for (const key of CONFIG_KEYS) {
1404
- const entry = config[key];
1405
- const provenance = flags.explain ? ` [${entry.source}]` : '';
1406
- deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`);
1432
+ // Anything that is not a person at a terminal keeps the flat `key = value`
1433
+ // form it has always had. Scripts parse this, and a prettier layout for an
1434
+ // audience that cannot see it would only be a breaking change.
1435
+ if (deps.io.interactive !== true || flags.plain === true) {
1436
+ for (const line of renderConfigPlain(config, flags.explain === true))
1437
+ deps.io.out(line);
1438
+ return EXIT.ok;
1439
+ }
1440
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
1441
+ deps.io.out(line);
1442
+ }
1443
+ return EXIT.ok;
1444
+ }
1445
+ /**
1446
+ * One setting, explained in full.
1447
+ *
1448
+ * The gap this closes: `config show` prints `require_idle = true` and there was
1449
+ * nowhere to go from there. Every key already had a careful explanation — in a
1450
+ * TypeScript comment, read by everyone except the person who needed it.
1451
+ */
1452
+ export function configExplainCommand(deps, key, flags = {}) {
1453
+ if (!CONFIG_KEYS.includes(key)) {
1454
+ deps.io.err(`Unknown setting "${key}".`);
1455
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1456
+ return EXIT.usage;
1457
+ }
1458
+ const configKey = key;
1459
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1460
+ const info = configInfo(configKey);
1461
+ const entry = config[configKey];
1462
+ if (flags.json) {
1463
+ deps.io.out(JSON.stringify({
1464
+ key: configKey,
1465
+ label: info.label,
1466
+ group: info.group,
1467
+ kind: info.kind,
1468
+ summary: info.summary,
1469
+ detail: info.detail,
1470
+ accepts: acceptedValues(configKey),
1471
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
1472
+ value: entry.value,
1473
+ source: entry.source,
1474
+ }, null, 2));
1475
+ return EXIT.ok;
1476
+ }
1477
+ if (deps.io.interactive !== true) {
1478
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`);
1479
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'));
1480
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`);
1481
+ return EXIT.ok;
1407
1482
  }
1483
+ for (const line of renderConfigExplain(configKey, config))
1484
+ deps.io.out(line);
1408
1485
  return EXIT.ok;
1409
1486
  }
1487
+ /**
1488
+ * Closest config key by edit distance, or null when nothing is close.
1489
+ *
1490
+ * The threshold matters more than the algorithm: suggesting a key that shares
1491
+ * three letters with the typo sends the reader to the wrong setting with
1492
+ * confidence, which is worse than listing all fourteen and letting them look.
1493
+ */
1494
+ function nearestKey(input) {
1495
+ let best = null;
1496
+ let bestDistance = Number.POSITIVE_INFINITY;
1497
+ for (const candidate of CONFIG_KEYS) {
1498
+ const distance = editDistance(input.toLowerCase(), candidate);
1499
+ if (distance < bestDistance) {
1500
+ bestDistance = distance;
1501
+ best = candidate;
1502
+ }
1503
+ }
1504
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
1505
+ }
1506
+ function editDistance(a, b) {
1507
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
1508
+ for (let i = 1; i <= a.length; i += 1) {
1509
+ let diagonal = previous[0];
1510
+ previous[0] = i;
1511
+ for (let j = 1; j <= b.length; j += 1) {
1512
+ const above = previous[j];
1513
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
1514
+ diagonal = above;
1515
+ }
1516
+ }
1517
+ return previous[b.length];
1518
+ }
1410
1519
  export async function configSetCommand(deps, key, rawValue, flags) {
1411
1520
  if (!CONFIG_KEYS.includes(key)) {
1412
- deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
1521
+ deps.io.err(`Unknown setting "${key}".`);
1522
+ const near = nearestKey(key);
1523
+ if (near !== null)
1524
+ deps.io.err(`Did you mean "${near}"?`);
1525
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1526
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
1413
1527
  return EXIT.usage;
1414
1528
  }
1529
+ const configKey = key;
1530
+ const info = configInfo(configKey);
1415
1531
  let value = rawValue;
1416
- if (NUMERIC_CONFIG_KEYS.includes(key)) {
1532
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
1417
1533
  const numeric = Number(rawValue);
1418
1534
  if (!Number.isInteger(numeric)) {
1419
- deps.io.err(`"${rawValue}" is not an integer.`);
1535
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`);
1420
1536
  return EXIT.usage;
1421
1537
  }
1422
- const bounds = configBounds(key);
1538
+ const bounds = configBounds(configKey);
1423
1539
  if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1424
1540
  deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
1425
1541
  return EXIT.usage;
1426
1542
  }
1427
1543
  value = numeric;
1428
1544
  }
1429
- if (BOOLEAN_CONFIG_KEYS.includes(key)) {
1545
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
1430
1546
  if (rawValue !== 'true' && rawValue !== 'false') {
1431
1547
  deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
1432
1548
  return EXIT.usage;
1433
1549
  }
1434
1550
  value = rawValue === 'true';
1435
1551
  }
1552
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
1553
+ // value the sender would later reject — a typo that only surfaces at the
1554
+ // moment a notification fails to carry the sound you asked for.
1555
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
1556
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`);
1557
+ return EXIT.usage;
1558
+ }
1436
1559
  if (key === 'devices')
1437
1560
  value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
1438
1561
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
@@ -1484,7 +1607,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1484
1607
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1485
1608
  * Keep this immutable and public because the command is printed to users.
1486
1609
  */
1487
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1';
1610
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0';
1488
1611
  function skillSourceParts() {
1489
1612
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1490
1613
  return match === null ? null : { source: match[1], ref: match[2] };
@@ -2449,8 +2572,13 @@ export async function doctorCommand(deps, flags) {
2449
2572
  // costs nothing.
2450
2573
  if (deps.io.interactive === true && deps.io.check) {
2451
2574
  await deps.io.intro?.('Notifai doctor');
2452
- for (const s of readiness.states)
2453
- await deps.io.check(s.status !== 'gap', line(s));
2575
+ for (const s of readiness.states) {
2576
+ // Four states, not two. Rendering `optional-gap` and `unknown` as
2577
+ // successes made a green report that had never checked half of what it
2578
+ // listed, and put a tick beside things the reader had deliberately
2579
+ // declined.
2580
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status));
2581
+ }
2454
2582
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
2455
2583
  }
2456
2584
  else {
@@ -2469,6 +2597,19 @@ export async function doctorCommand(deps, flags) {
2469
2597
  }
2470
2598
  return ok ? EXIT.ok : EXIT.failed;
2471
2599
  }
2600
+ /** Readiness status as a report tone. */
2601
+ function doctorTone(status) {
2602
+ switch (status) {
2603
+ case 'ready':
2604
+ return 'ok';
2605
+ case 'gap':
2606
+ return 'bad';
2607
+ case 'optional-gap':
2608
+ return 'warn';
2609
+ case 'unknown':
2610
+ return 'pending';
2611
+ }
2612
+ }
2472
2613
  /** One line telling the reader what to actually do about a state. */
2473
2614
  function remedyLine(state) {
2474
2615
  const remedy = state.remedy;
@@ -2853,14 +2994,22 @@ export function realIo(env = process.env) {
2853
2994
  error: (next) => progress.error(next),
2854
2995
  };
2855
2996
  },
2856
- check: async (ok, message) => {
2997
+ check: async (ok, message, tone) => {
2857
2998
  if (!interactive())
2858
2999
  return;
2859
3000
  const { log } = await clack();
2860
- if (ok)
2861
- log.success(message);
2862
- else
2863
- log.error(message);
3001
+ switch (tone ?? (ok ? 'ok' : 'bad')) {
3002
+ case 'ok':
3003
+ return log.success(message);
3004
+ case 'warn':
3005
+ return log.warn(message);
3006
+ case 'pending':
3007
+ // Never checked. `log.info` reads as neutral, which is the honest
3008
+ // rendering for a state no evidence was gathered about.
3009
+ return log.info(message);
3010
+ case 'bad':
3011
+ return log.error(message);
3012
+ }
2864
3013
  },
2865
3014
  openUrl: (url) => {
2866
3015
  try {