@hasna/skills 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,43 @@
1
1
  /**
2
- * Single source of truth for resolving the Skills API endpoint.
2
+ * Where the Skills API lives, for this process, right now.
3
3
  *
4
- * Boundary rule (R1): an unconfigured install must never produce a URL on a
5
- * vendor-controlled host.
4
+ * This module is a thin reading of `fleet-credentials.ts`, which is itself a
5
+ * thin reading of `@hasna/contracts/client`. It exists only so the two failure
6
+ * MODES stay named:
6
7
  *
7
- * - Read paths fail closed: `resolveApiUrl()` returns `undefined` and the
8
- * caller keeps working against the bundled local registry.
9
- * - Auth and write paths fail loudly: `requireApiUrl()` throws an error that
10
- * names the missing configuration.
8
+ * - Read paths fail closed: `resolveApiUrl()` returns `undefined` when nothing
9
+ * is configured, and the caller keeps working against the bundled local
10
+ * registry.
11
+ * - Auth and write paths fail loudly: `requireApiUrl()` throws an error naming
12
+ * the missing configuration.
11
13
  *
12
- * There is deliberately no fallback endpoint and no localhost default. An
13
- * unconfigured CLI has nothing sane to point a credential-bearing request at,
14
- * so it must say so rather than pick a host on the user's behalf.
14
+ * Boundary rule (R1), unchanged in substance: an install with NO credential
15
+ * produces no URL at all. The fleet gateway default in the shared seam applies
16
+ * only once a credential has resolved, so an unconfigured OSS install still
17
+ * names no host — see `resolveSkillsFleet`.
18
+ *
19
+ * A URL that IS configured while no credential resolves does not fall back to
20
+ * local: it throws (SkillsFleetCredentialError). Reading local data because
21
+ * authentication is unconfigured is the false green this ladder exists to
22
+ * remove.
15
23
  */
16
- import { type SkillsConfig } from "./config.js";
17
- export declare const API_URL_ENV_VAR = "SKILLS_API_URL";
18
- export declare const API_URL_CONFIG_KEY = "apiUrl";
24
+ import { type SkillsFleetOptions } from "./fleet-credentials.js";
25
+ /** The canonical env name. The unprefixed `SKILLS_API_URL` alias is still read. */
26
+ export declare const API_URL_ENV_VAR: string;
19
27
  /** Hint shown wherever a Skills API URL is required but absent. */
20
28
  export declare const MISSING_API_URL_HINT: string;
21
- export declare class MissingApiUrlError extends Error {
22
- readonly code = "MISSING_API_URL";
23
- constructor(action?: string);
24
- }
29
+ /** Kept under its old name: callers and tests match on this identity. */
30
+ export { MissingSkillsFleetError as MissingApiUrlError } from "./fleet-credentials.js";
31
+ type Env = Record<string, string | undefined>;
25
32
  /**
26
- * Resolve the configured Skills API origin, or `undefined` when the install has
27
- * not been pointed at an instance. `SKILLS_API_URL` wins over the config file.
33
+ * Resolve the Skills origin in effect, or `undefined` when this install has
34
+ * neither a credential nor an authority i.e. runs on this machine.
35
+ *
36
+ * Throws when an authority IS configured and no credential resolves.
28
37
  */
29
- export declare function resolveApiUrl(config?: SkillsConfig, env?: Record<string, string | undefined>): string | undefined;
38
+ export declare function resolveApiUrl(env?: Env, options?: SkillsFleetOptions): string | undefined;
30
39
  /**
31
- * Resolve the configured Skills API origin, or throw naming the missing
32
- * configuration. Use this on every auth and write path.
40
+ * Resolve the Skills origin, or throw naming what is missing. Use this on every
41
+ * auth and write path.
33
42
  */
