@hasna/skills 0.9.20 → 0.10.1

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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Signing in and out, shared by `skills login` / `skills logout` / `skills
3
+ * whoami` and the interactive TUI's `/login`, `/logout` and `/whoami`.
4
+ *
5
+ * The flow follows the Codex CLI model the owner chose (2026-09-23): a browser
6
+ * sign-in by default, a printed device code for headless machines, an API key
7
+ * read from stdin, and a logout that revokes what the sign-in minted.
8
+ *
9
+ * Device authorization follows RFC 8628: poll no faster than the server's
10
+ * `interval`, add five seconds on every `slow_down`, and stop on
11
+ * `expired_token`, `invalid_device_code` or `access_denied`. A pending device
12
+ * session is saved beside the credentials file, owner-only, so a headless
13
+ * `skills login --device` can be finished later with `skills login --poll`
14
+ * instead of starting a new session and discarding the code the user approved.
15
+ *
16
+ * Nothing here decides WHERE to sign in: callers pass the origin from
17
+ * `resolveSkillsSignInOrigin()`, which never sends a legacy internal key to the
18
+ * product default.
19
+ */
20
+ import { type StoredCredentialOrigin, type StoredKeyIssuer } from "./auth-store.js";
21
+ import { type SkillsFleetOptions } from "./fleet-credentials.js";
22
+ type Env = Record<string, string | undefined>;
23
+ /** The CLI's device-authorization client label, recorded by the server. */
24
+ export declare const SKILLS_CLI_DEVICE_CLIENT = "skills-cli";
25
+ /** RFC 8628 §3.5: every slow_down adds five seconds to the polling interval. */
26
+ export declare const SLOW_DOWN_INCREMENT_MS = 5000;
27
+ export interface DeviceAuthorizationStart {
28
+ /** Bearer capability for the pending key. Never printed; stored owner-only. */
29
+ deviceCode: string;
30
+ userCode: string;
31
+ verificationUri: string;
32
+ verificationUriComplete?: string;
33
+ expiresIn: number;
34
+ interval: number;
35
+ }
36
+ /** What a successful sign-in returns: the key (or a session to mint one) and who signed in. */
37
+ export interface SignInResult {
38
+ apiKey?: string;
39
+ token?: string;
40
+ user?: {
41
+ id?: string;
42
+ email?: string;
43
+ role?: string;
44
+ };
45
+ organization?: {
46
+ id?: string;
47
+ slug?: string;
48
+ name?: string;
49
+ };
50
+ firstLogin?: boolean;
51
+ }
52
+ export type DevicePollOutcome = {
53
+ status: "authorized";
54
+ result: SignInResult;
55
+ } | {
56
+ status: "expired";
57
+ } | {
58
+ status: "invalid";
59
+ } | {
60
+ status: "denied";
61
+ } | {
62
+ status: "timeout";
63
+ } | {
64
+ status: "cancelled";
65
+ };
66
+ /** Validate the server's device-start answer; a malformed one is refused, never guessed at. */
67
+ export declare function parseDeviceAuthorizationStart(body: unknown): DeviceAuthorizationStart;
68
+ /** Ask the instance for a device code. Sends no credential. */
69
+ export declare function startDeviceAuthorization(origin: string, client?: string): Promise<DeviceAuthorizationStart>;
70
+ /** One poll of the token endpoint. Sends only the device code. */
71
+ export declare function pollDeviceToken(origin: string, deviceCode: string): Promise<unknown>;
72
+ export interface PollDeviceAuthorizationInput {
73
+ /** One token-endpoint request. Resolves with the body or throws HostedApiError. */
74
+ poll: () => Promise<unknown>;
75
+ intervalSeconds: number;
76
+ /** Epoch milliseconds after which polling stops with `timeout`. */
77
+ deadline: number;
78
+ signal?: AbortSignal;
79
+ /** Called with the new interval each time the server asks to slow down. */
80
+ onSlowDown?: (intervalMs: number) => void;
81
+ sleep?: (ms: number) => Promise<void>;
82
+ now?: () => number;
83
+ }
84
+ /**
85
+ * Poll until the user approves, the code expires, or the deadline passes.
86
+ *
87
+ * Transport failures and unexpected server errors are thrown, never read as
88
+ * "still pending": a sign-in that cannot reach its server must say so.
89
+ */
90
+ export declare function pollDeviceAuthorization(input: PollDeviceAuthorizationInput): Promise<DevicePollOutcome>;
91
+ export interface PendingDeviceSignIn {
92
+ origin: string;
93
+ deviceCode: string;
94
+ userCode: string;
95
+ verificationUri: string;
96
+ verificationUriComplete?: string;
97
+ interval: number;
98
+ /** Epoch milliseconds. */
99
+ expiresAt: number;
100
+ }
101
+ /**
102
+ * Beside the credentials file, one per profile AND instance:
103
+ * `device-login[-<profile>]-<origin hash>.json`. Keyed by origin so a sign-in
104
+ * started on one server never overwrites a pending session for another.
105
+ */
106
+ export declare function pendingDeviceSignInPath(origin: string, env?: Env): string;
107
+ export declare function savePendingDeviceSignIn(origin: string, start: DeviceAuthorizationStart, env?: Env, now?: number): string;
108
+ /**
109
+ * The saved session for exactly this instance, if it has not expired. A session
110
+ * saved for a different instance is never returned, so its device code is only
111
+ * ever sent back to the server that issued it.
112
+ */
113
+ export declare function loadPendingDeviceSignIn(origin: string, env?: Env, now?: number): PendingDeviceSignIn | null;
114
+ /** The instances with an unexpired pending sign-in for this profile. */
115
+ export declare function pendingDeviceSignInOrigins(env?: Env, now?: number): string[];
116
+ /** Forget the pending session for one instance, or every pending session of this profile. */
117
+ export declare function clearPendingDeviceSignIn(env?: Env, origin?: string): void;
118
+ /**
119
+ * Store the key a sign-in produced, bound to the instance that issued it.
120
+ * A session-only answer (a token, no key) mints a CLI key first.
121
+ */
122
+ export declare function persistSignIn(result: SignInResult, origin: string, env?: Env, issuedBy?: StoredKeyIssuer): Promise<string>;
123
+ /**
124
+ * What happened to server-side revocation.
125
+ *
126
+ * revoked — the server confirmed it revoked the key.
127
+ * already_ended — the server no longer accepts the key (HTTP 401/403).
128
+ * not_requested — a user-brought key deleted without --revoke (still works).
129
+ * skipped — --no-revoke on a login-minted key (may still be live).
130
+ * unsupported — cannot be revoked: the server has no revoke route
131
+ * (404/405/501), the internal gateway has no login service,
132
+ * or an older sign-in left no record of how it was issued.
133
+ * not_revoked — the server answered without confirming revocation.
134
+ * failed — the request did not complete (network, 5xx, bad answer).
135
+ * none — no credential stored by `skills login` to revoke.
136
+ */
137
+ export type RevocationOutcome = "revoked" | "already_ended" | "not_requested" | "skipped" | "unsupported" | "not_revoked" | "failed" | "none";
138
+ /** A stable reason code plus one plain sentence. Never contains a credential. */
139
+ export interface SignOutReason {
140
+ code: "revocation_failed" | "revocation_not_confirmed" | "revocation_unsupported" | "revocation_skipped" | "credential_not_from_login" | "credential_still_active" | "local_delete_failed";
141
+ message: string;
142
+ }
143
+ export interface SignOutResult {
144
+ /** Exit 0 when true: every reason list is empty. */
145
+ signedOut: boolean;
146
+ /** What happened to the active profile's stored credential. */
147
+ stored: "deleted" | "none" | "left_alone" | "delete_failed";
148
+ storedBy?: StoredCredentialOrigin;
149
+ /** The credentials file, when one holds a credential. */
150
+ file?: string;
151
+ /** The instance the stored credential belongs to. */
152
+ origin?: string;
153
+ revocation: RevocationOutcome;
154
+ /** Sentences for a human: what was done, in order. */
155
+ notes: string[];
156
+ /** Why the command must exit non-zero. Empty when signed out. */
157
+ reasons: SignOutReason[];
158
+ }
159
+ export interface SignOutOptions {
160
+ /**
161
+ * true: also revoke a user-brought or legacy key (`--revoke`).
162
+ * false: revoke nothing (`--no-revoke`); a login-minted key left live exits non-zero.
163
+ * undefined: the default — revoke only what login minted.
164
+ */
165
+ revoke?: boolean;
166
+ env?: Env;
167
+ /** Resolution controls (a fake Keychain in tests). */
168
+ fleet?: SkillsFleetOptions;
169
+ /**
170
+ * A profile the ENVIRONMENT selects that differs from the active one, e.g.
171
+ * `HASNA_PROFILE=b skills --profile a logout`. If it still holds a credential,
172
+ * the next plain command uses it, so logout names it and exits non-zero.
173
+ */
174
+ environmentProfile?: string;
175
+ }
176
+ /** Where a person revokes a key by hand. Never a composed URL. */
177
+ export declare function manualRevocationHint(origin: string): string;
178
+ export declare function signOut(options?: SignOutOptions): Promise<SignOutResult>;
179
+ export type SignedInAccount = {
180
+ signedIn: false;
181
+ reason: string;
182
+ } | {
183
+ signedIn: true;
184
+ apiOrigin: string;
185
+ source: string;
186
+ email?: string;
187
+ organization?: string;
188
+ error?: string;
189
+ };
190
+ /** The account in effect, for a compact display (the TUI's `/whoami`). */
191
+ export declare function readSignedInAccount(env?: Env): Promise<SignedInAccount>;
192
+ /**
193
+ * The verification page to open, or null when it must only be printed.
194
+ *
195
+ * The URL comes from the server's answer, so it is opened only when it is an
196
+ * https URL (http only on loopback) with no userinfo, on the sign-in origin
197
+ * itself or on `auth.<sign-in host>` — where rule global-product-auth-url-layout
198
+ * will move the identity endpoints. Anything else is printed for the user to
199
+ * judge and never handed to the operating system.
200
+ */
201
+ export declare function verificationUrlToOpen(raw: string, signInOrigin: string): string | null;
202
+ /**
203
+ * The opener argv for a platform. Never a shell: on Windows `cmd /c start`
204
+ * would re-parse `&`, `|` and `^` in a server-provided URL, so the URL goes to
205
+ * the URL protocol handler as one argument instead.
206
+ */
207
+ export declare function browserCommand(url: string, platform?: NodeJS.Platform): string[];
208
+ /**
209
+ * Best effort: the URL and code are always printed too, so a refusal or a
210
+ * failure here costs nothing. Returns whether a browser was asked to open it.
211
+ */
212
+ export declare function openVerificationPage(url: string, signInOrigin: string): boolean;
213
+ export {};
@@ -69,6 +69,32 @@ export declare const VENDOR_HOST_URL_EXCEPTIONS: readonly {
69
69
  url: string;
70
70
  reason: string;
71
71
  }[];
