/** * The one place this package decides WHICH Skills service it talks to and WITH * WHICH credential. * * Everything here delegates to the shared client seam in `@hasna/contracts/client` * (owner rulings 2026-09-04 — hasna/apps#1720, #1668, #1690, #1613, #1599). This * package owns no second copy of the ladder, and no alias env name of its own. * * CREDENTIAL, resolved fresh on every call (contracts `resolveCredential`): * * 1. an explicit argument — `--api-key`, `--profile` * 2. a deliberate env pointer — `HASNA_SKILLS_API_KEY_OVERRIDE`, * `HASNA_PROFILE`, `HASNA_SKILLS_API_KEY_REF` * * `HASNA_SKILLS_API_KEY_REF` names a VAULT ITEM rather than a key, so it * resolves in two steps: the synchronous chain yields the pointer, and * `resolveSkillsApiKey()` (async) fetches the value through the secrets * SDK on each call. Never treat the pointer's own `apiKey` — the empty * string — as a credential; `resolveSkillsFleet` reports null for it. * 3. the macOS Keychain — generic-password `hasna.credentials.skills.api-key`, * account `HASNA_STATION`, else `hostname -s`, else `USER` * 4. disk, read at call time — `~/.hasna/skills/config/credentials` * (`HASNA_HOME` / `HASNA_CONFIG_HOME` relocate it; XDG never) * 5. `HASNA_SKILLS_API_KEY` in the environment — a legitimate tier, below disk, * and carrying no deprecation notice * * URL: `HASNA_SKILLS_API_URL` → the Keychain `api-url` item → the credentials * file → the fleet gateway `https://api.hasna.com/skills`. The gateway default * applies ONLY once a credential has resolved, so a DATA request from an install * with no credential names no host at all (the R1 boundary in * vendor-host-policy.ts). Signing in is the one exception: with no URL and no * credential, `resolveSkillsSignInOrigin` returns the product default * (product-default.ts; owner rulings 2026-09-23). * * The unprefixed `SKILLS_API_URL` / `SKILLS_API_KEY` spellings are still * accepted, silently, because the shared seam accepts `_API_URL` / * `_API_KEY` as documented aliases one rung below the canonical * `HASNA_SKILLS_*` names. They are documented in the README for one release and * are not read anywhere else in this package. `SKILL_API_KEY` (singular) is * gone: it shadowed nothing canonical and was never documented. * * THIRD: the local run is opt-in only (owner ruling 2026-09-04, hasna/apps#1720; * class-patch order 2026-09-06). `HASNA_SKILLS_LOCAL=1` (alias `SKILLS_LOCAL=1`) * selects the on-machine run when the ENVIRONMENT configures no authority; it is * answered before the resolver runs, so opting in never reads the Keychain or * the credentials file. A configured environment always outranks it. The * outcomes, and no fourth: * * - the local opt-in selected → LOCAL, announced once on stderr. * - a credential resolves → HOSTED. The authority is the configured * URL, else the fleet gateway. A credential * that cannot produce a usable key — a * deliberate selection contracts refuses, a * pointer whose vault item is missing, any * tier that yields a blank value — is a LOUD * failure, never a demotion to local. * - no credential, but a URL is configured * → LOUD failure. The caller exits non-zero * with one line naming what is missing. * There is no local fallback here: serving * local results while authentication is * unconfigured is a false green. * - neither a credential nor a URL, and no opt-in * → LOUD failure, exit non-zero, no SQLite, * no *-local-fallback event. Running on * this machine is a deliberate choice now, * not the silence that follows a missing * credential. */ import type { CredentialChainOptions, CredentialTier, KeychainTierOptions, ResolvedCredential } from "./client-types.js"; /** The app slug: the Keychain service, the `~/.hasna/` folder, the gateway path. */ export declare const SKILLS_APP = "skills"; /** The deliberate unhosted opt-in env names. Re-exported for the surfaces that have to name them. */ export { SKILLS_LOCAL_OPT_IN_ENV_KEYS, isSkillsLocalOptIn, selectsSkillsLocalMode } from "./local-opt-in.js"; type Env = Record; /** `HASNA_SKILLS_API_URL`, then the accepted `SKILLS_API_URL` alias. */ export declare const SKILLS_API_URL_ENV_KEYS: readonly string[]; /** `HASNA_SKILLS_API_KEY`, then the accepted `SKILLS_API_KEY` alias. */ export declare const SKILLS_API_KEY_ENV_KEYS: readonly string[]; /** The canonical spellings, for messages that have to name exactly one. */ export declare const SKILLS_API_URL_ENV: string; export declare const SKILLS_API_KEY_ENV: string; export interface SkillsFleetOptions { /** Tier-1 credential inputs and the Keychain-tier controls (a fake runner in tests). */ credentials?: CredentialChainOptions; } /** * Every tier the shared resolver can return, in its precedence order. The * pairing invariant (assertCredentialInstancePairing) classifies each one, and * the pairing matrix test derives its rows from this list; the type below fails * to compile if the resolver gains a tier this list does not name. */ export declare const SKILLS_CREDENTIAL_TIERS: readonly ["argument", "override", "pointer", "profile", "keychain", "disk", "env"]; /** A hosted resolution: an authority to call and a credential to call it with. */ export interface HostedSkillsFleet { mode: "hosted"; /** Origin the CLI/SDK sends requests to. Never carries a trailing slash. */ apiOrigin: string; /** * The resolved key, or null when the credential is a vault POINTER * (`HASNA_SKILLS_API_KEY_REF` / a `credential_ref` line) that only the async * path can complete — see {@link apiKeyPointer} and {@link resolveSkillsApiKey}. * * It is NEVER the empty string: a blank key would produce * `Authorization: Bearer ` on the wire, and — read as falsy by a caller * looking for "is there a token" — a silent drop back to local data. Both * are refused at resolution time instead. * * Never logged, never written anywhere but the header. */ apiKey: string | null; /** * The unresolved vault pointer, when tier === "pointer"; null otherwise. * * `resolveCredential` returns a TRUTHY credential for the pointer tier whose * `apiKey` is empty and whose `pointerVaultKey` names the vault item; * fetching that item is a separate async step. Carrying the pointer (rather * than its empty key) is what keeps the sending paths honest. */ apiKeyPointer: ResolvedCredential | null; /** WHERE the URL came from: an env key NAME, a Keychain reference, a path, or "default". */ apiUrlSource: string; /** WHERE the key came from: an env key NAME, a Keychain reference, or a path. Never a value. */ apiKeySource: string; apiKeyTier: CredentialTier; /** Advisory from the shared seam (never secret), or null. */ warning: string | null; } /** Nothing is configured: this install runs on this machine. */ export interface LocalSkillsFleet { mode: "local"; apiOrigin: null; apiKey: null; } export type SkillsFleet = HostedSkillsFleet | LocalSkillsFleet; /** Machine-readable reasons a hosted resolution was refused. */ export type SkillsFleetErrorCode = "MISSING_API_CREDENTIAL" | "INVALID_API_URL" | "INSTANCE_CREDENTIAL_MISMATCH" | "GATEWAY_AUTH_UNAVAILABLE"; /** * A configured install could not produce a usable hosted client. * * `code` is stable so JSON callers can branch on it, and distinguishes the two * refusals that are NOT the same fault: an authority with no credential * (MISSING_API_CREDENTIAL) versus an authority that is declared but unusable * (INVALID_API_URL). MISSING_API_URL stays the code for "nothing configured at * all" (see MissingSkillsFleetError), which is a third, non-error state for the * commands that may run locally. */ export declare class SkillsFleetCredentialError extends Error { readonly code: SkillsFleetErrorCode; /** Machine-readable next steps, the same ones the message names. Never a value. */ readonly next?: readonly string[]; constructor(message: string, code?: SkillsFleetErrorCode, next?: readonly string[]); } /** * True for this package's own refusal, across bundle boundaries. * * Exported for the surfaces that turn the refusal into data (an MCP tool's * `AUTH_REQUIRED` result, a CLI handler's one-line stderr exit) so they match * on the error's NAME rather than on a class identity a bundle may not share. */ export declare function isSkillsFleetCredentialError(error: unknown): error is SkillsFleetCredentialError; /** * Translate the shared seam's `CredentialResolutionError` into this package's * own refusal, or return null for anything else. * * A DELIBERATE selection that cannot be honoured — `HASNA_PROFILE` naming a * profile that has no entry, an override or pointer that resolves to nothing, a * corrupt credentials file — is thrown by `@hasna/contracts`, not by the * transport resolver. Left untranslated it escaped every helper in this file: * `skillsCredentialOrReason` and `resolveConfiguredRunRouting` recognise only * `SkillsFleetCredentialError`, so a `--json` command or an MCP tool got an * unhandled exception where the structured refusal was the whole point. * * The seam's message already names what was attempted and never carries a * credential value. Pointer-completion messages can still carry the selected * vault item identifier, so that exact identifier is removed before the error * reaches any user-visible surface. */ export declare const REDACTED_VAULT_REFERENCE = "[redacted vault reference]"; /** * Normalize a configured Skills authority to the origin the client dials. * * The Skills server serves its API under `/api/v1`, so the client composes * `/api/v1/...` itself. An operator who pasted the full API base — the * URL printed by every error message — must not end up with `/api/v1/api/v1`. */ export declare function normalizeSkillsApiOrigin(apiUrl: string): string; /** Compose a known Skills route without changing its credential-bound instance. * The fleet gateway strips /skills and forwards /v1 to its independent origin. * Other instances retain the established /api/v1 and /api/auth contracts. */ export declare function skillsApiRequestUrl(apiUrl: string, route: string): string; /** One configured authority: its value and the source that decided it. */ export interface ConfiguredSkillsApiUrl { value: string; /** An env key NAME, a `keychain:@` reference, or an absolute path. */ source: string; /** * Which trust source configured it: the process environment, the Keychain, * a credentials file (default or profile), or nothing (the gateway fallback * of a selected profile). The pairing invariant reads this, never the name. */ trust: "environment" | "keychain" | "file" | "default"; } /** * The authority an operator configured, in the shared seam's precedence order — * environment, then the Keychain `api-url` item, then the credentials file. * * Returns null when nothing configures one, which is what lets the gateway * default apply for a credentialled install and what keeps an install with no * credential from naming a host at all. */ export declare function configuredSkillsApiUrl(env?: Env, keychain?: KeychainTierOptions, profile?: string): ConfiguredSkillsApiUrl | null; /** The credential file paths consulted, for a message that has to name them. */ export declare function skillsCredentialFiles(env?: Env): string[]; /** * Where a credential should be written, and the only file this package writes. * * Throws when neither HOME nor HASNA_HOME anchors a root, because there is then * no correct place to put a secret and guessing one is worse than refusing. */ export declare function skillsCredentialFilePath(env?: Env): string; /** * Say — once per process, on stderr — that this install is running locally. * * Local mode is legitimate for Skills: the corpus ships in the package. It is a * deliberate choice now, though: it is reachable only through the explicit * opt-in (`HASNA_SKILLS_LOCAL=1`), and it is still announced, because "no * credential resolved" and "deliberately offline" look identical in the output * otherwise, and the first one is usually a misconfiguration the operator wants * to hear about. */ export declare function noticeLocalSkillsMode(write?: (line: string) => void): void; /** Test seam: forget that the local-mode line was printed. */ export declare function resetLocalSkillsModeNotice(): void; /** * Resolve the service this process should use, fresh. * * Never returns a hosted resolution without a credential, and never degrades a * configured authority to local mode. */ export declare function resolveSkillsFleet(env?: Env, options?: SkillsFleetOptions): SkillsFleet; /** * Where a resolved credential may be sent: THE pairing invariant, classified * once for every tier in {@link SKILLS_CREDENTIAL_TIERS} (the switch below is * exhaustive, and an unknown tier is refused rather than guessed). * * bound — a stored credential that records its instance. The credentials * file key (`disk`), a profile file key (`profile`), a vault pointer * stored in a credentials file (`pointer` from a file) and the * Keychain key (`keychain`, bound to the Keychain `api-url` beside * it). It is sent only to that instance. A stored credential that * recorded none is a legacy internal key: the internal gateway. * unbound — a credential that records no instance: `HASNA_SKILLS_API_KEY` / * `SKILLS_API_KEY` (`env`), `HASNA_SKILLS_API_KEY_OVERRIDE` * (`override`), an environment `HASNA_SKILLS_API_KEY_REF` (`pointer` * from the environment) and an explicit argument (`argument`). It is * sent only to the internal gateway or to a URL from the environment * — never to a URL from a credentials file or the Keychain, which may * have been written for a different credential (a `skills login`, a * `skills setup`, a station provisioner). * * So a URL that `skills login` wrote binds only the key login stored beside it. */ export type CredentialPairing = { kind: "bound"; instance: string; } | { kind: "unbound"; }; export declare function credentialPairing(credential: ResolvedCredential, env: Env, options?: SkillsFleetOptions): CredentialPairing; /** * The usable API key for this process, completing a vault pointer if that is * the tier that won. * * ASYNC because the pointer tier is: the value is fetched from the secrets * vault at call time, so a rotated item is picked up without a restart. Every * path that is about to SEND the key resolves it here; the synchronous * `resolveSkillsFleet` is for reporting (which tier, which source, which * origin), and its `apiKey` is deliberately null for a pointer. * * Returns null only in local mode. Throws {@link SkillsFleetCredentialError} * when a credential is configured and cannot be produced — never a fallback. */ export declare function resolveSkillsApiKey(env?: Env, options?: SkillsFleetOptions): Promise; /** Resolve the URL and credential once, including any asynchronous vault lookup. */ export declare function resolveSkillsConnection(env?: Env, options?: SkillsFleetOptions): Promise<(HostedSkillsFleet & { apiKey: string; }) | null>; /** The usable API key, or throw naming what is missing. Use on every send path. */ export declare function requireSkillsApiKey(action?: string, env?: Env, options?: SkillsFleetOptions): Promise; /** * The credential for a surface that reports refusals as data (an MCP tool, a * `--json` command) rather than as an exception. * * `reason` is the ladder's own message when a hosted resolution is refused — an * authority with no key, or an unconfigured install without the local opt-in * (fail-closed ruling) — a refusal carried as a value, NOT a fallback: the * caller must still refuse. It is null only when the explicit local opt-in * selected the on-machine run, the ordinary "not signed in" case. */ export declare function skillsCredentialOrReason(env?: Env, options?: SkillsFleetOptions): Promise<{ apiKey: string; apiOrigin: string; reason: null; } | { apiKey: null; apiOrigin: null; reason: string | null; }>; /** * The authority alone, for a flow that is ACQUIRING a credential. * * `skills auth login` cannot require a credential — obtaining one is the point — * so it resolves the AUTHORITY on its own: the configured URL (environment, * Keychain, credentials file), else the authority a resolved credential implies. * With neither, this returns null and the caller fails loudly: R1 still holds, * an install that named no service does not get to send an email address to one. * * The fail-closed refusal an unconfigured install now raises on every DATA * surface is swallowed here and reported as plain "no authority": for a flow * whose purpose is to acquire a credential, "nothing is configured" and "local * mode is opted in" have the same two-step way out (configure an origin, then * sign in). */ export declare function resolveSkillsApiOrigin(env?: Env, options?: SkillsFleetOptions): { origin: string; source: string; } | null; /** The authority for an auth flow, or throw naming what is missing. */ export declare function requireSkillsApiOrigin(action?: string, env?: Env, options?: SkillsFleetOptions): string; /** Where a sign-in goes, and what decided it. */ export interface SignInTarget { origin: string; /** "--url", a configured URL's source, or "default". */ source: string; /** Set when an already-resolving credential decided the origin: its source NAME, never a value. */ credentialSource?: string; } /** * The refusal for a sign-in that would target the internal gateway, which has * no sign-in service — or null. It names what to unset or remove, and never * suggests pointing this machine at the product server while an internal * credential is still configured. Nothing is sent either way. */ export declare function gatewaySignInRefusal(target: SignInTarget): SkillsFleetCredentialError | null; /** * The authority a SIGN-IN talks to: `skills login`, `skills auth login` / * `signup`, and the TUI `/login`. Resolved on its own because signing in is how * a credential is obtained, so it cannot require one. * * In order: * * 1. `--url ` — an explicit choice for this sign-in. It is refused when * an outranking URL (the environment or the Keychain) names a different * instance, because the key it mints would be shadowed the moment it is * saved. A URL in the credentials file is what a sign-in replaces. * 2. a configured URL — environment, profile or credentials file, Keychain. * 3. the instance an already-stored credential belongs to. A legacy internal * key with no recorded URL belongs to the fleet gateway, so a machine that * holds one keeps signing in there: its key is never sent to the product * default, and the product default is never chosen for it silently. * 4. the product default ({@link SKILLS_PRODUCT_DEFAULT_ORIGIN}) — only when * no URL and no credential resolve anywhere (owner rulings 2026-09-23). * * Nothing here sends a request. */ export declare function resolveSkillsSignInOrigin(env?: Env, options?: SkillsFleetOptions, explicitUrl?: string): SignInTarget; /** * The hosted resolution, or throw. Use on every auth and write path. * * `action` names the caller so the message says what was refused. */ export declare function requireSkillsFleet(action?: string, env?: Env, options?: SkillsFleetOptions): HostedSkillsFleet; /** * Nothing at all is configured and the caller needed a service. * * The message names the environment variable, the Keychain item, the credential * file and the command — and deliberately contains no URL, so an "error" can * never hand a caller an endpoint it refused to resolve. */ export declare class MissingSkillsFleetError extends Error { readonly code = "MISSING_API_URL"; constructor(action?: string); }