34
- export declare function requireApiUrl(action?: string, config?: SkillsConfig, env?: Record<string, string | undefined>): string;
43
+ export declare function requireApiUrl(action?: string, env?: Env, options?: SkillsFleetOptions): string;
@@ -1,66 +1,135 @@
1
1
  /**
2
- * auth.json lives at the skills app root, beside config.json.
2
+ * The credential the CLI signs in with, and the identity it displays.
3
3
  *
4
- * Resolved through getDataDir() so that $HASNA_SKILLS_DIR relocates the
5
- * credential file along with the rest of the app's state. It used to be an
6
- * import-time constant composed from homedir(), so with the override set the
7
- * CLI stored its API key at ~/.hasna/skills/auth.json while config, corpus,
8
- * and database all moved the same override-only-half-works split getDataDir()
9
- * documents for its own history.
4
+ * READING is not done here. Every read goes through `fleet-credentials.ts` →
5
+ * `@hasna/contracts/client`, so the argument, the env pointer, the macOS
6
+ * Keychain, `~/.hasna/skills/config/credentials` and `HASNA_SKILLS_API_KEY` are
7
+ * consulted in the fleet's one order, on every call. There is no cache: a
8
+ * credential is mutable state, and a value captured at process start is the
9
+ * defect the ladder exists to remove (a shell that outlives a key rotation).
10
10
  *
11
- * The legacy ~/.skills/auth.json stays a $HOME concern, exactly like
12
- * getDataDir()'s legacy merge (which is deliberately skipped for an overridden
13
- * dir): it is still read as a fallback and removed by clearAuthConfig(), but
14
- * never written when the override is set. Like getDataDir(), the home is read
15
- * from the environment at call time os.homedir() caches its answer after the
16
- * first call, so a process that re-points $HOME would keep composing the old
17
- * path.
11
+ * WRITING lands in exactly one file `~/.hasna/skills/config/credentials`,
12
+ * mode 0600, the shared seam's disk tier so `skills auth login` on this
13
+ * machine and a station wrapper reading the same file cannot disagree.
14
+ * `HASNA_HOME` / `HASNA_CONFIG_HOME` relocate it; `$HASNA_SKILLS_DIR` does not,
15
+ * because that variable relocates this app's DATA (corpus, database, config),
16
+ * and the fleet credential is not app data it is the machine's, shared with
17
+ * every other Hasna CLI.
18
+ *
19
+ * The display identity (`email`, org, user ids) is NOT a credential and lives
20
+ * beside it in `identity.json`. It is only ever what the server's `whoami`
21
+ * returned; nothing here invents a field.
22
+ *
23
+ * `~/.skills/auth.json` and `~/.hasna/skills/auth.json` are retired locations and
24
+ * are not read. `skills auth login` writes the credentials file; an operator
25
+ * still holding an old auth.json is told to sign in again.
18
26
  */
19
- export declare function getAuthFilePath(): string;
27
+ import { type SkillsFleetOptions } from "./fleet-credentials.js";
28
+ export { normalizeSkillsApiOrigin } from "./fleet-credentials.js";
29
+ type Env = Record<string, string | undefined>;
30
+ /** The credentials file this package writes and the shared seam reads. */
31
+ export declare function getAuthFilePath(env?: Env): string;
20
32
  /**
21
- * Write-free credential path resolution for read-only paths (e.g. `sync --dry-run`).
33
+ * Write-free path resolution for read-only paths (e.g. `sync --dry-run`).
22
34
  *
23
- * getAuthFilePath() routes through getDataDir(), which WRITES (mkdirs the app dir,
24
- * merges legacy ~/.skills content, copies the legacy config). A dry run must read
25
- * the same credential file a real run would without performing any of that.
35
+ * Identical to getAuthFilePath(): nothing in the credential path writes as a
36
+ * side effect of resolving any more. Kept as a separate name so the read-only
37
+ * callers keep reading as read-only.
26
38
  */
27
- export declare function getAuthFilePathReadOnly(): string;
39
+ export declare function getAuthFilePathReadOnly(env?: Env): string;
40
+ /** The display identity file beside the credential. Never holds a secret. */
41
+ export declare function getIdentityFilePath(env?: Env): string;
28
42
  /**
29
43
  * Stored credentials for a Skills API instance.
30
44
  *
31
- * `apiKey` is the only credential. The identity fields are display metadata
32
- * echoed back from the instance's `whoami`, so they are optional: an instance
33
- * that does not return them leaves them unset. They are never invented locally
34
- * a placeholder written here is indistinguishable from a value the server
35
- * actually returned once it is read back out of `auth.json`.
45
+ * `apiKey` is the credential the ladder resolved not necessarily one this CLI
46
+ * wrote. The identity fields are display metadata echoed back from the
47
+ * instance's `whoami`, so they are optional: an instance that does not return
48
+ * them leaves them unset. They are never invented locally a placeholder
49
+ * written here is indistinguishable from a value the server actually returned.
36
50
  */
37
51
  export interface AuthConfig {
38
- apiKey: string;
52
+ /**
53
+ * The credential the ladder resolved, or null when it is a vault POINTER that
54
+ * only the async path can complete (see {@link getApiKeyAsync}). Callers that
55
+ * need to SEND it must resolve it there; the display surfaces below only need
56
+ * to know that one is configured.
57
+ */
58
+ apiKey: string | null;
39
59
  email?: string;
40
60
  orgId?: string;
41
61
  orgSlug?: string;
42
62
  userId?: string;
43
63
  }
