import * as fs from "node:fs/promises"; import { type ProviderDescriptor, type ProviderId } from "../providers/types.js"; import { ConfigurationError, ValidationError } from "./errors.js"; export declare const CONFIG_VERSION: 1; export interface ProviderVerification { readonly status: "verified" | "unverified"; readonly checkedAt: number; readonly reason?: string; } export interface ProviderConfig { readonly apiKey?: string; readonly onboarded?: boolean; readonly verification?: ProviderVerification; } export interface ScoutlineConfig { readonly version: typeof CONFIG_VERSION; readonly fallbackEnabled?: boolean; /** * Multi-provider search fan-out switch (search-fanout plan, DESIGN * D7). Absent/false → single-provider search (default, byte-identical * pre-fan-out behavior); true → tier-3 activation per DESIGN D1. */ readonly fanout?: boolean; /** * Always-on research journaling kill-switch (history-journal merge, * ADR-0008). Absent/undefined/true → journaling enabled (the * `fanout` idiom inverted: the feature is on by default); explicit * false → no journal entries are written. Loaded LENIENTLY: a * non-boolean value is ignored (field dropped, default-on applies) * and never fails config load. */ readonly journal?: boolean; /** * Merged-search ranking algorithm (fusion seed-24): how multi-provider * fan-out results are ranked. Absent → "rrf" (the default). Loaded * LENIENTLY: a non-enum value is ignored (field dropped, default * applies) and never fails config load — the `journal` leniency * precedent. Strict validation lives at the `config set` surface and * in {@link resolveFusionMode} (env door). */ readonly fusion?: FusionMode; readonly providers: Partial>; readonly hintShown?: boolean; /** * Per-capability routed provider preference (routing-table plan). * Keys are capability ids (validated against PROVIDER_CAPABILITIES); * values are ordered ProviderId lists. Validation is LENIENT at load * time: unknown ids/capabilities and malformed entries warn and drop * (never a load failure). An empty list is stored as absent. Absent * on configs written by older binaries (they rebuild from known * fields) — the documented drop trade-off. */ readonly routing?: Readonly>; /** * Per-agent-tool registration choices from the init wizard's agent * step (agent registration D4/D6): tool id → registered. Absent on * configs written by older binaries and on configs from users who * never saw the agent step. Strictly validated at load time — a * non-object value or a non-boolean entry is corrupt config (the * `fanout` boolean precedent). Tool-id KEYS are deliberately not * validated: an id minted by a newer binary must not corrupt an older * binary's config. */ readonly agentRules?: Readonly>; } export interface ConfigStoreOptions { readonly filePath?: string; readonly onWarning?: (warning: AnyConfigWarning) => void; } export interface AtomicReplaceOptions { readonly platform?: NodeJS.Platform; readonly randomId?: () => string; readonly rename?: typeof fs.rename; /** * Skip the forced 0700 chmod of the containing directory. Set for * writes into directories we do NOT own (agent-tool homes): the * force-mode is scoutline's own-config hardening and must not strip * group/shared access from ~/.codex, ~/.claude/rules, etc. */ readonly preserveDirectoryMode?: boolean; } export interface WriteConfigOptions extends ConfigStoreOptions { readonly atomic?: AtomicReplaceOptions; readonly allowEmpty?: boolean; } export interface ConfigWarning { readonly code: "UNKNOWN_PROVIDER"; readonly providerId: string; readonly message: string; } /** * Routing-specific config warning: an unknown capability key or a * malformed routing list in config.json. Warn-and-drop like * UNKNOWN_PROVIDER — never a load failure. */ export interface RoutingConfigWarning { readonly code: "UNKNOWN_CAPABILITY"; readonly capability: string; readonly message: string; } /** * Malformed top-level field warning: a non-boolean `journal` value in * config.json. Warn-and-drop like UNKNOWN_PROVIDER — never a load * failure (journaling falls back to the enabled default). */ export interface MalformedJournalWarning { readonly code: "MALFORMED_JOURNAL"; readonly message: string; } /** * Malformed top-level field warning: a non-enum `fusion` value in * config.json. Warn-and-drop like MALFORMED_JOURNAL — never a load * failure (the "rrf" default applies). */ export interface MalformedFusionWarning { readonly code: "MALFORMED_FUSION"; readonly message: string; } /** * The merged-search ranking algorithms (fusion seed-24). Case-sensitive: * anything not exactly "rrf" or "occurrence" is invalid wherever strict * validation applies (`config set`, {@link resolveFusionMode} env door). */ export type FusionMode = "rrf" | "occurrence"; /** * Resolve the effective fusion mode (fusion seed-24 owner rulings). * Precedence: SCOUTLINE_FUSION env > config `fusion` > default "rrf". * The env door is STRICT: a non-empty value that is not exactly "rrf" * or "occurrence" throws ValidationError — typos fail loudly, never * silently fall back. (The file-config value arrives here already * leniently parsed to enum|undefined, so it needs no re-check.) * Pure — both inputs injected; no ambient reads; safe to unit-test. */ export declare function resolveFusionMode(env: { readonly SCOUTLINE_FUSION?: string | undefined; }, config: ScoutlineConfig | undefined): FusionMode; export type AnyConfigWarning = ConfigWarning | RoutingConfigWarning | MalformedJournalWarning | MalformedFusionWarning; export type ConfigInspection = { readonly status: "absent"; readonly filePath: string; } | { readonly status: "valid"; readonly filePath: string; readonly config: ScoutlineConfig; readonly warnings: readonly AnyConfigWarning[]; } | { readonly status: "corrupt"; readonly filePath: string; readonly error: ConfigurationError; }; export interface ConfigRootEnvironment { readonly SCOUTLINE_CONFIG_DIR?: string; } export interface ConfigRootPlatform { readonly homedir: string; } export declare function resolveConfigRootPure(env: ConfigRootEnvironment, platform: ConfigRootPlatform): string; /** * Test-isolation guard (issue #119): `node --test` sets NODE_TEST_CONTEXT * in every spawned test child, so a bare default-root resolve there means * the caller FORGOT dependency injection and is about to touch the real * `~/.scoutline` — fail loud instead. Lives only on this ambient-env seam; * `resolveConfigRootPure` stays total/pure. `SCOUTLINE_NO_TEST_GUARD=1` * is the documented escape hatch for suites deliberately exercising the * default path. Note the shell convention: ANY non-empty value bypasses * (JS truthiness) — `=0` does NOT re-arm the guard; unset it (or set it * empty) to re-arm. */ export declare function resolveConfigRoot(): string; export declare function configFilePath(root?: string): string; export declare function readConfig(options?: ConfigStoreOptions): Promise; export declare function inspectConfig(options?: ConfigStoreOptions): Promise; export declare function atomicReplaceFile(filePath: string, contents: string | Uint8Array, options?: AtomicReplaceOptions): Promise; export declare function writeConfig(config: ScoutlineConfig, options?: WriteConfigOptions): Promise; /** * Eligibility context {@link fanoutCostNotice} uses to compute the * billable arm set: the injected env (file-configured API keys are * layered on top by {@link resolveEnvFromConfig} — the same merge the * search handler's env view uses) and the live provider registry. The * dispatcher threads both from its `HandlerDependencies`; doubles may * omit the context entirely (the notice then falls back to the blanket * sentence rather than naming an eligibility set it cannot verify). */ export interface FanoutNoticeContext { readonly env: NodeJS.ProcessEnv; readonly descriptors: readonly ProviderDescriptor[]; } /** * One settable/gettable settings surface. `parseValue` is STRICT: it * throws ValidationError on anything it cannot store verbatim-in-meaning * (contrast the lenient load-time warn-and-drop of parseConfig — an * explicit single-value command must not silently store a different * value than the user typed). */ export interface ConfigKeyDescriptor { /** Literal path, or a parameterized prefix for `match` handling. */ readonly path: string; readonly gettable: boolean; readonly settable: boolean; /** true → `config get` redacts the value; `config set` refuses it. */ readonly credential: boolean; readonly describe: string; /** * Sentence the `config set` success path emits (stderr notice) when * this key stores boolean `true` — the cost warning a switch-on must * carry (search-fanout DESIGN D7: `config set fanout true` must state * the billable cost). Receives the UPDATED config plus the * eligibility context (env + provider registry) so the warning can * describe the arms that will actually run: when `routing.search` * narrows tier-3 fan-out, the notice names only the routed providers * that are ELIGIBLE (configured ∩ search-capable — the same arm set * `resolveFanoutPlan` computes), never raw routing entries that would * not bill (review fix, PR #36). Absent on keys whose enablement * carries no such warning. */ readonly setTrueNotice?: (config: ScoutlineConfig, context?: FanoutNoticeContext) => string; /** * Value-agnostic set notice (fusion seed-24 DESIGN D1): emitted on * EVERY successful `config set` of the key, as a stderr notice naming * the consequence plainly. Distinct from {@link setTrueNotice}, which * is boolean-enable-specific (the fan-out cost warning). Absent on * keys whose set carries no consequence worth announcing. */ readonly setNotice?: (config: ScoutlineConfig) => string; } /** The mandated fan-out cost warning (search-fanout DESIGN D7, verbatim). */ export declare const FANOUT_COST_SENTENCE = "every search will bill ALL configured search providers \u2014 N arms = N billable calls"; /** * Enable-time cost warning for `config set fanout true` (D7). With * `routing.search` set, the notice names only the routed providers * that are ELIGIBLE — configured (env OR file key, through the same * `resolveEnvFromConfig` merge the search handler uses) ∩ * search-capable, first-encounter dedupe — the identical arm set * tier 3 of `resolveFanoutPlan` (commands/search.ts) computes. Naming * a raw routing entry that lacks credentials or the capability would * falsely claim it bills on every search; zero eligible arms means * fan-out resolves to nothing and NO provider bills, so the notice * says that instead (review fix, PR #36). Without a routing table — * or without the eligibility context (minimal doubles) — the mandated * blanket D7 sentence ships verbatim. */ export declare function fanoutCostNotice(config: ScoutlineConfig, context?: FanoutNoticeContext): string; /** * Resolve a dotted settings path to its key descriptor, or null when * the path names no registered key (including internal fields like * `version`/`hintShown`, which are deliberately not config-command * surfaces). */ export declare function resolveConfigKey(path: string): ConfigKeyDescriptor | null; export declare function unknownConfigKeyError(path: string): ValidationError; /** * Map a lock-level failure (never a `run()` error — the WeakSet in * {@link serializeConfigWrite} has already excluded those) onto the * writeConfig `ConfigurationError` contract, with advice that matches * the cause. `withAsyncFileLock` raises exactly two failure shapes: * an acquire-deadline timeout (genuine contention — retrying later * can succeed) and raw non-EEXIST `fs.open` errors such as ENOTDIR or * EACCES (environment problems — retrying cannot fix them). The * timeout is recognized by the typed `LockTimeoutError` (primary — its * message no longer carries the legacy tail, see #48), with the legacy * plain-Error message tail kept as a fallback. */ export declare function lockFailureToConfigurationError(error: unknown): ConfigurationError; /** Options for the typed set/unset helpers (same surface as writeConfig). */ export interface ConfigKeyOptions extends WriteConfigOptions { } /** * Set one registered key, strictly. Read-modify-write through the * existing atomic save path, with a round-trip guarantee: the stored * config re-parses (leniently, warning-free) to the same value. * Credential-bearing paths refuse with a pointer to `init` / env — * API keys never belong in command arguments (AGENTS.md). */ export declare function setConfigValue(path: string, value: string, options?: ConfigKeyOptions): Promise; /** * Unset one registered key: a routing capability removes that entry * (and the table itself when the last entry goes); `routing` removes * the whole table; `fallbackEnabled` removes the switch. Unsetting a * nonexistent entry fails — silence would look like success. */ export declare function unsetConfigValue(path: string, options?: ConfigKeyOptions): Promise; /** * Minimal structural view of a Provider Descriptor that * {@link resolveEnvFromConfig} consumes. Keeping this a structural subset * (not the full `ProviderDescriptor`) lets the helper stay pure and * testable without importing transport-level types. */ export interface CredentialDescriptor { readonly id: string; isConfigured(env: NodeJS.ProcessEnv): boolean; /** * Environment-variable names this Provider reads to decide it is * configured. The FIRST entry is the canonical (primary) variable that * file-configured keys are written into; subsequent entries are * aliases that are checked but never populated from the file. */ readonly credentialEnvVars?: readonly string[]; } /** * Build the resolved environment view that shared commands see: the * injected `env` with file-configured API keys layered in for any * Provider that is NOT already configured via `env`. * * Precedence rules (Plan A — T2a): * - **Env overrides file.** A non-blank key already present in `env` * (including aliases like `ZAI_API_KEY`) wins; the file key for that * Provider is not written. This preserves the documented alias * precedence (`Z_AI_API_KEY` > `ZAI_API_KEY` > file key). * - **`process.env` is never mutated.** The returned object is a fresh * shallow copy; the caller owns its lifetime. * - File keys are written into the Provider's CANONICAL variable * (the first `credentialEnvVars` entry, e.g. `Z_AI_API_KEY` for zai) * so the existing `resolveXApiKey` resolvers discover them without a * new code path. * * A Provider with no matching descriptor, a blank file key, or no * `credentialEnvVars` is silently skipped. */ export declare function resolveEnvFromConfig(env: NodeJS.ProcessEnv, config: ScoutlineConfig, descriptors: readonly CredentialDescriptor[]): NodeJS.ProcessEnv; /** * Injectable verification-promotion store. Doctor calls `promote` after * a successful Provider probe to flip the matching record from * `unverified` to `verified`. Production wires * {@link createDefaultVerificationPromoter} (real read-modify-write * against `~/.scoutline/config.json`); tests inject in-memory doubles * so the promotion assertions never touch real config-root I/O. * * Contract: * - Only a Provider whose probe SUCCEEDED is promoted. Skipped, * failed, no-tools, and network-deferred records are NOT promoted * (Doctor's report still reflects the probe's authoritative status). * - A record that is already `verified` (or absent, or has no * verification record) is a no-op. * - Write failure is surfaced through the returned promise so the * caller can isolate it (Doctor logs and continues; the report * stays unaffected). */ export interface VerificationPromotionStore { promote(providerId: ProviderId, checkedAt: number): Promise; } /** * Production {@link VerificationPromotionStore}. Reads the live config, * flips the matching Provider record's `verification.status` from * `unverified` to `verified`, and rewrites the file atomically. A * record that is absent, has no verification field, or is already * `verified` is a no-op (no write, no error). The read-modify-write is * not cross-process locked; Doctor is a single-shot CLI command and the * atomic rename keeps the final state crash-safe against partial writes. */ export declare function createDefaultVerificationPromoter(options?: WriteConfigOptions): VerificationPromotionStore; /** * Injectable hint-shown store. Trigger detection calls `setHintShown` * once after emitting the env-only hint so the hint never repeats. * Production wires {@link createDefaultHintShownStore}; tests inject * in-memory doubles so the hint persistence assertions stay hermetic. * * Contract: * - Absent / corrupt config is a no-op (no hint can be persisted when * the config substrate is unavailable; the hint simply does not * repeat within this process and is re-emitted on the next run * after the user repairs via `init`). * - `hintShown === true` already is a no-op. * - `false → true` is the only write. */ export interface HintShownStore { setHintShown(): Promise; } /** * Production {@link HintShownStore}. Reads the live config, sets * `hintShown: true` if it is currently unset/false, and rewrites the * file atomically. Same read-modify-write caveats as the verification * promoter. * * When the config is ABSENT, the store creates a minimal config file * (`{version:1, providers:{}, hintShown:true}`) so the hint does NOT * repeat on every subsequent run — the ticket's "one-time marker" * contract requires persistence, and absent-config is the common case * for a user who just installed scoutline and is running on env vars. * A CORRUPT config is a no-op (init is the recovery path; the hint * store must not silently rewrite a corrupt file). */ export declare function createDefaultHintShownStore(options?: WriteConfigOptions): HintShownStore; //# sourceMappingURL=config-store.d.ts.map