diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b8a58f4 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# HypAware — Context & Glossary + +This file is a glossary of the domain language used in HypAware. It is not a +spec or a design doc — it defines terms so that code, docs, and conversation +use the same words to mean the same things. + +## Glossary + +### Source + +A thing HypAware can capture signals from. In the first-run wizard the +user-facing sources are `claude`, `codex`, `raw-anthropic`, `raw-openai`, and +`otel`. Sources divide into two kinds: + +- **Client source** — a known tool HypAware configures for you. `claude` and + `codex` are the client sources. Picking one adds its gateway upstream *and* + its adapter plugin (`@hypaware/claude` / `@hypaware/codex`), which attaches + the tool (rewrites its base URL), installs hooks/skills, and can backfill + its local history. Client sources are the only sources that can be + [[autodetect]]ed. +- **Raw proxy source** — `raw-anthropic` / `raw-openai`. Picking one opens the + gateway with that provider upstream but configures no client; the user + points their own SDK app or script at the local gateway by hand. Serves the + "observe my own AI app" persona. Not autodetectable — there is no installed + tool to find. + +`otel` is a third shape: a local OTLP receiver for apps that export +OpenTelemetry signals. Like a raw proxy source, it is manual and not +autodetectable. + +### Autodetect + +The first-run wizard inspecting the system for the presence of a **client +source** and pre-selecting (checking) it by default in the picker, while +leaving the user free to uncheck it. Only client sources (`claude`, `codex`) +are autodetected; raw proxy sources and `otel` are never autodetected because +there is no installed tool to find. + +Autodetect sets only the *initial* checkbox state. It never forces a source +on, never hides one, and an undetected source can still be checked by hand. + +Distinct from a [[default]]: autodetect is derived from system state; a default +is a fixed starting choice that holds regardless of what is on the system. + +### Default + +A fixed starting selection in the wizard that is not derived from system +state. The export choice defaults to `local-parquet` (pre-checked) and +retention defaults to 30 days. Defaults hold whether or not any source is +detected, and the user can change them. Contrast [[autodetect]], which is +driven by what is actually present on the system. diff --git a/src/core/cli/detect.js b/src/core/cli/detect.js new file mode 100644 index 0000000..8ccc4f0 --- /dev/null +++ b/src/core/cli/detect.js @@ -0,0 +1,70 @@ +// @ts-check + +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { resolveClientSettingsPath } from '../daemon/client_settings_path.js' + +/** + * @import { PickerSource } from './types.d.ts' + */ + +/** + * The client sources the first-run picker can autodetect, paired with + * the `settings_file` their plugin manifest declares for the attach + * probe. Detection stats the *directory* that holds this file (the + * client's config home) — the "tool is installed on this system" + * signal — not the file itself, so HypAware writing the settings file + * on attach never makes a source detect itself. + * + * This list is intentionally hardcoded rather than read from + * `catalog.clientDescriptors[*].attach_probe`. At first `npx hypaware` + * run only bundled plugins exist, so there is no third-party client + * plugin to discover, and the picker's source list (`PICKER_SOURCES`) + * is itself hardcoded. If the picker is ever made plugin-driven, move + * detection to iterate the client descriptors and read each + * `attach_probe.settings_file` (see `probeClientAttachFromDescriptor` + * in daemon/status.js) in that same change — not before. + * + * @type {{ source: PickerSource, client: string, settingsFile: string }[]} + */ +const DETECTABLE_CLIENT_SOURCES = [ + { source: 'claude', client: 'claude', settingsFile: '.claude/settings.json' }, + { source: 'codex', client: 'codex', settingsFile: '.codex/config.toml' }, +] + +/** + * Inspect the system for installed client tools and return the set of + * picker sources that are present. A source is "present" when the + * config-home directory of its client exists (`~/.claude`, and + * `$CODEX_HOME` ?? `~/.codex`). Honors `$CLAUDE_HOME`/`$CODEX_HOME` via + * the shared {@link resolveClientSettingsPath}. + * + * Best-effort: any stat outcome other than "directory exists" is + * treated as not-present, so detection never blocks or throws the + * walkthrough. The result only seeds the picker's initial checkbox + * state; the user can still toggle every box. + * + * @param {{ env: NodeJS.ProcessEnv }} opts + * @returns {Promise>} + */ +export async function detectClientSources(opts) { + const env = opts.env + const homeDir = env.HOME ?? os.homedir() + /** @type {Set} */ + const detected = new Set() + await Promise.all( + DETECTABLE_CLIENT_SOURCES.map(async ({ source, client, settingsFile }) => { + const settingsPath = resolveClientSettingsPath(client, settingsFile, env, homeDir) + const configHome = path.dirname(settingsPath) + try { + const stat = await fsp.stat(configHome) + if (stat.isDirectory()) detected.add(source) + } catch { + // ENOENT (or any stat failure) → tool not present; leave unset. + } + }) + ) + return detected +} diff --git a/src/core/cli/types.d.ts b/src/core/cli/types.d.ts index 0781cb5..b322e69 100644 --- a/src/core/cli/types.d.ts +++ b/src/core/cli/types.d.ts @@ -27,6 +27,12 @@ export interface WalkthroughOption { label: string summary?: string plugin?: string + /** + * Initial checkbox state in the TUI multiselect. Used by the picker to + * pre-select autodetected sources and the default export. Ignored by + * the legacy numbered prompt, which has no preselection concept. + */ + checked?: boolean } export interface WalkthroughQuestion { @@ -90,6 +96,13 @@ export interface RunPickerWalkthroughOptions { picks?: PickerPicks prompt?: AsyncPickPrompt retentionPrompt?: AsyncRetentionPrompt + /** + * Override the system source detector. Defaults to the real + * filesystem-based {@link detectClientSources}. Only consulted in + * interactive mode (no pre-baked `picks`); tests inject a stub so the + * picker's preselected boxes do not depend on the dev's home dir. + */ + detect?: (opts: { env: NodeJS.ProcessEnv }) => Promise> /** * Interactive consent prompt for the onboarding backfill step. Only * consulted in interactive mode (no pre-baked `picks`); non-interactive diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index 947e430..7a6fd46 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -10,6 +10,7 @@ import { readObservabilityEnv } from '../observability/env.js' import { discoverBundledPlugins } from '../runtime/bundled.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { ensureDurableBinForNpx } from './global_install.js' +import { detectClientSources } from './detect.js' import { multiselect, text, confirm } from './tui/index.js' import { isPromptCancelledError } from './tui/runtime.js' import { shouldUseTui } from './tui-router.js' @@ -422,6 +423,7 @@ function tuiPromptFactory(opts) { value: o.value, label: o.label, ...(o.summary && o.summary !== o.label ? { summary: o.summary } : {}), + ...(o.checked ? { checked: true } : {}), })), ...(question.bounds ? { bounds: question.bounds } : {}), stdin: opts.stdin ?? process.stdin, @@ -631,6 +633,23 @@ export async function runPickerWalkthrough(opts) { const { capabilities, stdout, env } = opts const log = getLogger('walkthrough') + // Autodetect installed client tools so the picker can pre-check them. + // Interactive only: when `picks` are supplied (`--yes` / `--dry-run` / + // presets) the selection is explicit and must stay deterministic, so + // detection is skipped entirely. Best-effort — a detector failure + // leaves the set empty rather than blocking onboarding. + const interactive = !opts.picks + /** @type {Set} */ + let detected = new Set() + if (interactive) { + const detect = opts.detect ?? detectClientSources + try { + detected = await detect({ env }) + } catch { + detected = new Set() + } + } + await withSpan( 'walkthrough.start', { @@ -638,6 +657,8 @@ export async function runPickerWalkthrough(opts) { [Attr.OPERATION]: 'walkthrough.start', sources_available: PICKER_SOURCES.length, exports_available: PICKER_EXPORTS.length, + sources_detected: detected.size, + detected_sources: [...detected].join(','), status: 'ok', }, async () => {}, @@ -658,7 +679,12 @@ export async function runPickerWalkthrough(opts) { const sourceRaw = await ask({ pickType: 'sources', title: 'What do you want to collect? (space to toggle, enter to confirm)', - options: PICKER_SOURCES.map((s) => ({ value: s.value, label: s.label, summary: s.summary })), + options: PICKER_SOURCES.map((s) => ({ + value: s.value, + label: detected.has(s.value) ? `${s.label} · detected` : s.label, + summary: s.summary, + ...(detected.has(s.value) ? { checked: true } : {}), + })), }) const sources = /** @type {PickerSource[]} */ ( sourceRaw.filter((v) => PICKER_SOURCES.some((s) => s.value === v)) @@ -667,7 +693,15 @@ export async function runPickerWalkthrough(opts) { const exportRaw = await ask({ pickType: 'sinks', title: 'Where should HypAware export captured data?', - options: PICKER_EXPORTS.map((e) => ({ value: e.value, label: e.label, summary: e.summary })), + options: PICKER_EXPORTS.map((e) => ({ + value: e.value, + label: e.label, + summary: e.summary, + // Default export: pre-check local Parquet so the interactive + // picker matches the documented `--yes` default (which also + // defaults to local-parquet). A plain default, not autodetect. + ...(e.value === 'local-parquet' ? { checked: true } : {}), + })), }) const exportChoice = /** @type {PickerExport} */ ( PICKER_EXPORTS.find((e) => exportRaw.includes(e.value))?.value ?? 'keep-local' diff --git a/src/core/daemon/client_settings_path.js b/src/core/daemon/client_settings_path.js new file mode 100644 index 0000000..9afa05c --- /dev/null +++ b/src/core/daemon/client_settings_path.js @@ -0,0 +1,29 @@ +// @ts-check + +import path from 'node:path' + +/** + * Resolve the absolute settings-file path for a client. The manifest + * `settings_file` is relative to `$HOME` (e.g. `.codex/config.toml`). + * Client-specific env overrides like `CODEX_HOME` replace the first + * directory component (`.codex` → `$CODEX_HOME`). + * + * Pure (path-only) so both the daemon status attach-probe and the + * first-run source detector can share it without pulling in either + * module's heavier import graph. + * + * @param {string} clientName + * @param {string} settingsFile + * @param {NodeJS.ProcessEnv | undefined} env + * @param {string} homeDir + * @returns {string} + */ +export function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { + const envKey = `${clientName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_HOME` + const override = env?.[envKey] + if (typeof override === 'string' && override.length > 0) { + const parts = settingsFile.split('/') + return path.join(override, ...parts.slice(1)) + } + return path.join(homeDir, ...settingsFile.split('/')) +} diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 51308f9..8da3a40 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -11,6 +11,7 @@ import { diagnoseV1Config, validateConfig } from '../config/validate.js' import { discoverInstalledPlugins } from '../runtime/installed.js' import { discoverBundledPlugins } from '../runtime/bundled.js' import { buildPluginCatalog } from '../plugin_catalog.js' +import { resolveClientSettingsPath } from './client_settings_path.js' import { defaultLogDir, platformIsSupported, @@ -573,27 +574,11 @@ export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env } } -/** - * Resolve the absolute settings-file path for a client. The manifest - * `settings_file` is relative to `$HOME` (e.g. `.codex/config.toml`). - * Client-specific env overrides like `CODEX_HOME` replace the first - * directory component (`.codex` → `$CODEX_HOME`). - * - * @param {string} clientName - * @param {string} settingsFile - * @param {NodeJS.ProcessEnv | undefined} env - * @param {string} homeDir - * @returns {string} - */ -export function resolveClientSettingsPath(clientName, settingsFile, env, homeDir) { - const envKey = `${clientName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_HOME` - const override = env?.[envKey] - if (typeof override === 'string' && override.length > 0) { - const parts = settingsFile.split('/') - return path.join(override, ...parts.slice(1)) - } - return path.join(homeDir, ...settingsFile.split('/')) -} +// `resolveClientSettingsPath` moved to ./client_settings_path.js so the +// first-run source detector can share it without importing this module's +// heavier graph. Imported above for internal use; re-exported here to keep +// existing import sites (`from './status.js'`) stable. +export { resolveClientSettingsPath } /** * Walk the recent telemetry directory and count log entries whose diff --git a/test/core/detect.test.js b/test/core/detect.test.js new file mode 100644 index 0000000..e8d6ef7 --- /dev/null +++ b/test/core/detect.test.js @@ -0,0 +1,75 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { detectClientSources } from '../../src/core/cli/detect.js' + +/** + * @returns {Promise} + */ +async function tmpHome() { + return fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-detect-')) +} + +test('detects claude when ~/.claude exists', async () => { + const home = await tmpHome() + await fs.mkdir(path.join(home, '.claude'), { recursive: true }) + + const detected = await detectClientSources({ env: { HOME: home } }) + + assert.equal(detected.has('claude'), true) + assert.equal(detected.has('codex'), false) +}) + +test('detects codex when ~/.codex exists', async () => { + const home = await tmpHome() + await fs.mkdir(path.join(home, '.codex'), { recursive: true }) + + const detected = await detectClientSources({ env: { HOME: home } }) + + assert.equal(detected.has('codex'), true) + assert.equal(detected.has('claude'), false) +}) + +test('detects both when both config homes exist', async () => { + const home = await tmpHome() + await fs.mkdir(path.join(home, '.claude'), { recursive: true }) + await fs.mkdir(path.join(home, '.codex'), { recursive: true }) + + const detected = await detectClientSources({ env: { HOME: home } }) + + assert.deepEqual([...detected].sort(), ['claude', 'codex']) +}) + +test('detects nothing in an empty home', async () => { + const home = await tmpHome() + + const detected = await detectClientSources({ env: { HOME: home } }) + + assert.equal(detected.size, 0) +}) + +test('honors $CODEX_HOME override for codex detection', async () => { + // HOME has no ~/.codex; the override points elsewhere and exists. + const home = await tmpHome() + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-codexhome-')) + + const detected = await detectClientSources({ env: { HOME: home, CODEX_HOME: codexHome } }) + + assert.equal(detected.has('codex'), true) + assert.equal(detected.has('claude'), false) +}) + +test('a plain file (not a directory) at the config-home path does not count', async () => { + const home = await tmpHome() + // Write `.claude` as a file rather than a directory. + await fs.writeFile(path.join(home, '.claude'), 'not a dir\n', 'utf8') + + const detected = await detectClientSources({ env: { HOME: home } }) + + assert.equal(detected.has('claude'), false) +}) diff --git a/test/core/walkthrough-detect.test.js b/test/core/walkthrough-detect.test.js new file mode 100644 index 0000000..5c0d3dd --- /dev/null +++ b/test/core/walkthrough-detect.test.js @@ -0,0 +1,107 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { runPickerWalkthrough } from '../../src/core/cli/walkthrough.js' + +function makeBuf() { + let s = '' + return { + write(/** @type {string} */ c) { s += c; return true }, + text() { return s }, + } +} + +async function tmpEnv() { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-wt-detect-')) + return { HOME: tmp, HYP_HOME: path.join(tmp, '.hyp') } +} + +test('picker pre-checks detected sources, labels them, and defaults export to local-parquet', async () => { + /** @type {import('../../src/core/cli/types.d.ts').WalkthroughQuestion[]} */ + const seen = [] + /** @type {import('../../src/core/cli/types.d.ts').AsyncPickPrompt} */ + const prompt = async (question) => { + seen.push(question) + // Confirm whatever is pre-checked, mirroring a user pressing enter. + return question.options.filter((o) => o.checked).map((o) => o.value) + } + + const result = await runPickerWalkthrough({ + capabilities: /** @type {any} */ ({}), + stdout: makeBuf(), + stderr: makeBuf(), + env: await tmpEnv(), + detect: async () => new Set(['claude']), + prompt, + retentionPrompt: async (_p, d) => d, + }) + + const sources = seen.find((q) => q.pickType === 'sources') + assert.ok(sources, 'sources question asked') + const claude = sources.options.find((o) => o.value === 'claude') + const codex = sources.options.find((o) => o.value === 'codex') + assert.equal(claude?.checked, true) + assert.match(claude?.label ?? '', /· detected$/) + assert.notEqual(codex?.checked, true) + assert.doesNotMatch(codex?.label ?? '', /detected/) + + const sinks = seen.find((q) => q.pickType === 'sinks') + assert.ok(sinks, 'export question asked') + const parquet = sinks.options.find((o) => o.value === 'local-parquet') + const keepLocal = sinks.options.find((o) => o.value === 'keep-local') + assert.equal(parquet?.checked, true) + assert.notEqual(keepLocal?.checked, true) + + // The confirmed picks reflect the preselected state. + assert.deepEqual(result.sourcesPicked, ['claude']) + assert.equal(result.exportPicked, 'local-parquet') +}) + +test('nothing detected → no source pre-checked, export still defaults to local-parquet', async () => { + /** @type {import('../../src/core/cli/types.d.ts').WalkthroughQuestion[]} */ + const seen = [] + /** @type {import('../../src/core/cli/types.d.ts').AsyncPickPrompt} */ + const prompt = async (question) => { + seen.push(question) + return question.options.filter((o) => o.checked).map((o) => o.value) + } + + const result = await runPickerWalkthrough({ + capabilities: /** @type {any} */ ({}), + stdout: makeBuf(), + stderr: makeBuf(), + env: await tmpEnv(), + detect: async () => new Set(), + prompt, + retentionPrompt: async (_p, d) => d, + }) + + const sources = seen.find((q) => q.pickType === 'sources') + assert.ok(sources) + assert.equal(sources.options.some((o) => o.checked), false) + assert.equal(sources.options.some((o) => /detected/.test(o.label)), false) + assert.deepEqual(result.sourcesPicked, []) + assert.equal(result.exportPicked, 'local-parquet') +}) + +test('non-interactive picks skip detection entirely', async () => { + let detectCalled = false + + const result = await runPickerWalkthrough({ + capabilities: /** @type {any} */ ({}), + stdout: makeBuf(), + stderr: makeBuf(), + env: await tmpEnv(), + picks: { sources: ['otel'], exportChoice: 'keep-local', retentionDays: 30 }, + detect: async () => { detectCalled = true; return new Set(['claude', 'codex']) }, + }) + + assert.equal(detectCalled, false, 'detector must not run when picks are supplied') + assert.deepEqual(result.sourcesPicked, ['otel']) + assert.equal(result.exportPicked, 'keep-local') +}) diff --git a/test/core/walkthrough-tui-happy.test.js b/test/core/walkthrough-tui-happy.test.js index 065f130..0032fe6 100644 --- a/test/core/walkthrough-tui-happy.test.js +++ b/test/core/walkthrough-tui-happy.test.js @@ -86,7 +86,8 @@ test('runPickerWalkthrough drives the TUI multiselect end-to-end when stdin+stdo await settle() await feed(io.stdin, ['\x1b[B', '\x1b[B', ' ', '\r']) - // Exports prompt — empty selection falls through to keep-local. + // Exports prompt — local-parquet is pre-checked by default, so a bare + // enter accepts it (matches the documented `--yes` default). await settle() await feed(io.stdin, ['\r']) @@ -97,16 +98,17 @@ test('runPickerWalkthrough drives the TUI multiselect end-to-end when stdin+stdo const result = await promise assert.equal(result.exitCode, 0) assert.deepEqual(result.sourcesPicked, ['raw-anthropic']) - assert.equal(result.exportPicked, 'keep-local') + assert.equal(result.exportPicked, 'local-parquet') assert.equal(result.retentionDays, 30) assert.deepEqual(result.clientsPicked, []) - // The config file landed at HYP_HOME and contains the expected sink - // shape (none configured for keep-local). + // The config file landed at HYP_HOME and carries the local-parquet + // sink that the default export pre-check produced. const configRaw = await fs.readFile(result.configPath, 'utf8') const config = JSON.parse(configRaw) assert.equal(config.version, 2) - assert.equal(config.sinks, undefined) + assert.equal(config.sinks?.local?.destination, '@hypaware/local-fs') + assert.equal(config.sinks?.local?.writer, '@hypaware/format-parquet') // Wire-through evidence: the TUI rendered the source list at least once. assert.match(io.output(), /capture raw Anthropic API traffic/) } finally {