44
- export declare function getAuthConfig(): AuthConfig | null;
45
- export declare function saveAuthConfig(config: AuthConfig): void;
46
- export declare function clearAuthConfig(): void;
47
- export declare function getApiKey(): string | null;
64
+ /** The identity half, on its own: what `whoami` said, with no credential. */
65
+ export type AuthIdentity = Omit<AuthConfig, "apiKey">;
66
+ /**
67
+ * What `saveAuthConfig` is handed: a key this CLI actually holds.
68
+ *
69
+ * Distinct from {@link AuthConfig}, whose `apiKey` may be null for a vault
70
+ * pointer — there is nothing to write to disk in that case, and writing an
71
+ * empty line would masquerade as a stored credential.
72
+ */
73
+ export type StoredAuthConfig = AuthIdentity & {
74
+ apiKey: string;
75
+ };
76
+ export declare function getAuthIdentity(env?: Env): AuthIdentity;
77
+ /**
78
+ * The credential in effect plus whatever identity was recorded for it, or null
79
+ * when no credential resolves anywhere on the ladder.
80
+ */
81
+ export declare function getAuthConfig(env?: Env, options?: SkillsFleetOptions): AuthConfig | null;
82
+ /** Alias kept for the read-only callers; resolution never writes. */
83
+ export declare function getAuthConfigReadOnly(env?: Env, options?: SkillsFleetOptions): AuthConfig | null;
48
84
  /**
49
- * Write-free credential read for read-only paths (e.g. `sync --dry-run`).
85
+ * Persist the credential (and any identity the server returned) for this user.
50
86
  *
51
- * getAuthConfig() resolves through getAuthFilePath() -> getDataDir(), which
52
- * writes. Reads the same files (canonical auth.json, then the legacy ~/.skills
53
- * fallback) without creating or migrating anything, and without touching the
54
- * write path's module-level cache.
87
+ * Returns the file it wrote, so the CLI can name the real path rather than a
88
+ * path it assumed.
89
+ */
90
+ export declare function saveAuthConfig(config: StoredAuthConfig, env?: Env): string;
91
+ /** Store (or clear, with null) the API URL beside the credential. */
92
+ export declare function saveApiUrl(apiUrl: string | null, env?: Env): string;
93
+ /** The API URL recorded in the credentials file, or null. */
94
+ export declare function readStoredApiUrl(env?: Env): string | null;
95
+ /**
96
+ * Remove the credential this CLI wrote.
97
+ *
98
+ * Only the file is cleared: a key injected from the environment or held in the
99
+ * Keychain belongs to the machine, not to this command, and silently appearing
100
+ * to remove it would be a lie. The caller is told whether one still resolves.
101
+ */
102
+ export declare function clearAuthConfig(env?: Env): {
103
+ stillResolves: boolean;
104
+ };
105
+ /**
106
+ * The credential in effect, resolved fresh through the shared ladder.
107
+ *
108
+ * SYNCHRONOUS, so it cannot complete a vault pointer
109
+ * (`HASNA_SKILLS_API_KEY_REF`): for that tier it returns null, because the
110
+ * pointer's own value is the empty string and handing THAT back as a key is how
111
+ * `Authorization: Bearer ` reached the wire. Any path that is about to SEND the
112
+ * key must use {@link getApiKeyAsync} (or `resolveSkillsApiKey`), which fetches
113
+ * the vault item and refuses loudly when it cannot.
114
+ */
115
+ export declare function getApiKey(env?: Env, options?: SkillsFleetOptions): string | null;
116
+ /**
117
+ * The credential in effect, completing a vault pointer through the secrets
118
+ * vault. Null only in local mode; throws when a configured credential cannot
119
+ * be produced. Use this wherever the key is about to be sent.
55
120
  */
56
- export declare function getAuthConfigReadOnly(): AuthConfig | null;
57
- export declare function getApiKeyReadOnly(): string | null;
58
- export declare function normalizeSkillsApiOrigin(apiUrl: string): string;
121
+ export declare function getApiKeyAsync(env?: Env, options?: SkillsFleetOptions): Promise<string | null>;
122
+ /** Identical to getApiKey(): resolution has no write side effects. */
123
+ export declare function getApiKeyReadOnly(env?: Env, options?: SkillsFleetOptions): string | null;
59
124
  /**
60
125
  * Origin every credential-bearing request is sent to.
61
126
  *
62
- * Throws `MissingApiUrlError` when the install has not been pointed at an
63
- * instance. There is no fallback endpoint: an unconfigured CLI must not decide
64
- * on the user's behalf where their email address, login code, or API key goes.
127
+ * The AUTHORITY, not the whole hosted resolution: `skills auth login` runs
128
+ * before there is a credential, and requiring one here would make signing in
129
+ * impossible. Throws when nothing names a service: an install that named none
130
+ * must not decide on the user's behalf where their email address, login code,
131
+ * or API key goes.
65
132
  */
66
- export declare function getApiUrl(action?: string): string;
133
+ export declare function getApiUrl(action?: string, env?: Env, options?: SkillsFleetOptions): string;
134
+ /** Permission bits of the credentials file, for `skills auth status`-style output. */
135
+ export declare function credentialFileMode(env?: Env): number | null;
@@ -16,29 +16,29 @@
16
16
  */
17
17
  export { DATA_DIR_ENV } from "./app-home.js";
18
18
  /**
19
- * There is no deployment "mode" key.
19
+ * There is no deployment "mode" key, and no service address here either.
20
20
  *
21
21
  * Skills has one deployment story: you run it. Whether this CLI talks to a
22
- * server is not a product variant, it is one fact — whether an API origin is
23
- * configured (apiUrl here, or $SKILLS_API_URL). Nothing else may be derived
24
- * from a declared label, because a label can disagree with the configuration
25
- * it claims to describe.
22
+ * server is not a product variant, it is one fact — whether a fleet credential
23
+ * resolves. Nothing may be derived from a declared label, because a label can
24
+ * disagree with the configuration it claims to describe.
26
25
  *
27
- * One caveat, true at the time of writing and tracked separately: getApiUrl()
28
- * in auth-store.ts still falls back to a built-in origin when neither is set,
29
- * so "no origin configured" is not yet the same as "sends nothing anywhere" on
30
- * the auth path. Removing that fallback belongs to the no-vendor-defaults work,
31
- * not here; remote-registry.ts already fails closed and is the model.
26
+ * `apiUrl` used to live here as a sixth URL tier of this package's own. It is
27
+ * retired (owner ruling 2026-09-04, hasna/apps#1720): the authority ladder is
28
+ * `HASNA_SKILLS_API_URL` the Keychain `api-url` item
29
+ * `~/.hasna/skills/config/credentials` → the fleet gateway, and it belongs to
30
+ * @hasna/contracts so every Hasna CLI resolves it identically.
31
+ * `skills setup --api-url <origin>` still writes it — into the credentials file.
32
32
  *
33
- * Configs written by older versions may still carry a "mode" key on disk. That is
34
- * refused rather than ignored - see lib/retired-settings.ts for why silence is
35
- * the worse of the two failures - and `skills config unset mode` removes it.
33
+ * Configs written by older versions may still carry a "mode" or "apiUrl" key on
34
+ * disk. Those are refused rather than ignored see lib/retired-settings.ts for
35
+ * why silence is the worse of the two failures and `skills config unset <key>`
36
+ * removes them.
36
37
  */
37
38
  export interface SkillsConfig {
38
39
  defaultAgent?: "claude" | "codex" | "gemini" | "pi" | "opencode" | "all";
39
40
  defaultScope?: "global" | "project";
40
41
  format?: "compact" | "json" | "csv";
41
- apiUrl?: string;
42
42
  extensionsDir?: string;
43
43
  }
44
44
  export type ConfigScope = "global" | "project";
@@ -109,9 +109,8 @@ export declare function getConfigPathReadOnly(scope: ConfigScope): string;
109
109
  * This mirrors that FILE-LEVEL precedence, never field-level merging: canonical
110
110
  * config.json, when present, is the whole global config; legacy ~/.skillsrc is read
111
111
  * only in the exact situation the write path would copy it (no canonical file, no
112
- * override). Field-level merging would inherit a stale legacy origin beneath a
113
- * canonical config that omits apiUrl and the client sends its stored credential to
114
- * whatever origin resolves, so divergence from the write path is credential-bearing.
112
+ * override). Field-level merging would inherit stale legacy values beneath a
113
+ * canonical config that omits them, so the two paths must agree exactly.
115
114
  */
116
115
  export declare function loadConfigReadOnly(): SkillsConfig;