72
+ /**
73
+ * The product default: the one vendor URL an UNCONFIGURED install may name.
74
+ *
75
+ * Owner rulings 2026-09-23 (Todos PLA8-00366) made the product the default place
76
+ * to SIGN IN, superseding R1 for that one path: with no URL and no credential,
77
+ * `skills login` targets this origin. Every data surface still fails closed with
78
+ * no URL at all, and a credential that already resolves keeps its own instance
79
+ * (see unconfigured-client-boundary.test.ts and skills-md-default.test.ts).
80
+ *
81
+ * Unlike {@link VENDOR_HOST_URL_EXCEPTIONS}, this exception is scoped to FILES as
82
+ * well as to the exact URL. The origin is declared once, in `src/lib/product-default.ts`;
83
+ * it may appear in the two bundles built from the CLI and MCP entry points that
84
+ * import it, and in the README that documents it. The same URL anywhere else —
85
+ * a server default, a skill, a split or folded literal — is still a finding, so
86
+ * a second copy cannot quietly become another default. The URL is not repeated
87
+ * in this file: it is the imported constant.
88
+ */
89
+ export declare const PRODUCT_DEFAULT_URL_EXCEPTION: {
90
+ url: string;
91
+ files: readonly string[];
92
+ reason: string;
93
+ };
94
+ /** Is this exact URL excepted in this exact file? Global exceptions, then the scoped product default. */
95
+ export declare function isExceptedVendorUrl(url: string, file: string): boolean;
96
+ /** Every excepted URL for one file, for the value-independent token scan to strip. */
97
+ export declare function exceptedVendorUrlsFor(file: string | undefined): string[];
72
98
  /** Reduce a hostname to its registrable domain (approximate eTLD+1). */
