Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 70 additions & 0 deletions src/core/cli/detect.js
Original file line number Diff line number Diff line change
@@ -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<Set<PickerSource>>}
*/
export async function detectClientSources(opts) {
const env = opts.env
const homeDir = env.HOME ?? os.homedir()
/** @type {Set<PickerSource>} */
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
}
13 changes: 13 additions & 0 deletions src/core/cli/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Set<PickerSource>>
/**
* Interactive consent prompt for the onboarding backfill step. Only
* consulted in interactive mode (no pre-baked `picks`); non-interactive
Expand Down
38 changes: 36 additions & 2 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -631,13 +633,32 @@ 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<PickerSource>} */
let detected = new Set()
if (interactive) {
const detect = opts.detect ?? detectClientSources
try {
detected = await detect({ env })
} catch {
detected = new Set()
}
}

await withSpan(
'walkthrough.start',
{
[Attr.COMPONENT]: 'walkthrough',
[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 () => {},
Expand All @@ -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))
Expand All @@ -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'
Expand Down
29 changes: 29 additions & 0 deletions src/core/daemon/client_settings_path.js
Original file line number Diff line number Diff line change
@@ -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('/'))
}
27 changes: 6 additions & 21 deletions src/core/daemon/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions test/core/detect.test.js
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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)
})
Loading