117
116
  /**
@@ -129,10 +128,9 @@ export declare function saveConfig(key: string, value: string, scope?: ConfigSco
129
128
  /**
130
129
  * Remove a single config key from the specified scope.
131
130
  *
132
- * This is the counterpart that makes "no deployment mode" workable. Running on
133
- * this machine is the absence of a configured apiUrl, so there has to be a way
134
- * to get back to that state; previously the only way to express the intent was
135
- * to set mode=local, and that key is gone.
131
+ * This is also how a retired key written by an older version is removed: a file
132
+ * carrying one is refused by every read, so refusing to unset it would leave an
133
+ * operator with a config every command rejects and no supported repair.
136
134
  *
137
135
  * Returns whether the key was actually present, so callers can distinguish
138
136
  * "removed" from "there was nothing to remove" instead of guessing.
@@ -21,8 +21,13 @@ export interface FeedbackResult {
21
21
  export declare function getFeedbackDbPath(): string;
22
22
  export declare function saveFeedback(input: FeedbackInput): FeedbackResult;
23
23
  /**
24
- * True when this install is pointed at a Skills instance: the env var, its HASNA_-prefixed
25
- * alias, or the config file written by `skills setup --api-url` / `skills login`. The check
26
- * never throws - a broken config file means local mode, not a crash in `skills feedback`.
24
+ * True when this install talks to a Skills instance i.e. a credential resolves
25
+ * on the shared fleet ladder (lib/fleet-credentials.ts).
26
+ *
27
+ * The check never throws: `skills feedback` must not crash because the ladder is
28
+ * half-configured. A configured authority with no credential is treated as
29
+ * api mode here on purpose — the operator meant to be hosted, so feedback goes
30
+ * to the forwardable JSONL file rather than opening a local database that the
31
+ * hosted install has no business creating.
27
32
  */
28
33
  export declare function isApiMode(env?: Record<string, string | undefined>): boolean;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The one place this package decides WHICH Skills service it talks to and WITH