73
99
  export declare function registrableDomain(host: string): string;
74
100
  export declare function isLoopbackHost(host: string): boolean;
@@ -65,7 +65,7 @@ export interface VendorHostFinding {
65
65
  /**
66
66
  * Find known vendor domains even when they do not appear in a complete URL.
67
67
  */
68
- export declare function findVendorDomainTokens(text: string): {
68
+ export declare function findVendorDomainTokens(text: string, file?: string): {
69
69
  domain: string;
70
70
  index: number;
71
71
  }[];
package/dist/sdk/index.js CHANGED
@@ -32373,7 +32373,7 @@ var init_event_sink = __esm(() => {
32373
32373
  // package.json
32374
32374
  var package_default = {
32375
32375
  name: "@hasna/skills",
32376
- version: "0.9.20",
32376
+ version: "0.10.1",
32377
32377
  description: "Skills library for AI coding agents",
32378
32378
  type: "module",
32379
32379
  bin: {
@@ -33390,9 +33390,17 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
33390
33390
 
33391
33391
  // src/lib/instance-credentials.ts
33392
33392
  import { closeSync as closeSync3, constants, fstatSync as fstatSync3, lstatSync as lstatSync2, openSync as openSync3, readSync } from "fs";
33393
+
33394
+ // src/lib/cli-credential-profile.ts
33395
+ var cliCredentialProfile;
33396
+ function selectedCliSkillsCredentialProfile() {
33397
+ return cliCredentialProfile;
33398
+ }
33399
+
33400
+ // src/lib/instance-credentials.ts
33393
33401
  var SKILLS_BOUND_API_URL = "HASNA_SKILLS_BOUND_API_URL";
33394
33402
  function selectedSkillsProfile(env, explicit) {
33395
- const selected = explicit ?? env.HASNA_PROFILE;
33403
+ const selected = explicit ?? selectedCliSkillsCredentialProfile() ?? env.HASNA_PROFILE;
33396
33404
  if (selected === undefined)
33397
33405
  return null;
33398
33406
  const profile = selected.trim();
@@ -33505,13 +33513,15 @@ var SKILLS_API_URL_ENV_KEYS = ENV_KEYS.apiUrlKeys;
33505
33513
  var SKILLS_API_KEY_ENV_KEYS = ENV_KEYS.apiKeyKeys;
33506
33514
  var SKILLS_API_URL_ENV = SKILLS_API_URL_ENV_KEYS[0];
33507
33515
  var SKILLS_API_KEY_ENV = SKILLS_API_KEY_ENV_KEYS[0];
33508
-
33509
33516
  class SkillsFleetCredentialError extends Error {
33510
33517
  code;
33511
- constructor(message, code = "MISSING_API_CREDENTIAL") {
33518
+ next;
33519
+ constructor(message, code = "MISSING_API_CREDENTIAL", next) {
33512
33520
  super(message);
33513
33521
  this.name = "SkillsFleetCredentialError";
33514
33522
  this.code = code;
33523
+ if (next)
33524
+ this.next = next;
33515
33525
  }
33516
33526
  }
33517
33527
  function isCredentialResolutionError(error) {
@@ -33569,7 +33579,7 @@ function configuredSkillsApiUrl(env = process.env, keychain, profile) {
33569
33579
  if (!entry.value.trim() || /[\x00-\x1f\x7f]/.test(entry.value))
33570
33580
  throw new SkillsFleetCredentialError(`${entry.key} is blank or contains control characters`, "INVALID_API_URL");
33571
33581
  }
33572
- const normalized = declared.map((entry) => ({ value: normalizeSkillsApiOrigin(entry.value), source: entry.key }));
33582
+ const normalized = declared.map((entry) => ({ value: normalizeSkillsApiOrigin(entry.value), source: entry.key, trust: "environment" }));
33573
33583
  if (new Set(normalized.map((entry) => entry.value)).size > 1)
33574
33584
  throw new SkillsFleetCredentialError("Skills API URL aliases disagree", "INVALID_API_URL");
33575
33585
  if (normalized[0])
@@ -33578,19 +33588,19 @@ function configuredSkillsApiUrl(env = process.env, keychain, profile) {
33578
33588
  for (const file of skillsProfileCredentialFiles(env, profile)) {
33579
33589
  const metadata = readSkillsInstanceMetadata(file);
33580
33590
  if (metadata.apiUrl || metadata.binding)
33581
- return { value: metadata.apiUrl ?? metadata.binding, source: file };
33591
+ return { value: metadata.apiUrl ?? metadata.binding, source: file, trust: "file" };
33582
33592
  }
33583
- return { value: defaultFleetGatewayBaseUrl(SKILLS_APP), source: "default" };
33593
+ return { value: defaultFleetGatewayBaseUrl(SKILLS_APP), source: "default", trust: "default" };
33584
33594
  }
33585
33595
  const fromKeychain = keychainConfigValue(SKILLS_APP, env, keychain);
33586
33596
  if (fromKeychain)
33587
- return { value: fromKeychain.value.trim(), source: fromKeychain.source };
33597
+ return { value: fromKeychain.value.trim(), source: fromKeychain.source, trust: "keychain" };
33588
33598
  const fromDisk = appConfigDiskValue(SKILLS_APP, env, SKILLS_API_URL_ENV_KEYS);
33589
33599
  if (fromDisk?.unusable) {
33590
33600
  throw new SkillsFleetCredentialError(`${fromDisk.key} in ${fromDisk.path} is declared but blank or malformed; ` + `a Skills authority must be a valid https URL (or an exact loopback http URL).`, "INVALID_API_URL");
33591
33601
  }
33592
33602
  if (fromDisk)
33593
- return { value: fromDisk.value.trim(), source: fromDisk.path };
33603
+ return { value: fromDisk.value.trim(), source: fromDisk.path, trust: "file" };
33594
33604
  return null;
33595
33605
  }
33596
33606
  function skillsCredentialFiles(env = process.env) {
@@ -33638,11 +33648,13 @@ function snapshotSkillsEnvironment(env) {
33638
33648
  return Object.freeze(snapshot);
33639
33649
  }
33640
33650
  function snapshotSkillsOptions(env, options) {
33651
+ const profile = options.credentials?.profile ?? selectedCliSkillsCredentialProfile();
33652
+ const credentials = profile === undefined ? options.credentials : { ...options.credentials, profile };
33641
33653
  if (env !== process.env)
33642
- return options;
33643
- return { ...options, credentials: { ...options.credentials, keychain: {
33644
- ...options.credentials?.keychain,
33645
- enabled: options.credentials?.keychain?.enabled ?? true
33654
+ return credentials === options.credentials ? options : { ...options, credentials };
33655
+ return { ...options, credentials: { ...credentials, keychain: {
33656
+ ...credentials?.keychain,
33657
+ enabled: credentials?.keychain?.enabled ?? true
33646
33658
  } } };
33647
33659
  }
33648
33660
  function resolveSkillsFleetOrThrow(env, options) {
@@ -33653,13 +33665,13 @@ function resolveSkillsFleetOrThrow(env, options) {
33653
33665
  const credential = resolveCredential(SKILLS_APP, env, options.credentials);
33654
33666
  if (!credential) {
33655
33667
  if (!configured) {
33656
- throw new SkillsFleetCredentialError(`No API key resolved and no Skills API URL is configured \u2014 failing closed ` + `(local mode is opt-in only: set ${SKILLS_LOCAL_OPT_IN_ENV_KEYS[0]}=1 to run on this machine). ` + `Looked in ${credentialLocations(env)}. Sign in with: skills auth login`);
33668
+ throw new SkillsFleetCredentialError(`No API key resolved and no Skills API URL is configured \u2014 failing closed ` + `(local mode is opt-in only: set ${SKILLS_LOCAL_OPT_IN_ENV_KEYS[0]}=1 to run on this machine). ` + `Looked in ${credentialLocations(env)}. Sign in with: skills login`);
33657
33669
  }
33658
- throw new SkillsFleetCredentialError(`${configured.source} points this CLI at a Skills service but no API key resolved \u2014 refusing to run locally instead. ` + `Looked in ${credentialLocations(env)}. Sign in with: skills auth login`);
33670
+ throw new SkillsFleetCredentialError(`${configured.source} points this CLI at a Skills service but no API key resolved \u2014 refusing to run locally instead. ` + `Looked in ${credentialLocations(env)}. Sign in with: skills login`);
33659
33671
  }
33660
33672
  const apiOrigin = normalizeSkillsApiOrigin(configured?.value ?? defaultFleetGatewayBaseUrl(SKILLS_APP));
33661
33673
  toV1BaseUrl(apiOrigin);
33662
- assertCredentialInstance(credential, apiOrigin, env, options);
33674
+ assertCredentialInstancePairing(credential, configured, apiOrigin, env, options);
33663
33675
  assertFilesUnchanged();
33664
33676
  const base = {
33665
33677
  mode: "hosted",
@@ -33673,7 +33685,7 @@ function resolveSkillsFleetOrThrow(env, options) {
33673
33685
  return { ...base, apiKey: null, apiKeyPointer: credential };
33674
33686
  }
33675
33687
  if (!credential.apiKey.trim()) {
33676
- throw new SkillsFleetCredentialError(`The Skills API key from ${credential.source} is empty \u2014 refusing to send an unauthenticated request. ` + `Sign in with: skills auth login`);
33688
+ throw new SkillsFleetCredentialError(`The Skills API key from ${credential.source} is empty \u2014 refusing to send an unauthenticated request. ` + `Sign in with: skills login`);
33677
33689
  }
33678
33690
  return { ...base, apiKey: credential.apiKey, apiKeyPointer: null };
33679
33691
  }
@@ -33681,25 +33693,58 @@ function credentialLocations(env) {
33681
33693
  const files = skillsCredentialFiles(env).join(" or ") || "no credentials file (HOME is unset)";
33682
33694
  return `hasna.credentials.${SKILLS_APP}.api-key (macOS Keychain, account HASNA_STATION or the host name), ${files}, and ${SKILLS_API_KEY_ENV}`;
33683
33695
  }
33684
- function assertCredentialInstance(credential, apiOrigin, env, options) {
33685
- let bound;
33686
- if (credential.tier === "disk" || credential.tier === "profile" || credential.tier === "pointer" && credential.diskCandidates.includes(credential.source)) {
33687
- const metadata = readSkillsInstanceMetadata(credential.source);
33688
- bound = metadata.binding ?? metadata.apiUrl ?? defaultFleetGatewayBaseUrl(SKILLS_APP);
33689
- } else if (credential.tier === "keychain") {
33690
- bound = keychainConfigValue(SKILLS_APP, env, options.credentials?.keychain)?.value ?? defaultFleetGatewayBaseUrl(SKILLS_APP);
33696
+ function credentialPairing(credential, env, options = {}) {
33697
+ const fileBinding = (file) => {
33698
+ const metadata = readSkillsInstanceMetadata(file);
33699
+ return { kind: "bound", instance: normalizeSkillsApiOrigin(metadata.binding ?? metadata.apiUrl ?? defaultFleetGatewayBaseUrl(SKILLS_APP)) };
33700
+ };
33701
+ const tier = credential.tier;
33702
+ switch (tier) {
33703
+ case "disk":
33704
+ case "profile":
33705
+ return fileBinding(credential.source);
33706
+ case "pointer":
33707
+ return credential.diskCandidates.includes(credential.source) ? fileBinding(credential.source) : { kind: "unbound" };
33708
+ case "keychain":
33709
+ return {
33710
+ kind: "bound",
33711
+ instance: normalizeSkillsApiOrigin(keychainConfigValue(SKILLS_APP, env, options.credentials?.keychain)?.value ?? defaultFleetGatewayBaseUrl(SKILLS_APP))
33712
+ };
33713
+ case "env":
33714
+ case "override":
33715
+ case "argument":
33716
+ return { kind: "unbound" };
33717
+ default: {
33718
+ const unclassified = tier;
33719
+ throw new SkillsFleetCredentialError(`The Skills credential from ${credential.source} has an unclassified tier (${String(unclassified)}); refusing to send it.`, "INSTANCE_CREDENTIAL_MISMATCH");
33720
+ }
33691
33721
  }
33692
- if (bound && normalizeSkillsApiOrigin(bound) !== apiOrigin) {
33722
+ }
33723
+ function assertCredentialInstancePairing(credential, configured, apiOrigin, env, options) {
33724
+ const pairing = credentialPairing(credential, env, options);
33725
+ if (pairing.kind === "bound") {
33726
+ if (pairing.instance === apiOrigin)
33727
+ return;
33693
33728
  throw new SkillsFleetCredentialError("The selected Skills API does not match this credential's instance. Select its profile or sign in to the new instance; no credential was sent.", "INSTANCE_CREDENTIAL_MISMATCH");
33694
33729
  }
33730
+ if (apiOrigin === normalizeSkillsApiOrigin(defaultFleetGatewayBaseUrl(SKILLS_APP)))
33731
+ return;
33732
+ if (configured?.trust === "environment")
33733
+ return;
33734
+ const next = [
33735
+ `set ${SKILLS_API_URL_ENV} alongside ${credential.source}`,
33736
+ `unset ${credential.source}, then run: skills login`
33737
+ ];
33738
+ throw new SkillsFleetCredentialError(`${credential.source} records no Skills server of its own, so it is only sent to the internal gateway or to a URL ` + `from the environment (${SKILLS_API_URL_ENV}); the Skills URL here comes from ${configured?.source ?? "nowhere"}. ` + `No credential was sent. To use your own server, ${next[0]}; otherwise ${next[1]}.`, "INSTANCE_CREDENTIAL_MISMATCH", next);
33695
33739
  }
33696
33740
  async function resolveSkillsApiKey(env = process.env, options = {}) {
33697
33741
  return (await resolveSkillsConnection(env, options))?.apiKey ?? null;
33698
33742
  }
33699
33743
  async function resolveSkillsConnection(env = process.env, options = {}) {
33700
33744
  const snapshotEnv = snapshotSkillsEnvironment(env);
33701
- const assertFilesUnchanged = selectsSkillsLocalMode(snapshotEnv) ? () => {} : captureSkillsCredentialFiles(skillsProfileCredentialFiles(snapshotEnv, options.credentials?.profile));
33702
- const fleet = resolveSkillsFleet(snapshotEnv, snapshotSkillsOptions(env, options));
33745
+ const resolvedOptions = snapshotSkillsOptions(env, options);
33746
+ const assertFilesUnchanged = selectsSkillsLocalMode(snapshotEnv) ? () => {} : captureSkillsCredentialFiles(skillsProfileCredentialFiles(snapshotEnv, resolvedOptions.credentials?.profile));
33747
+ const fleet = resolveSkillsFleet(snapshotEnv, resolvedOptions);
33703
33748
  if (fleet.mode === "local" && env === process.env)
33704
33749
  noticeLocalSkillsMode();
33705
33750
  if (fleet.mode !== "hosted")
@@ -33708,7 +33753,7 @@ async function resolveSkillsConnection(env = process.env, options = {}) {
33708
33753
  return { ...fleet, apiKey: fleet.apiKey };
33709
33754
  const pointer = fleet.apiKeyPointer;
33710
33755
  if (!pointer) {
33711
- throw new SkillsFleetCredentialError(`A Skills authority resolved but no API key did. Sign in with: skills auth login`);
33756
+ throw new SkillsFleetCredentialError(`A Skills authority resolved but no API key did. Sign in with: skills login`);
33712
33757
  }
33713
33758
  let completed;
33714
33759
  try {
@@ -33775,7 +33820,7 @@ function requireSkillsFleet(action = "This command", env = process.env, options
33775
33820
  class MissingSkillsFleetError extends Error {
33776
33821
  code = "MISSING_API_URL";
33777
33822
  constructor(action = "This command") {
33778
- super(`${action} requires a Skills API credential and none is configured \u2014 ` + `run: skills auth login, or set ${SKILLS_API_KEY_ENV} ` + `(add the Keychain item hasna.credentials.${SKILLS_APP}.api-key, or write ~/.hasna/skills/config/credentials). ` + `Point at your own instance with ${SKILLS_API_URL_ENV}, or run: skills setup --api-url <your Skills instance origin>`);
33823
+ super(`${action} requires a Skills API credential and none is configured \u2014 ` + `run: skills login, or set ${SKILLS_API_KEY_ENV} ` + `(add the Keychain item hasna.credentials.${SKILLS_APP}.api-key, or write ~/.hasna/skills/config/credentials). ` + `Point at your own instance with ${SKILLS_API_URL_ENV}, or run: skills setup --api-url <your Skills instance origin>`);
33779
33824
  this.name = "MissingSkillsFleetError";
33780
33825
  }
33781
33826
  }
@@ -90557,7 +90602,7 @@ class RemoteSkillsAuthClient {
90557
90602
  return (await this.sessionClient(email4, code, context)).removeWorkspaceMember(captured.membershipId, captured.body);
90558
90603
  }
90559
90604
  request(path2, options) {
90560
- if (!["/api/auth/login", "/api/auth/verify", "/api/auth/device/start", "/api/auth/device/token", "/api/auth/keys", "/api/auth/whoami"].includes(path2))
90605
+ if (!["/api/auth/login", "/api/auth/verify", "/api/auth/device/start", "/api/auth/device/token", "/api/auth/keys", "/api/auth/whoami", "/api/auth/logout"].includes(path2))
90561
90606
  throw new Error("Unsupported authentication operation");
90562
90607
  return requestAuthApi(this.apiOrigin, path2, options);
90563
90608
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/skills",
3
- "version": "0.9.20",
3
+ "version": "0.10.1",
4
4
  "description": "Skills library for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {