// configurable, identity-scoped hint registry for the rnx CLI. // // two problems this solves: // 1. the CLI scatters "hint: ..." console.logs across many command files, // each with slightly different wording. // 2. agents that loop over the same command (e.g. describe, find) see the // same hint every invocation, which becomes noise. // // each hint declares its own frequency (always, once-per-identity, or a // cooldown), so hints that are informational-but-stale-quickly can still // re-fire while advice-like hints stay suppressed for the rest of the // CLI identity. // // suppression is keyed on `getCliIdentityKey()` so two agents / two terminals // don't share shown-sets. import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { dirname, join } from 'path' import { getCliIdentityKey } from './current-sim' export type HintFrequency = | 'always' | 'once-per-identity' | 'once-ever' | { cooldownMs: number } export interface HintSpec { frequency: HintFrequency // returned string(s) are printed as-is, prefixed with two spaces. // return null to skip (useful when render wants to decide conditionally). render: (...args: Args) => string | string[] | null } const registry = new Map>() export function defineHint(id: string, spec: HintSpec) { registry.set(id, spec as HintSpec) } interface HintStateFile { version: 1 shown: Record // id -> last-shown timestamp (ms) } const STATE_VERSION = 1 as const function identityStatePath(): string { return join(tmpdir(), `rnx-cli-hints-${getCliIdentityKey()}.json`) } function globalStatePath(): string { return join(tmpdir(), `rnx-cli-hints-global.json`) } function readState(path: string): HintStateFile { if (!existsSync(path)) return { version: STATE_VERSION, shown: {} } try { const parsed = JSON.parse(readFileSync(path, 'utf8')) if (parsed?.version !== STATE_VERSION || !parsed?.shown) { return { version: STATE_VERSION, shown: {} } } return parsed as HintStateFile } catch { return { version: STATE_VERSION, shown: {} } } } function writeState(path: string, state: HintStateFile) { try { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, JSON.stringify(state) + '\n') } catch { // best-effort only; losing shown state just means a hint re-fires once. } } function modeFromEnv(): 'normal' | 'always' | 'off' { const v = (process.env.RNX_HINTS || '').toLowerCase() if (v === 'off' || v === '0' || v === 'false') return 'off' if (v === 'always' || v === 'verbose') return 'always' return 'normal' } function shouldShow(id: string, frequency: HintFrequency): boolean { const mode = modeFromEnv() if (mode === 'off') return false if (mode === 'always') return true if (frequency === 'always') return true const now = Date.now() if (frequency === 'once-per-identity') { const state = readState(identityStatePath()) if (state.shown[id]) return false state.shown[id] = now writeState(identityStatePath(), state) return true } if (frequency === 'once-ever') { const state = readState(globalStatePath()) if (state.shown[id]) return false state.shown[id] = now writeState(globalStatePath(), state) return true } if (typeof frequency === 'object' && 'cooldownMs' in frequency) { const state = readState(identityStatePath()) const last = state.shown[id] ?? 0 if (now - last < frequency.cooldownMs) return false state.shown[id] = now writeState(identityStatePath(), state) return true } return true } // print a registered hint. no-ops if suppressed for this identity/cooldown. // returns whether it was actually shown. export function maybeHint(id: string, ...args: unknown[]): boolean { const spec = registry.get(id) if (!spec) { if (process.env.RNX_HINTS_DEBUG) { console.error(` [hints] no hint registered for id "${id}"`) } return false } if (!shouldShow(id, spec.frequency)) return false const body = spec.render(...args) if (body == null) return false const lines = Array.isArray(body) ? body : [body] // always stderr. every hint is advice about how to drive the CLI, never part // of a command's result, and several commands (`describe`, `find`, every // `do` verb) put a JSON document on stdout — a hint printed there corrupts it. for (const line of lines) console.error(` hint: ${line}`) return true } export function resetHintsForIdentity() { rmSync(identityStatePath(), { force: true }) } export function resetGlobalHints() { rmSync(globalStatePath(), { force: true }) } export function listHints(): Array<{ id: string; frequency: HintFrequency }> { return Array.from(registry.entries()).map(([id, spec]) => ({ id, frequency: spec.frequency, })) } // register the repo-wide hints here. keeping them centralised makes it easy // to audit wording + frequency policy in one place. defineHint<[number]>('app-still-loading', { // re-fires once a minute because node count may genuinely change as the // bundle boots — telling the user about it again is useful, not noisy. frequency: { cooldownMs: 60_000 }, render: (nodeCount) => `app may still be loading (${nodeCount} nodes). run \`rnx wait ready\` first.`, }) defineHint<[string]>('wait-selector-for-missing-testid', { // advice is stable; once per CLI identity is enough. frequency: 'once-per-identity', render: (testId) => `rnx wait selector ${testId}`, }) defineHint<[string[]]>('prefer-cli-over-eval', { frequency: 'once-per-identity', render: (suggestions) => { if (!suggestions.length) return null return [`try the CLI shortcut instead:`, ...suggestions.map((s) => ` ${s}`)] }, }) defineHint<[]>('describe-use-filters', { // shown when a describe run emits >=80 lines without any filter active — // gentle nudge that there are better tools. frequency: 'once-per-identity', render: () => [ `describe output is long. narrow it with:`, ` rnx describe --only '*Bottom Sheet*'`, ` rnx describe --testid-like 'swap-*'`, ` rnx describe --subtree `, ], }) defineHint<[]>('describe-filter-context', { // filtered describe is useful search output, but it intentionally drops // surrounding tree context. once per identity is enough to prevent agents // treating a search hit as route proof. frequency: 'once-per-identity', render: () => [ `filtered describe is search output, not current-route proof.`, `run \`rnx describe\` without filters after navigation; clipped nodes are marked \`(clipped:...)\`.`, ], }) defineHint<[string]>('subtree-root-not-found', { frequency: 'always', render: (id) => `no node with testID/id "${id}" — try \`rnx find --testid ${id}\` to discover available ids.`, }) defineHint<[]>('clean-uninstall', { // shown once on the first `rnx --help` run so users learn the teardown path. frequency: 'once-ever', render: () => [ `to remove the background daemon, run \`rnx daemon uninstall\`. it keeps your runtimes and device profiles; add \`--purge\` to delete ~/.rnx too.`, `the optional desktop app and its macOS preference plist are separate from the daemon.`, ], })