3
+ * WHICH credential.
4
+ *
5
+ * Everything here delegates to the shared client seam in `@hasna/contracts/client`
6
+ * (owner rulings 2026-09-04 — hasna/apps#1720, #1668, #1690, #1613, #1599). This
7
+ * package owns no second copy of the ladder, and no alias env name of its own.
8
+ *
9
+ * CREDENTIAL, resolved fresh on every call (contracts `resolveCredential`):
10
+ *
11
+ * 1. an explicit argument — `--api-key`, `--profile`
12
+ * 2. a deliberate env pointer — `HASNA_SKILLS_API_KEY_OVERRIDE`,
13
+ * `HASNA_PROFILE`, `HASNA_SKILLS_API_KEY_REF`
14
+ *
15
+ * `HASNA_SKILLS_API_KEY_REF` names a VAULT ITEM rather than a key, so it
16
+ * resolves in two steps: the synchronous chain yields the pointer, and
17
+ * `resolveSkillsApiKey()` (async) fetches the value through the secrets
18
+ * SDK on each call. Never treat the pointer's own `apiKey` — the empty
19
+ * string — as a credential; `resolveSkillsFleet` reports null for it.
20
+ * 3. the macOS Keychain — generic-password `hasna.credentials.skills.api-key`,
21
+ * account `HASNA_STATION`, else `hostname -s`, else `USER`
22
+ * 4. disk, read at call time — `~/.hasna/skills/config/credentials`
23
+ * (`HASNA_HOME` / `HASNA_CONFIG_HOME` relocate it; XDG never)
24
+ * 5. `HASNA_SKILLS_API_KEY` in the environment — a legitimate tier, below disk,
25
+ * and carrying no deprecation notice
26
+ *
27
+ * URL: `HASNA_SKILLS_API_URL` → the Keychain `api-url` item → the credentials
28
+ * file → the fleet gateway `https://api.hasna.com/skills`. The gateway default
29
+ * applies ONLY once a credential has resolved, so an install with no credential
30
+ * still names no host at all (the R1 boundary in vendor-host-policy.ts).
31
+ *
32
+ * The unprefixed `SKILLS_API_URL` / `SKILLS_API_KEY` spellings are still
33
+ * accepted, silently, because the shared seam accepts `<APP>_API_URL` /
34
+ * `<APP>_API_KEY` as documented aliases one rung below the canonical
35
+ * `HASNA_SKILLS_*` names. They are documented in the README for one release and
36
+ * are not read anywhere else in this package. `SKILL_API_KEY` (singular) is
37
+ * gone: it shadowed nothing canonical and was never documented.
38
+ *
39
+ * THREE OUTCOMES, and no fourth:
40
+ *
41
+ * - a credential resolves → HOSTED. The authority is the configured
42
+ * URL, else the fleet gateway. A credential
43
+ * that cannot produce a usable key — a
44
+ * deliberate selection contracts refuses, a
45
+ * pointer whose vault item is missing, any
46
+ * tier that yields a blank value — is a LOUD
47
+ * failure, never a demotion to local.
48
+ * - no credential, but a URL is configured
49
+ * → LOUD failure. The caller exits non-zero
50
+ * with one line naming what is missing.
51
+ * There is no local fallback here: serving
52
+ * local results while authentication is
53
+ * unconfigured is a false green.
54
+ * - neither a credential nor a URL → LOCAL. Skills is an OSS tool with a
55
+ * bundled corpus, so running on this
56
+ * machine is a real mode — and it says so,
57
+ * once, on stderr.
58
+ */
59
+ import { type CredentialChainOptions, type CredentialTier, type KeychainTierOptions, type ResolvedCredential } from "@hasna/contracts/client";
60
+ /** The app slug: the Keychain service, the `~/.hasna/<app>` folder, the gateway path. */
61
+ export declare const SKILLS_APP = "skills";
62
+ type Env = Record<string, string | undefined>;
63
+ /** `HASNA_SKILLS_API_URL`, then the accepted `SKILLS_API_URL` alias. */
64
+ export declare const SKILLS_API_URL_ENV_KEYS: readonly string[];
65
+ /** `HASNA_SKILLS_API_KEY`, then the accepted `SKILLS_API_KEY` alias. */
66
+ export declare const SKILLS_API_KEY_ENV_KEYS: readonly string[];
67
+ /** The canonical spellings, for messages that have to name exactly one. */
68
+ export declare const SKILLS_API_URL_ENV: string;
69
+ export declare const SKILLS_API_KEY_ENV: string;
70
+ export interface SkillsFleetOptions {
71
+ /** Tier-1 credential inputs and the Keychain-tier controls (a fake runner in tests). */
72
+ credentials?: CredentialChainOptions;
73
+ }
74
+ /** A hosted resolution: an authority to call and a credential to call it with. */
75
+ export interface HostedSkillsFleet {
76
+ mode: "hosted";
77
+ /** Origin the CLI/SDK sends requests to. Never carries a trailing slash. */
78
+ apiOrigin: string;
79
+ /**
80
+ * The resolved key, or null when the credential is a vault POINTER
81
+ * (`HASNA_SKILLS_API_KEY_REF` / a `credential_ref` line) that only the async
82
+ * path can complete — see {@link apiKeyPointer} and {@link resolveSkillsApiKey}.
83
+ *
84
+ * It is NEVER the empty string: a blank key would produce
85
+ * `Authorization: Bearer ` on the wire, and — read as falsy by a caller
86
+ * looking for "is there a token" — a silent drop back to local data. Both
87
+ * are refused at resolution time instead.
88
+ *
89
+ * Never logged, never written anywhere but the header.
90
+ */
91
+ apiKey: string | null;
92
+ /**
93
+ * The unresolved vault pointer, when tier === "pointer"; null otherwise.
94
+ *
95
+ * `resolveCredential` returns a TRUTHY credential for the pointer tier whose
96
+ * `apiKey` is empty and whose `pointerVaultKey` names the vault item;
97
+ * fetching that item is a separate async step. Carrying the pointer (rather
98
+ * than its empty key) is what keeps the sending paths honest.
99
+ */
100
+ apiKeyPointer: ResolvedCredential | null;
101
+ /** WHERE the URL came from: an env key NAME, a Keychain reference, a path, or "default". */
102
+ apiUrlSource: string;
103
+ /** WHERE the key came from: an env key NAME, a Keychain reference, or a path. Never a value. */
104
+ apiKeySource: string;
105
+ apiKeyTier: CredentialTier;
106
+ /** Advisory from the shared seam (never secret), or null. */
107
+ warning: string | null;
108
+ }
109
+ /** Nothing is configured: this install runs on this machine. */
110
+ export interface LocalSkillsFleet {
111
+ mode: "local";
112
+ apiOrigin: null;
113
+ apiKey: null;
114
+ }
115
+ export type SkillsFleet = HostedSkillsFleet | LocalSkillsFleet;
116
+ /** Machine-readable reasons a hosted resolution was refused. */
117
+ export type SkillsFleetErrorCode = "MISSING_API_CREDENTIAL" | "INVALID_API_URL";
118
+ /**
119
+ * A configured install could not produce a usable hosted client.
120
+ *
121
+ * `code` is stable so JSON callers can branch on it, and distinguishes the two
122
+ * refusals that are NOT the same fault: an authority with no credential
123
+ * (MISSING_API_CREDENTIAL) versus an authority that is declared but unusable
124
+ * (INVALID_API_URL). MISSING_API_URL stays the code for "nothing configured at
125
+ * all" (see MissingSkillsFleetError), which is a third, non-error state for the
126
+ * commands that may run locally.
127
+ */
128
+ export declare class SkillsFleetCredentialError extends Error {
129
+ readonly code: SkillsFleetErrorCode;
130
+ constructor(message: string, code?: SkillsFleetErrorCode);
131
+ }
132
+ /**
133
+ * Normalize a configured Skills authority to the origin the client dials.
134
+ *
135
+ * The Skills server serves its API under `/api/v1`, so the client composes
136
+ * `<origin>/api/v1/...` itself. An operator who pasted the full API base — the
137
+ * URL printed by every error message — must not end up with `/api/v1/api/v1`.
138
+ */
139
+ export declare function normalizeSkillsApiOrigin(apiUrl: string): string;
140
+ /** One configured authority: its value and the source that decided it. */
141
+ export interface ConfiguredSkillsApiUrl {
142
+ value: string;
143
+ /** An env key NAME, a `keychain:<service>@<account>` reference, or an absolute path. */
144
+ source: string;
145
+ }
146
+ /**
147
+ * The authority an operator configured, in the shared seam's precedence order —
148
+ * environment, then the Keychain `api-url` item, then the credentials file.
149
+ *
150
+ * Returns null when nothing configures one, which is what lets the gateway
151
+ * default apply for a credentialled install and what keeps an install with no
152
+ * credential from naming a host at all.
153
+ */
154
+ export declare function configuredSkillsApiUrl(env?: Env, keychain?: KeychainTierOptions): ConfiguredSkillsApiUrl | null;
155
+ /** The credential file paths consulted, for a message that has to name them. */
156
+ export declare function skillsCredentialFiles(env?: Env): string[];
157
+ /**
158
+ * Where a credential should be written, and the only file this package writes.
159
+ *
160
+ * Throws when neither HOME nor HASNA_HOME anchors a root, because there is then
161
+ * no correct place to put a secret and guessing one is worse than refusing.
162
+ */
163
+ export declare function skillsCredentialFilePath(env?: Env): string;
164
+ /**
165
+ * Say — once per process, on stderr — that this install is running locally.
166
+ *
167
+ * Local mode is legitimate for Skills: the corpus ships in the package. It is
168
+ * still announced, because "no credential resolved" and "deliberately offline"
169
+ * look identical in the output otherwise, and the first one is usually a
170
+ * misconfiguration the operator wants to hear about.
171
+ */
172
+ export declare function noticeLocalSkillsMode(write?: (line: string) => void): void;
173
+ /** Test seam: forget that the local-mode line was printed. */
174
+ export declare function resetLocalSkillsModeNotice(): void;
175
+ /**
176
+ * Resolve the service this process should use, fresh.
177
+ *
178
+ * Never returns a hosted resolution without a credential, and never degrades a
179
+ * configured authority to local mode.
180
+ */
181
+ export declare function resolveSkillsFleet(env?: Env, options?: SkillsFleetOptions): SkillsFleet;
182
+ /**
183
+ * The usable API key for this process, completing a vault pointer if that is
184
+ * the tier that won.
185
+ *
186
+ * ASYNC because the pointer tier is: the value is fetched from the secrets
187
+ * vault at call time, so a rotated item is picked up without a restart. Every
188
+ * path that is about to SEND the key resolves it here; the synchronous
189
+ * `resolveSkillsFleet` is for reporting (which tier, which source, which
190
+ * origin), and its `apiKey` is deliberately null for a pointer.
191
+ *
192
+ * Returns null only in local mode. Throws {@link SkillsFleetCredentialError}
193
+ * when a credential is configured and cannot be produced — never a fallback.
194
+ */
195
+ export declare function resolveSkillsApiKey(env?: Env, options?: SkillsFleetOptions): Promise<string | null>;
196
+ /** The usable API key, or throw naming what is missing. Use on every send path. */
197
+ export declare function requireSkillsApiKey(action?: string, env?: Env, options?: SkillsFleetOptions): Promise<string>;
198
+ /**
199
+ * The credential for a surface that reports refusals as data (an MCP tool, a
200
+ * `--json` command) rather than as an exception.
201
+ *
202
+ * `reason` is the ladder's own message when an authority is configured and no
203
+ * key resolved — a refusal carried as a value, NOT a fallback: the caller must
204
+ * still refuse. It is null only when nothing at all is configured, which is the
205
+ * ordinary "not signed in" case.
206
+ */
207
+ export declare function skillsCredentialOrReason(env?: Env, options?: SkillsFleetOptions): Promise<{
208
+ apiKey: string;
209
+ reason: null;
210
+ } | {
211
+ apiKey: null;
212
+ reason: string | null;
213
+ }>;
214
+ /**
215
+ * The authority alone, for a flow that is ACQUIRING a credential.
216
+ *
217
+ * `skills auth login` cannot require a credential — obtaining one is the point —
218
+ * so it resolves the AUTHORITY on its own: the configured URL (environment,
219
+ * Keychain, credentials file), else the authority a resolved credential implies.
220
+ * With neither, this returns null and the caller fails loudly: R1 still holds,
221
+ * an install that named no service does not get to send an email address to one.
222
+ */
223
+ export declare function resolveSkillsApiOrigin(env?: Env, options?: SkillsFleetOptions): {
224
+ origin: string;
225
+ source: string;
226
+ } | null;
227
+ /** The authority for an auth flow, or throw naming what is missing. */
228
+ export declare function requireSkillsApiOrigin(action?: string, env?: Env, options?: SkillsFleetOptions): string;
229
+ /**
230
+ * The hosted resolution, or throw. Use on every auth and write path.
231
+ *
232
+ * `action` names the caller so the message says what was refused.
233
+ */
234
+ export declare function requireSkillsFleet(action?: string, env?: Env, options?: SkillsFleetOptions): HostedSkillsFleet;
235
+ /**
236
+ * Nothing at all is configured and the caller needed a service.
237
+ *
238
+ * The message names the environment variable, the Keychain item, the credential
239
+ * file and the command — and deliberately contains no URL, so an "error" can
240
+ * never hand a caller an endpoint it refused to resolve.
241
+ */
242
+ export declare class MissingSkillsFleetError extends Error {
243
+ readonly code = "MISSING_API_URL";
244
+ constructor(action?: string);
245
+ }
246
+ export {};
@@ -11,6 +11,15 @@ export declare function defaultProvenance(sourceCommit?: string): PortableSkillP
11
11
  export declare function normalizePortableSkillName(name: string): string;
12
12
  export declare function readPortableSkillManifest(skillPath: string, fallbackName?: string): PortableSkillManifest;
13
13
  export declare function parseSkillKind(value: string | undefined): SkillKind | undefined;
14
+ /**
15
+ * The version a skill EXPLICITLY declares, or undefined when none of the sources do:
16
+ * skill.json first, then the SKILL.md frontmatter, then package.json.
17
+ *
18
+ * Distinct from `readPortableSkillManifest().version`, which falls back to
19
+ * PORTABLE_SKILL_DEFAULT_VERSION: callers that must not invent a version (publishing,
20
+ * version pinning) use this and refuse when it is undefined.
21
+ */
22
+ export declare function readDeclaredSkillVersion(skillPath: string): string | undefined;
14
23
  export declare function createInstructionManifest(name: string, options: {
15
24
  description: string;
16
25
  }): PortableSkillManifest;
@@ -1,8 +1,8 @@
1
1
  import type { SkillKind, SkillMeta } from "./registry-types.js";
2
2
  import { type SkillValidationResult } from "./skill-validation.js";
3
- import { normalizePortableSkillName, readPortableSkillManifest } from "./portable-skills-files.js";
3
+ import { normalizePortableSkillName, readDeclaredSkillVersion, readPortableSkillManifest } from "./portable-skills-files.js";
4
4
  import type { BulkPortPortableSkillOptions, BulkPortResult, PortableSkillOptions, PortableSkillRunOptions, PortableSkillRunResult, PortableSkillSummary, PortableSkillWriteResult, PortPortableSkillOptions, ScaffoldPortableSkillOptions } from "./portable-skills-types.js";
5
- export { normalizePortableSkillName, readPortableSkillManifest, };
5
+ export { normalizePortableSkillName, readDeclaredSkillVersion, readPortableSkillManifest, };
6
6
  export * from "./portable-skills-types.js";
7
7
  /**
8
8
  * Resolve the corpus: the directory holding one folder per installed skill.