@hasna/skills 0.3.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -1221,6 +1221,16 @@ function isHostedMetadataSkill(skillName, frontmatter, registryMeta, packageDecl
1221
1221
  return true;
1222
1222
  return false;
1223
1223
  }
1224
+ function frontmatterString(raw) {
1225
+ if (raw.startsWith('"') && raw.endsWith('"')) {
1226
+ try {
1227
+ const decoded = JSON.parse(raw);
1228
+ if (typeof decoded === "string")
1229
+ return decoded;
1230
+ } catch {}
1231
+ }
1232
+ return raw.replace(/^["']|["']$/g, "");
1233
+ }
1224
1234
  function parseSkillFrontmatter(content) {
1225
1235
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1226
1236
  if (!match)
@@ -1240,12 +1250,12 @@ function parseSkillFrontmatter(content) {
1240
1250
  const tags = [];
1241
1251
  while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
1242
1252
  i++;
1243
- tags.push(lines[i].replace(/^\s+-\s+/, "").trim());
1253
+ tags.push(frontmatterString(lines[i].replace(/^\s+-\s+/, "").trim()));
1244
1254
  }
1245
1255
  result.tags = tags;
1246
1256
  continue;
1247
1257
  }
1248
- const value = rawValue.replace(/^["']|["']$/g, "");
1258
+ const value = frontmatterString(rawValue);
1249
1259
  if (!value)
1250
1260
  continue;
1251
1261
  if (key === "name")
@@ -1878,8 +1888,8 @@ function createInstructionManifest(name, options) {
1878
1888
  description: options.description,
1879
1889
  version: PORTABLE_SKILL_DEFAULT_VERSION,
1880
1890
  displayName: displayName(name),
1881
- category: "Development Tools",
1882
- tags: ["custom", name],
1891
+ category: options.category ?? "Development Tools",
1892
+ tags: options.tags ?? ["custom", name],
1883
1893
  kind: "instruction",
1884
1894
  inputs: [],
1885
1895
  commands: [],
@@ -1894,16 +1904,16 @@ function writeInstructionSkillTemplate(skillPath, manifest) {
1894
1904
  }
1895
1905
  function renderInstructionSkillMd(manifest) {
1896
1906
  const tags = manifest.tags?.length ? `tags:
1897
- ${manifest.tags.map((tag) => ` - ${tag}`).join(`
1907
+ ${manifest.tags.map((tag) => ` - ${yamlString(tag)}`).join(`
1898
1908
  `)}
1899
1909
  ` : "";
1900
1910
  return `---
1901
1911
  name: ${manifest.name}
1902
- description: ${manifest.description}
1912
+ description: ${yamlString(manifest.description)}
1903
1913
  kind: instruction
1904
1914
  version: ${manifest.version}
1905
1915
  source: custom
1906
- category: ${manifest.category ?? "Development Tools"}
1916
+ category: ${yamlString(manifest.category ?? "Development Tools")}
1907
1917
  ${tags}---
1908
1918
 
1909
1919
  # ${manifest.displayName ?? displayName(manifest.name)}
@@ -1924,8 +1934,8 @@ function createPortableManifest(name, options) {
1924
1934
  description: options.description,
1925
1935
  version: PORTABLE_SKILL_DEFAULT_VERSION,
1926
1936
  displayName: displayName(name),
1927
- category: "Development Tools",
1928
- tags: ["custom", name],
1937
+ category: options.category ?? "Development Tools",
1938
+ tags: options.tags ?? ["custom", name],
1929
1939
  inputs: DEFAULT_INPUTS,
1930
1940
  commands: [{
1931
1941
  name,
@@ -2116,10 +2126,13 @@ function isExcludedCopyEntry(name, isFirstSegment) {
2116
2126
  return true;
2117
2127
  return false;
2118
2128
  }
2129
+ function yamlString(value) {
2130
+ return JSON.stringify(value);
2131
+ }
2119
2132
  function renderSkillMd(manifest) {
2120
2133
  return `---
2121
2134
  name: ${manifest.name}
2122
- description: ${manifest.description}
2135
+ description: ${yamlString(manifest.description)}
2123
2136
  ---
2124
2137
 
2125
2138
  # ${manifest.displayName ?? displayName(manifest.name)}
@@ -2502,11 +2515,11 @@ function scaffoldPortableSkill(name, options = {}) {
2502
2515
  const kind = options.kind ?? "executable";
2503
2516
  const description = options.description ?? `${displayName(skillName)} skill`;
2504
2517
  if (kind === "instruction") {
2505
- const manifest2 = createInstructionManifest(skillName, { description });
2518
+ const manifest2 = createInstructionManifest(skillName, { description, category: options.category, tags: options.tags });
2506
2519
  writeInstructionSkillTemplate(skillPath, manifest2);
2507
2520
  return { name: skillName, path: skillPath, manifest: manifest2, created: true };
2508
2521
  }
2509
- const manifest = createPortableManifest(skillName, { description });
2522
+ const manifest = createPortableManifest(skillName, { description, category: options.category, tags: options.tags });
2510
2523
  writePortableSkillTemplate(skillPath, manifest);
2511
2524
  return { name: skillName, path: skillPath, manifest, created: true };
2512
2525
  }
@@ -3950,6 +3963,7 @@ function createSkillRun(params, targetDir = process.cwd()) {
3950
3963
  startedAt: now.toISOString(),
3951
3964
  remote: params.remote ?? false,
3952
3965
  ...params.remoteRunId ? { remoteRunId: params.remoteRunId } : {},
3966
+ ...params.remoteApiOrigin ? { remoteApiOrigin: params.remoteApiOrigin } : {},
3953
3967
  ...params.costCents !== undefined ? { costCents: params.costCents } : {},
3954
3968
  artifacts: [],
3955
3969
  paths: {
@@ -8964,7 +8978,6 @@ async function completePointerCredential(name, pointerResolution, env = process.
8964
8978
  });
8965
8979
  }
8966
8980
  var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
8967
- var DEFAULT_AUTHORITY_SOURCE = "default";
8968
8981
  function defaultFleetGatewayBaseUrl(name) {
8969
8982
  return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
8970
8983
  }
@@ -9083,102 +9096,6 @@ function toV1BaseUrl(apiUrl) {
9083
9096
  url.pathname = `${path}/v1`;
9084
9097
  return url.toString().replace(/\/+$/, "");
9085
9098
  }
9086
- class ClientTransportConfigurationError extends Error {
9087
- appName;
9088
- sources;
9089
- constructor(appName, message, sources = []) {
9090
- super(message);
9091
- this.name = "ClientTransportConfigurationError";
9092
- this.appName = appName;
9093
- this.sources = Object.freeze([...sources]);
9094
- }
9095
- }
9096
- function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
9097
- env = snapshotClientEnvironment(name, env);
9098
- const keys = clientTransportEnvKeys(name);
9099
- const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
9100
- const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
9101
- if (blankUrl) {
9102
- throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
9103
- }
9104
- const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
9105
- if (controlledUrl) {
9106
- throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
9107
- }
9108
- const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
9109
- if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
9110
- throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
9111
- }
9112
- const envUrlHit = usableUrlEntries[0] ?? null;
9113
- const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
9114
- const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
9115
- if (diskConfigUrlHit?.unusable) {
9116
- throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
9117
- }
9118
- const urlCandidates = [
9119
- ...envUrlHit ? [envUrlHit] : [],
9120
- ...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
9121
- ...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
9122
- ];
9123
- const configuredUrl = urlCandidates[0] ?? null;
9124
- const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
9125
- if (configuredUrl && divergentUrls.length > 0) {
9126
- throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
9127
- }
9128
- const warnings = [];
9129
- if (configuredUrl && !envUrlHit) {
9130
- warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
9131
- }
9132
- const credential = resolveCredential(name, env, options.credentials);
9133
- if (!credential) {
9134
- const diskHint = credentialDiskSourcesForMessage(name, env);
9135
- const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
9136
- warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
9137
- throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
9138
- }
9139
- if (credential.warning)
9140
- warnings.push(credential.warning);
9141
- let urlHit;
9142
- if (configuredUrl) {
9143
- urlHit = configuredUrl;
9144
- } else {
9145
- try {
9146
- urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
9147
- } catch (error) {
9148
- const message = error instanceof Error ? error.message : String(error);
9149
- throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
9150
- }
9151
- }
9152
- const apiUrlSource = urlHit.key;
9153
- let baseUrl;
9154
- try {
9155
- baseUrl = toV1BaseUrl(urlHit.value);
9156
- } catch (error) {
9157
- const message = error instanceof Error ? error.message : String(error);
9158
- throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
9159
- }
9160
- return {
9161
- resolution: {
9162
- transport: "http",
9163
- transportSource: urlHit.key,
9164
- baseUrl,
9165
- apiUrlSource,
9166
- apiKeyPresent: true,
9167
- apiKeySource: credential.source,
9168
- apiKeyTier: credential.tier,
9169
- misconfigured: false,
9170
- warning: warnings.length > 0 ? warnings.join(" ") : null
9171
- },
9172
- credential
9173
- };
9174
- }
9175
- function resolveClientTransport(name, env = process.env, options = {}) {
9176
- return resolveClientTransportSnapshot(name, env, options).resolution;
9177
- }
9178
- function credentialDiskSourcesForMessage(name, env) {
9179
- const paths = credentialDiskSources(name, env);
9180
- return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
9181
- }
9182
9099
  var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
9183
9100
  var AUTHORITY_OVERRIDE_HEADERS = new Set([
9184
9101
  "host",
@@ -9188,6 +9105,94 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
9188
9105
  "x-original-host"
9189
9106
  ]);
9190
9107
 
9108
+ // src/lib/instance-credentials.ts
9109
+ import { closeSync as closeSync2, constants, fstatSync as fstatSync2, lstatSync as lstatSync3, openSync as openSync2, readSync } from "fs";
9110
+ var SKILLS_BOUND_API_URL = "HASNA_SKILLS_BOUND_API_URL";
9111
+ function selectedSkillsProfile(env, explicit) {
9112
+ const selected = explicit ?? env.HASNA_PROFILE;
9113
+ if (selected === undefined)
9114
+ return null;
9115
+ const profile = selected.trim();
9116
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(profile))
9117
+ throw new Error("Invalid Skills credential profile");
9118
+ return profile;
9119
+ }
9120
+ function skillsProfileCredentialFiles(env, explicit) {
9121
+ return credentialDiskSourceList("skills", env, selectedSkillsProfile(env, explicit)).map((source) => source.path);
9122
+ }
9123
+ function fileIdentity(file) {
9124
+ try {
9125
+ return lstatSync3(file);
9126
+ } catch (error) {
9127
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
9128
+ return null;
9129
+ throw new Error("Cannot inspect Skills instance configuration");
9130
+ }
9131
+ }
9132
+ function unchanged(before, after) {
9133
+ return before === null || after === null ? before === after : ["dev", "ino", "size", "mtimeMs", "ctimeMs", "mode", "uid"].every((key) => before[key] === after[key]);
9134
+ }
9135
+ function captureSkillsCredentialFiles(files) {
9136
+ const identities = files.map((file) => [file, fileIdentity(file)]);
9137
+ return () => {
9138
+ if (identities.some(([file, before]) => !unchanged(before, fileIdentity(file)))) {
9139
+ throw new Error("Skills instance configuration changed while resolving credentials; retry without sending a credential");
9140
+ }
9141
+ };
9142
+ }
9143
+ function readMetadataText(file) {
9144
+ let fd;
9145
+ try {
9146
+ fd = openSync2(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
9147
+ } catch (error) {
9148
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
9149
+ return null;
9150
+ throw new Error("Cannot safely read Skills instance configuration");
9151
+ }
9152
+ try {
9153
+ const before = fstatSync2(fd);
9154
+ const uid = process.getuid?.() ?? process.geteuid?.();
9155
+ if (!before.isFile() || ![256, 384].includes(before.mode & 4095) || uid !== undefined && before.uid !== uid || before.size > 64 * 1024) {
9156
+ throw new Error("Unsafe Skills instance configuration; expected a bounded owner-only regular file");
9157
+ }
9158
+ const bytes = Buffer.alloc(64 * 1024 + 1);
9159
+ let length = 0;
9160
+ while (length < bytes.length) {
9161
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
9162
+ if (!count)
9163
+ break;
9164
+ length += count;
9165
+ }
9166
+ if (length > 64 * 1024 || !unchanged(before, fstatSync2(fd)) || !unchanged(before, fileIdentity(file))) {
9167
+ throw new Error("Skills instance configuration changed while reading");
9168
+ }
9169
+ return bytes.subarray(0, length).toString("utf8");
9170
+ } finally {
9171
+ closeSync2(fd);
9172
+ }
9173
+ }
9174
+ function readSkillsInstanceMetadata(file) {
9175
+ const text = readMetadataText(file);
9176
+ if (text === null)
9177
+ return {};
9178
+ const values = new Map;
9179
+ for (const line of text.split(/\r?\n/)) {
9180
+ const match = /^\s*(?:export\s+)?(HASNA_SKILLS_API_URL|SKILLS_API_URL|HASNA_SKILLS_BOUND_API_URL)\s*=\s*(.*)$/.exec(line);
9181
+ if (!match)
9182
+ continue;
9183
+ let value = match[2].trim();
9184
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))
9185
+ value = value.slice(1, -1);
9186
+ if (!value || /[\x00-\x20\x7f]/.test(value) || values.has(match[1]))
9187
+ throw new Error("Invalid Skills instance configuration");
9188
+ values.set(match[1], value);
9189
+ }
9190
+ const urls = [values.get("HASNA_SKILLS_API_URL"), values.get("SKILLS_API_URL")].filter(Boolean);
9191
+ if (new Set(urls).size > 1)
9192
+ throw new Error("Skills API URL aliases disagree");
9193
+ return { apiUrl: urls[0], binding: values.get(SKILLS_BOUND_API_URL) };
9194
+ }
9195
+
9191
9196
  // src/lib/fleet-credentials.ts
9192
9197
  var SKILLS_APP = "skills";
9193
9198
  var ENV_KEYS = clientTransportEnvKeys(SKILLS_APP);
@@ -9204,9 +9209,6 @@ class SkillsFleetCredentialError extends Error {
9204
9209
  this.code = code;
9205
9210
  }
9206
9211
  }
9207
- function isClientTransportConfigurationError(error) {
9208
- return error instanceof ClientTransportConfigurationError || typeof error === "object" && error !== null && error.name === "ClientTransportConfigurationError";
9209
- }
9210
9212
  function isCredentialResolutionError(error) {
9211
9213
  return error instanceof CredentialResolutionError || typeof error === "object" && error !== null && error.name === "CredentialResolutionError";
9212
9214
  }
@@ -9217,6 +9219,9 @@ function asSkillsFleetCredentialError(error) {
9217
9219
  }
9218
9220
  function normalizeSkillsApiOrigin(apiUrl) {
9219
9221
  const url = new URL(apiUrl);
9222
+ if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
9223
+ throw new SkillsFleetCredentialError("A Skills API URL must use HTTPS (or loopback HTTP), without credentials, query or fragment", "INVALID_API_URL");
9224
+ }
9220
9225
  const pathname = url.pathname.replace(/\/+$/, "");
9221
9226
  if (pathname === "/api" || pathname === "/api/v1") {
9222
9227
  url.pathname = "/";
@@ -9227,11 +9232,24 @@ function normalizeSkillsApiOrigin(apiUrl) {
9227
9232
  }
9228
9233
  return url.toString().replace(/\/+$/, "");
9229
9234
  }
9230
- function configuredSkillsApiUrl(env = process.env, keychain) {
9231
- for (const key of SKILLS_API_URL_ENV_KEYS) {
9232
- const value = env[key]?.trim();
9233
- if (value)
9234
- return { value, source: key };
9235
+ function configuredSkillsApiUrl(env = process.env, keychain, profile) {
9236
+ const declared = SKILLS_API_URL_ENV_KEYS.filter((key) => env[key] !== undefined).map((key) => ({ key, value: env[key] }));
9237
+ for (const entry of declared) {
9238
+ if (!entry.value.trim() || /[\x00-\x1f\x7f]/.test(entry.value))
9239
+ throw new SkillsFleetCredentialError(`${entry.key} is blank or contains control characters`, "INVALID_API_URL");
9240
+ }
9241
+ const normalized = declared.map((entry) => ({ value: normalizeSkillsApiOrigin(entry.value), source: entry.key }));
9242
+ if (new Set(normalized.map((entry) => entry.value)).size > 1)
9243
+ throw new SkillsFleetCredentialError("Skills API URL aliases disagree", "INVALID_API_URL");
9244
+ if (normalized[0])
9245
+ return normalized[0];
9246
+ if (selectedSkillsProfile(env, profile)) {
9247
+ for (const file of skillsProfileCredentialFiles(env, profile)) {
9248
+ const metadata = readSkillsInstanceMetadata(file);
9249
+ if (metadata.apiUrl || metadata.binding)
9250
+ return { value: metadata.apiUrl ?? metadata.binding, source: file };
9251
+ }
9252
+ return { value: defaultFleetGatewayBaseUrl(SKILLS_APP), source: "default" };
9235
9253
  }
9236
9254
  const fromKeychain = keychainConfigValue(SKILLS_APP, env, keychain);
9237
9255
  if (fromKeychain)
@@ -9245,7 +9263,7 @@ function configuredSkillsApiUrl(env = process.env, keychain) {
9245
9263
  return null;
9246
9264
  }
9247
9265
  function skillsCredentialFiles(env = process.env) {
9248
- return credentialDiskSources(SKILLS_APP, env);
9266
+ return skillsProfileCredentialFiles(env);
9249
9267
  }
9250
9268
  function skillsCredentialFilePath(env = process.env) {
9251
9269
  const paths = skillsCredentialFiles(env);
@@ -9264,7 +9282,11 @@ function noticeLocalSkillsMode(write = (line) => console.error(line)) {
9264
9282
  }
9265
9283
  function resolveSkillsFleet(env = process.env, options = {}) {
9266
9284
  try {
9267
- return resolveSkillsFleetOrThrow(env, options);
9285
+ const snapshot = snapshotSkillsEnvironment(env);
9286
+ const resolved = resolveSkillsFleetOrThrow(snapshot, snapshotSkillsOptions(env, options));
9287
+ if (resolved.mode === "local" && env === process.env)
9288
+ noticeLocalSkillsMode();
9289
+ return resolved;
9268
9290
  } catch (error) {
9269
9291
  const translated = asSkillsFleetCredentialError(error);
9270
9292
  if (translated)
@@ -9272,38 +9294,46 @@ function resolveSkillsFleet(env = process.env, options = {}) {
9272
9294
  throw error;
9273
9295
  }
9274
9296
  }
9275
- function resolveSkillsFleetOrThrow(env, options) {
9276
- let resolution;
9277
- try {
9278
- resolution = resolveClientTransport(SKILLS_APP, env, { credentials: options.credentials });
9279
- } catch (error) {
9280
- if (!isClientTransportConfigurationError(error))
9281
- throw error;
9282
- const configured2 = configuredSkillsApiUrl(env, options.credentials?.keychain);
9283
- const credential2 = resolveCredential(SKILLS_APP, env, options.credentials);
9284
- if (!configured2 && !credential2) {
9285
- if (env === process.env)
9286
- noticeLocalSkillsMode();
9287
- return { mode: "local", apiOrigin: null, apiKey: null };
9288
- }
9289
- if (configured2 && !credential2) {
9290
- throw new SkillsFleetCredentialError(`${configured2.source} points this CLI at a Skills service but no API key resolved \u2014 ` + `refusing to run locally instead. Looked in the Keychain item ` + `hasna.credentials.${SKILLS_APP}.api-key, then ${skillsCredentialFiles(env).join(" or ") || "no credentials file (no HOME)"}, ` + `then ${SKILLS_API_KEY_ENV}. Sign in with: skills auth login`);
9297
+ function snapshotSkillsEnvironment(env) {
9298
+ const snapshot = {};
9299
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(env))) {
9300
+ if (!("value" in descriptor)) {
9301
+ if (/^(?:HASNA_|SKILLS_|HOME$|USER$)/.test(key))
9302
+ throw new SkillsFleetCredentialError("Accessor-backed Skills configuration is unsupported");
9303
+ continue;
9291
9304
  }
9292
- throw error;
9305
+ snapshot[key] = descriptor.value;
9293
9306
  }
9294
- const configured = configuredSkillsApiUrl(env, options.credentials?.keychain);
9295
- const apiOrigin = configured ? normalizeSkillsApiOrigin(configured.value) : stripV1(resolution.baseUrl);
9307
+ return Object.freeze(snapshot);
9308
+ }
9309
+ function snapshotSkillsOptions(env, options) {
9310
+ if (env !== process.env)
9311
+ return options;
9312
+ return { ...options, credentials: { ...options.credentials, keychain: {
9313
+ ...options.credentials?.keychain,
9314
+ enabled: options.credentials?.keychain?.enabled ?? true
9315
+ } } };
9316
+ }
9317
+ function resolveSkillsFleetOrThrow(env, options) {
9318
+ const assertFilesUnchanged = captureSkillsCredentialFiles(skillsProfileCredentialFiles(env, options.credentials?.profile));
9319
+ const configured = configuredSkillsApiUrl(env, options.credentials?.keychain, options.credentials?.profile);
9296
9320
  const credential = resolveCredential(SKILLS_APP, env, options.credentials);
9297
9321
  if (!credential) {
9298
- throw new SkillsFleetCredentialError(`A Skills authority resolved but no API key did. Sign in with: skills auth login`);
9322
+ if (!configured)
9323
+ return { mode: "local", apiOrigin: null, apiKey: null };
9324
+ 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 hasna.credentials.skills.api-key, ${skillsCredentialFiles(env).join(" or ") || "no credentials file"}, and ${SKILLS_API_KEY_ENV}. Sign in with: skills auth login`);
9299
9325
  }
9326
+ const apiOrigin = normalizeSkillsApiOrigin(configured?.value ?? defaultFleetGatewayBaseUrl(SKILLS_APP));
9327
+ toV1BaseUrl(apiOrigin);
9328
+ assertCredentialInstance(credential, apiOrigin, env, options);
9329
+ assertFilesUnchanged();
9300
9330
  const base = {
9301
9331
  mode: "hosted",
9302
9332
  apiOrigin,
9303
- apiUrlSource: resolution.apiUrlSource ?? (configured?.source ?? "default"),
9304
- apiKeySource: resolution.apiKeySource ?? credential.source,
9305
- apiKeyTier: resolution.apiKeyTier,
9306
- warning: resolution.warning
9333
+ apiUrlSource: configured?.source ?? "default",
9334
+ apiKeySource: credential.source,
9335
+ apiKeyTier: credential.tier,
9336
+ warning: credential.warning
9307
9337
  };
9308
9338
  if (credential.tier === "pointer") {
9309
9339
  return { ...base, apiKey: null, apiKeyPointer: credential };
@@ -9313,19 +9343,37 @@ function resolveSkillsFleetOrThrow(env, options) {
9313
9343
  }
9314
9344
  return { ...base, apiKey: credential.apiKey, apiKeyPointer: null };
9315
9345
  }
9346
+ function assertCredentialInstance(credential, apiOrigin, env, options) {
9347
+ let bound;
9348
+ if (credential.tier === "disk" || credential.tier === "profile") {
9349
+ const metadata = readSkillsInstanceMetadata(credential.source);
9350
+ bound = metadata.binding ?? metadata.apiUrl ?? defaultFleetGatewayBaseUrl(SKILLS_APP);
9351
+ } else if (credential.tier === "keychain") {
9352
+ bound = keychainConfigValue(SKILLS_APP, env, options.credentials?.keychain)?.value ?? defaultFleetGatewayBaseUrl(SKILLS_APP);
9353
+ }
9354
+ if (bound && normalizeSkillsApiOrigin(bound) !== apiOrigin) {
9355
+ 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");
9356
+ }
9357
+ }
9316
9358
  async function resolveSkillsApiKey(env = process.env, options = {}) {
9317
- const fleet = resolveSkillsFleet(env, options);
9359
+ return (await resolveSkillsConnection(env, options))?.apiKey ?? null;
9360
+ }
9361
+ async function resolveSkillsConnection(env = process.env, options = {}) {
9362
+ const snapshotEnv = snapshotSkillsEnvironment(env);
9363
+ const fleet = resolveSkillsFleet(snapshotEnv, snapshotSkillsOptions(env, options));
9364
+ if (fleet.mode === "local" && env === process.env)
9365
+ noticeLocalSkillsMode();
9318
9366
  if (fleet.mode !== "hosted")
9319
9367
  return null;
9320
9368
  if (fleet.apiKey)
9321
- return fleet.apiKey;
9369
+ return { ...fleet, apiKey: fleet.apiKey };
9322
9370
  const pointer = fleet.apiKeyPointer;
9323
9371
  if (!pointer) {
9324
9372
  throw new SkillsFleetCredentialError(`A Skills authority resolved but no API key did. Sign in with: skills auth login`);
9325
9373
  }
9326
9374
  let completed;
9327
9375
  try {
9328
- completed = await completePointerCredential(SKILLS_APP, pointer, env);
9376
+ completed = await completePointerCredential(SKILLS_APP, pointer, snapshotEnv);
9329
9377
  } catch (error) {
9330
9378
  const translated = asSkillsFleetCredentialError(error);
9331
9379
  if (translated)
@@ -9335,7 +9383,7 @@ async function resolveSkillsApiKey(env = process.env, options = {}) {
9335
9383
  if (!completed.apiKey?.trim()) {
9336
9384
  throw new SkillsFleetCredentialError(`${credentialPointerEnvKey(SKILLS_APP)} names a vault item that produced an empty Skills API key \u2014 ` + `refusing to send an unauthenticated request.`);
9337
9385
  }
9338
- return completed.apiKey;
9386
+ return { ...fleet, apiKey: completed.apiKey, apiKeyPointer: null };
9339
9387
  }
9340
9388
  async function requireSkillsApiKey(action = "This command", env = process.env, options = {}) {
9341
9389
  const apiKey = await resolveSkillsApiKey(env, options);
@@ -9343,22 +9391,19 @@ async function requireSkillsApiKey(action = "This command", env = process.env, o
9343
9391
  throw new MissingSkillsFleetError(action);
9344
9392
  return apiKey;
9345
9393
  }
9346
- function stripV1(baseUrl) {
9347
- return baseUrl.replace(/\/v1$/, "").replace(/\/+$/, "");
9348
- }
9349
9394
  async function skillsCredentialOrReason(env = process.env, options = {}) {
9350
9395
  try {
9351
- const apiKey = await resolveSkillsApiKey(env, options);
9352
- return apiKey ? { apiKey, reason: null } : { apiKey: null, reason: null };
9396
+ const connection = await resolveSkillsConnection(env, options);
9397
+ return connection ? { apiKey: connection.apiKey, apiOrigin: connection.apiOrigin, reason: null } : { apiKey: null, apiOrigin: null, reason: null };
9353
9398
  } catch (error) {
9354
9399
  if (error instanceof SkillsFleetCredentialError || error?.name === "SkillsFleetCredentialError") {
9355
- return { apiKey: null, reason: error.message };
9400
+ return { apiKey: null, apiOrigin: null, reason: error.message };
9356
9401
  }
9357
9402
  throw error;
9358
9403
  }
9359
9404
  }
9360
9405
  function resolveSkillsApiOrigin(env = process.env, options = {}) {
9361
- const configured = configuredSkillsApiUrl(env, options.credentials?.keychain);
9406
+ const configured = configuredSkillsApiUrl(env, options.credentials?.keychain, options.credentials?.profile);
9362
9407
  if (configured) {
9363
9408
  toV1BaseUrl(configured.value);
9364
9409
  return { origin: normalizeSkillsApiOrigin(configured.value), source: configured.source };
@@ -10181,6 +10226,141 @@ function pickNumber(record, key) {
10181
10226
  return typeof value === "number" && Number.isFinite(value) ? { [key]: value } : {};
10182
10227
  }
10183
10228
 
10229
+ // src/lib/remote-account.ts
10230
+ class RemoteCreditApprovalError extends Error {
10231
+ requiredCredits;
10232
+ maximumCredits;
10233
+ code = "CREDIT_APPROVAL_REQUIRED";
10234
+ constructor(requiredCredits, maximumCredits) {
10235
+ super(`This run requires ${requiredCredits} credits; the approved maximum is ${maximumCredits}. Quote the run and explicitly approve its cost.`);
10236
+ this.requiredCredits = requiredCredits;
10237
+ this.maximumCredits = maximumCredits;
10238
+ this.name = "RemoteCreditApprovalError";
10239
+ }
10240
+ }
10241
+ function creditCount(value) {
10242
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 2147483647) {
10243
+ throw new Error("The Skills server returned an invalid credit count");
10244
+ }
10245
+ return value;
10246
+ }
10247
+ function parseRemoteRunQuote(value) {
10248
+ const quote = object(value);
10249
+ const pricing = object(quote.pricing);
10250
+ if (typeof quote.skill !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(quote.skill))
10251
+ throw new Error("Invalid quoted skill");
10252
+ const costCents = creditCount(pricing.costCredits ?? pricing.costCents);
10253
+ if (pricing.costCredits !== undefined && pricing.costCents !== undefined && pricing.costCents !== costCents)
10254
+ throw new Error("Inconsistent quoted credit count");
10255
+ if (quote.availability && object(quote.availability).status !== "available")
10256
+ throw new Error("This skill is unavailable for remote execution");
10257
+ return { ...quote, skill: quote.skill, pricing: { ...pricing, costCredits: costCents, costCents, formattedCost: `${costCents} credits` } };
10258
+ }
10259
+ function parseRemoteCreditPacks(value) {
10260
+ if (!Array.isArray(value))
10261
+ throw new Error("Invalid credit pack response");
10262
+ const ids = new Set;
10263
+ return value.map((value2) => {
10264
+ const row = object(value2);
10265
+ const counts = [row.credits, row.creditsCents, row.amountCents].filter((value3) => value3 !== undefined).map(creditCount);
10266
+ if (!counts.length || counts[0] === 0 || counts.some((count) => count !== counts[0]))
10267
+ throw new Error("Inconsistent credit pack counts");
10268
+ const credits = counts[0];
10269
+ const id = row.id ?? `credits_${credits}`;
10270
+ if (typeof id !== "string" || !/^[a-z0-9][a-z0-9_-]{0,99}$/.test(id) || ids.has(id))
10271
+ throw new Error("Invalid credit pack ID");
10272
+ if (id.startsWith("credits_") && id !== `credits_${credits}`)
10273
+ throw new Error("Inconsistent credit pack ID");
10274
+ ids.add(id);
10275
+ return { id, credits, ...row.expiresInDays === undefined ? {} : { expiresInDays: creditCount(row.expiresInDays) } };
10276
+ });
10277
+ }
10278
+ function parseRemoteBillingStatus(value) {
10279
+ const row = object(value);
10280
+ const counts = [row.creditBalance, row.balanceCents].filter((value2) => value2 !== undefined).map(creditCount);
10281
+ if (!counts.length || counts.some((count) => count !== counts[0]))
10282
+ throw new Error("Inconsistent credit balance");
10283
+ return {
10284
+ creditBalance: counts[0],
10285
+ formattedCreditBalance: `${counts[0]} credits`,
10286
+ ...typeof row.plan === "string" ? { plan: row.plan } : {},
10287
+ ...typeof row.hasPaymentMethod === "boolean" ? { hasPaymentMethod: row.hasPaymentMethod } : {}
10288
+ };
10289
+ }
10290
+ function parseRemoteCheckout(value) {
10291
+ const row = object(value);
10292
+ if (typeof row.url !== "string")
10293
+ throw new Error("Invalid checkout URL");
10294
+ const url = new URL(row.url);
10295
+ if (url.protocol !== "https:" || url.username || url.password)
10296
+ throw new Error("Invalid checkout URL");
10297
+ return { url: row.url };
10298
+ }
10299
+ function object(value) {
10300
+ if (!value || typeof value !== "object" || Array.isArray(value))
10301
+ throw new Error("Invalid Skills server response");
10302
+ return value;
10303
+ }
10304
+
10305
+ // src/lib/remote-files.ts
10306
+ import { createHash as createHash3 } from "crypto";
10307
+ var MAX_REMOTE_FILE_BYTES = 64 * 1024 * 1024;
10308
+ function describeRemoteFiles(files) {
10309
+ if (files.length > 10)
10310
+ throw new Error("At most 10 input files are supported");
10311
+ const names = new Set;
10312
+ let total = 0;
10313
+ return files.map((file) => {
10314
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(file.name) || file.name === "." || file.name === ".." || names.has(file.name))
10315
+ throw new Error("Input file names must be unique safe basenames");
10316
+ names.add(file.name);
10317
+ total += file.bytes.byteLength;
10318
+ if (file.bytes.byteLength > 20 * 1024 * 1024 || total > 50 * 1024 * 1024)
10319
+ throw new Error("Input files exceed the supported size limit");
10320
+ return { name: file.name, sizeBytes: file.bytes.byteLength, sha256: sha256(file.bytes), contentType: file.contentType ?? "application/octet-stream" };
10321
+ });
10322
+ }
10323
+ function sha256(bytes) {
10324
+ return createHash3("sha256").update(bytes).digest("hex");
10325
+ }
10326
+ async function readBoundedResponse(response, maximum) {
10327
+ if (!Number.isSafeInteger(maximum) || maximum < 0 || maximum > MAX_REMOTE_FILE_BYTES)
10328
+ throw new Error("Invalid artifact size limit");
10329
+ const length = response.headers.get("content-length");
10330
+ if (length && (!/^\d+$/.test(length) || Number(length) > maximum)) {
10331
+ await response.body?.cancel();
10332
+ throw new Error("Artifact exceeds its declared size limit");
10333
+ }
10334
+ const reader = response.body?.getReader();
10335
+ if (!reader)
10336
+ return new Uint8Array;
10337
+ const chunks = [];
10338
+ let size = 0;
10339
+ try {
10340
+ while (true) {
10341
+ const next = await reader.read();
10342
+ if (next.done)
10343
+ break;
10344
+ size += next.value.byteLength;
10345
+ if (size > maximum)
10346
+ throw new Error("Artifact exceeds its declared size limit");
10347
+ chunks.push(next.value);
10348
+ }
10349
+ } catch (error) {
10350
+ await reader.cancel().catch(() => {});
10351
+ throw error;
10352
+ } finally {
10353
+ reader.releaseLock();
10354
+ }
10355
+ const bytes = new Uint8Array(size);
10356
+ let offset = 0;
10357
+ for (const chunk of chunks) {
10358
+ bytes.set(chunk, offset);
10359
+ offset += chunk.byteLength;
10360
+ }
10361
+ return bytes;
10362
+ }
10363
+
10184
10364
  // src/lib/remote-client.ts
10185
10365
  class RemoteRouteUnsupportedError extends Error {
10186
10366
  path;
@@ -10209,13 +10389,16 @@ class RemoteRequestError extends Error {
10209
10389
  class RemoteSkillsClient {
10210
10390
  apiUrl;
10211
10391
  apiKey;
10392
+ capabilities;
10212
10393
  constructor(apiKey, apiUrl = getApiUrl()) {
10213
10394
  this.apiKey = apiKey;
10214
- this.apiUrl = apiUrl.replace(/\/$/, "");
10395
+ this.apiUrl = normalizeSkillsApiOrigin(apiUrl);
10215
10396
  }
10216
10397
  async request(path, options) {
10217
10398
  return fetch(`${this.apiUrl}${path}`, {
10218
10399
  ...options,
10400
+ redirect: "error",
10401
+ signal: options?.signal ?? AbortSignal.timeout(15000),
10219
10402
  headers: {
10220
10403
  Authorization: `Bearer ${this.apiKey}`,
10221
10404
  "Content-Type": "application/json",
@@ -10238,8 +10421,7 @@ class RemoteSkillsClient {
10238
10421
  return response;
10239
10422
  }
10240
10423
  async listSkills() {
10241
- const res = await this.request("/api/v1/skills");
10242
- return res.json();
10424
+ return this.arrayResponse("/api/v1/skills");
10243
10425
  }
10244
10426
  async getSkillMd(slug) {
10245
10427
  const res = await this.request(`/api/v1/skills/${slug}/skill.md`);
@@ -10261,39 +10443,217 @@ class RemoteSkillsClient {
10261
10443
  } catch {}
10262
10444
  return { status: res.status, body };
10263
10445
  }
10264
- async submitRun(slug, input, args) {
10265
- const res = await this.request(`/api/v1/runs/${slug}`, {
10446
+ async submitRun(slug, input, args, approval = {}) {
10447
+ if (approval.idempotencyKey !== undefined && !/^[A-Za-z0-9._:-]{1,128}$/.test(approval.idempotencyKey))
10448
+ throw new Error("Idempotency key must be 1-128 URL-safe characters");
10449
+ if (approval.maxCostCents !== undefined)
10450
+ creditCount(approval.maxCostCents);
10451
+ if (approval.maxCredits !== undefined)
10452
+ creditCount(approval.maxCredits);
10453
+ if (approval.maxCredits !== undefined && approval.maxCostCents !== undefined && approval.maxCredits !== approval.maxCostCents)
10454
+ throw new Error("Credit approval fields disagree");
10455
+ const res = await this.request(`/api/v1/runs/${encodeURIComponent(slug)}`, {
10266
10456
  method: "POST",
10267
- body: JSON.stringify({ input, args })
10457
+ body: JSON.stringify({
10458
+ input,
10459
+ args,
10460
+ ...approval.maxCredits !== undefined ? { maxCredits: approval.maxCredits } : {},
10461
+ ...approval.maxCostCents !== undefined ? { maxCostCents: approval.maxCostCents } : {},
10462
+ ...approval.idempotencyKey !== undefined ? { idempotencyKey: approval.idempotencyKey } : {},
10463
+ ...approval.inputFiles !== undefined ? { files: approval.inputFiles } : {}
10464
+ })
10268
10465
  });
10269
10466
  return normalizeRemoteSkillRunContract(await res.json(), slug);
10270
10467
  }
10468
+ async quoteRun(slug, input = {}, args = []) {
10469
+ const response = await this.requestNewRoute(`/api/v1/skills/${encodeURIComponent(slug)}/quote`, {
10470
+ method: "POST",
10471
+ body: JSON.stringify({ input, args })
10472
+ });
10473
+ return parseRemoteRunQuote(await response.json());
10474
+ }
10475
+ getCapabilities() {
10476
+ if (!this.capabilities)
10477
+ this.capabilities = (async () => {
10478
+ const value = await (await this.requestNewRoute("/api/v1/capabilities")).json();
10479
+ if (value.contractVersion !== 1 || value.apiVersion !== 1 || !Array.isArray(value.capabilities) || value.capabilities.some((item) => typeof item !== "string"))
10480
+ throw new Error("Unsupported Skills server capability contract");
10481
+ const billing = value.billing;
10482
+ return { contractVersion: 1, apiVersion: 1, capabilities: value.capabilities, ...billing ? { billing } : {} };
10483
+ })();
10484
+ return this.capabilities;
10485
+ }
10486
+ async submitQuotedRun(slug, input = {}, args = [], approval = {}) {
10487
+ const maximum = creditCount(approval.maxCredits ?? approval.maxCostCents ?? 0);
10488
+ if (approval.maxCostCents !== undefined && approval.maxCostCents !== maximum)
10489
+ throw new Error("Credit approval fields disagree");
10490
+ const quote = await this.quoteRun(slug, input, args);
10491
+ if (quote.pricing.costCents > maximum)
10492
+ throw new RemoteCreditApprovalError(quote.pricing.costCents, maximum);
10493
+ const capabilities = await this.getCapabilities();
10494
+ if (!capabilities.capabilities.includes("runs.submit") || capabilities.billing?.boundedRunApproval !== true || capabilities.billing.unit !== "credits") {
10495
+ throw new Error("The configured server does not support bounded credit approval; refusing remote submission");
10496
+ }
10497
+ return this.submitRun(quote.skill, input, args, { ...approval, maxCredits: maximum, maxCostCents: maximum });
10498
+ }
10499
+ async getIdentity() {
10500
+ return (await this.requestNewRoute("/api/auth/whoami")).json();
10501
+ }
10502
+ async listApiKeys() {
10503
+ return this.arrayResponse("/api/auth/keys");
10504
+ }
10505
+ async createApiKey(name, scopes) {
10506
+ if (!name.trim() || name.length > 100)
10507
+ throw new Error("API key name must be 1-100 characters");
10508
+ const value = await (await this.requestNewRoute("/api/auth/keys", { method: "POST", body: JSON.stringify({ name, ...scopes ? { scopes } : {} }) })).json();
10509
+ if (!value || typeof value.key !== "string" || !value.key.trim())
10510
+ throw new Error("The server did not return a created API key");
10511
+ return value;
10512
+ }
10513
+ async revokeApiKey(keyId) {
10514
+ return (await this.requestNewRoute(`/api/auth/keys/${encodeURIComponent(keyId)}`, { method: "DELETE" })).json();
10515
+ }
10516
+ async getBillingStatus() {
10517
+ return parseRemoteBillingStatus(await (await this.requestNewRoute("/api/v1/billing/status")).json());
10518
+ }
10519
+ async listCreditPacks() {
10520
+ return parseRemoteCreditPacks(await (await this.requestNewRoute("/api/v1/billing/credits")).json());
10521
+ }
10522
+ async createCreditCheckout(packId) {
10523
+ const packs = await this.listCreditPacks();
10524
+ if (!packs.some((pack) => pack.id === packId))
10525
+ throw new Error("Choose a credit pack returned by skills credits packs");
10526
+ return parseRemoteCheckout(await (await this.requestNewRoute("/api/v1/billing/credits", {
10527
+ method: "POST",
10528
+ body: JSON.stringify({ packId })
10529
+ })).json());
10530
+ }
10531
+ async getUsage() {
10532
+ return this.arrayResponse("/api/v1/billing/usage");
10533
+ }
10534
+ async listInvoices() {
10535
+ return this.arrayResponse("/api/v1/billing/invoices");
10536
+ }
10537
+ async createBillingCheckout() {
10538
+ return this.checkoutResponse("/api/v1/billing/checkout");
10539
+ }
10540
+ async createBillingPortal() {
10541
+ return this.checkoutResponse("/api/v1/billing/portal");
10542
+ }
10543
+ async cancelRun(runId) {
10544
+ return this.controlRun(runId, "cancel");
10545
+ }
10546
+ async resumeRun(runId) {
10547
+ return this.controlRun(runId, "resume");
10548
+ }
10549
+ async controlRun(runId, action) {
10550
+ const response = await this.requestNewRoute(`/api/v1/runs/${encodeURIComponent(runId)}/${action}`, { method: "POST", body: "{}" });
10551
+ return normalizeRemoteSkillRunContract(await response.json());
10552
+ }
10553
+ async checkoutResponse(path) {
10554
+ return parseRemoteCheckout(await (await this.requestNewRoute(path, { method: "POST", body: "{}" })).json());
10555
+ }
10556
+ async arrayResponse(path) {
10557
+ const rows = await (await this.requestNewRoute(path)).json();
10558
+ if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
10559
+ throw new Error("Invalid Skills server list response");
10560
+ return rows;
10561
+ }
10271
10562
  async getRun(runId) {
10272
- const res = await this.request(`/api/v1/runs/${runId}`);
10273
- if (!res.ok)
10563
+ const path = `/api/v1/runs/${encodeURIComponent(runId)}`;
10564
+ const res = await this.request(path);
10565
+ if (res.status === 404)
10274
10566
  return null;
10567
+ if (!res.ok)
10568
+ throw new RemoteRequestError(path, res.status, res.statusText);
10275
10569
  return normalizeRemoteSkillRunContract(await res.json());
10276
10570
  }
10277
10571
  async getRunLogs(runId) {
10278
- const res = await this.request(`/api/v1/runs/${runId}/logs`);
10279
- if (!res.ok)
10280
- return [];
10281
- const payload = await res.json();
10282
- return Array.isArray(payload) ? payload : [];
10572
+ return this.arrayResponse(`/api/v1/runs/${encodeURIComponent(runId)}/logs`);
10283
10573
  }
10284
10574
  async listRuns(limit = 20) {
10285
- const res = await this.request(`/api/v1/runs?limit=${limit}`);
10286
- return res.json();
10575
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
10576
+ throw new Error("Run limit must be an integer from 1 to 100");
10577
+ return this.arrayResponse(`/api/v1/runs?limit=${limit}`);
10287
10578
  }
10288
10579
  async getRunArtifacts(runId) {
10289
- const res = await this.request(`/api/v1/runs/${runId}/artifacts`);
10290
- return res.json();
10580
+ return this.arrayResponse(`/api/v1/runs/${encodeURIComponent(runId)}/artifacts`);
10291
10581
  }
10292
10582
  async downloadRunArtifact(runId, artifactId) {
10293
- return this.request(`/api/v1/runs/${runId}/artifacts/${artifactId}/download`, {
10583
+ return this.request(`/api/v1/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(artifactId)}/download`, {
10294
10584
  method: "GET"
10295
10585
  });
10296
10586
  }
10587
+ async getVerifiedRunArtifact(runId, artifactId, maximumBytes = MAX_REMOTE_FILE_BYTES) {
10588
+ const artifacts = await this.getRunArtifacts(runId);
10589
+ const artifact = artifacts.find((row) => row.id === artifactId);
10590
+ if (!artifact)
10591
+ throw new Error("Run artifact not found");
10592
+ if (!Number.isSafeInteger(artifact.byteSize) || artifact.byteSize < 0 || artifact.byteSize > maximumBytes || typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifact.sha256))
10593
+ throw new Error("The server does not provide valid artifact integrity metadata");
10594
+ const response = await this.downloadRunArtifact(runId, artifactId);
10595
+ if (!response.ok)
10596
+ throw new RemoteRequestError("artifact download", response.status, response.statusText);
10597
+ const bytes = await readBoundedResponse(response, artifact.byteSize);
10598
+ if (bytes.byteLength !== artifact.byteSize || sha256(bytes) !== artifact.sha256)
10599
+ throw new Error("Artifact integrity verification failed");
10600
+ return { id: artifactId, fileName: String(artifact.fileName ?? artifactId), bytes, byteSize: bytes.byteLength, sha256: artifact.sha256 };
10601
+ }
10602
+ async submitQuotedRunWithFiles(slug, input, args, files, approval = {}) {
10603
+ const inputFiles = describeRemoteFiles(files);
10604
+ if (files.length && !(await this.getCapabilities()).capabilities.includes("runs.uploads"))
10605
+ throw new Error("The configured server does not support input uploads");
10606
+ const run = await this.submitQuotedRun(slug, input, args, { ...approval, inputFiles });
10607
+ if (run.error || !run.id || !files.length)
10608
+ return run;
10609
+ const pastUploads = (status) => typeof status === "string" && [
10610
+ "running",
10611
+ "completed",
10612
+ "failed",
10613
+ "cancelled",
10614
+ "expired",
10615
+ "pending_approval",
10616
+ "approved",
10617
+ "waiting"
10618
+ ].includes(status);
10619
+ if (pastUploads(run.status))
10620
+ return run;
10621
+ try {
10622
+ await this.uploadRunFiles(run.id, files);
10623
+ } catch {
10624
+ try {
10625
+ const current = await this.getRun(run.id);
10626
+ if (current && pastUploads(current.status))
10627
+ return current;
10628
+ } catch {}
10629
+ let cancellationRequested = false;
10630
+ try {
10631
+ await this.cancelRun(run.id);
10632
+ cancellationRequested = true;
10633
+ } catch {}
10634
+ throw new Error(`Input upload failed for run ${run.id}; ${cancellationRequested ? "cancellation requested" : "check its status and cancel the run"}`);
10635
+ }
10636
+ return run;
10637
+ }
10638
+ async uploadRunFiles(runId, files) {
10639
+ const descriptors = describeRemoteFiles(files);
10640
+ const response = await this.requestNewRoute(`/api/v1/runs/${encodeURIComponent(runId)}/uploads`, { method: "POST", body: JSON.stringify({ files: descriptors }) });
10641
+ const payload = await response.json();
10642
+ if (!Array.isArray(payload.files) || payload.files.length !== files.length || new Set(payload.files.map((file) => file.name)).size !== files.length)
10643
+ throw new Error("Invalid input upload response");
10644
+ for (const file of files) {
10645
+ const upload = payload.files.find((row) => row.name === file.name);
10646
+ if (!upload)
10647
+ throw new Error("Missing input upload URL");
10648
+ const url = new URL(upload.uploadUrl);
10649
+ if (url.username || url.password || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
10650
+ throw new Error("Unsafe input upload URL");
10651
+ const uploaded = await fetch(url, { method: "PUT", body: file.bytes, headers: { "Content-Type": file.contentType ?? "application/octet-stream" }, redirect: "error", signal: AbortSignal.timeout(60000) });
10652
+ if (!uploaded.ok)
10653
+ throw new Error("Input upload failed");
10654
+ await uploaded.body?.cancel();
10655
+ }
10656
+ }
10297
10657
  async publishSkill(manifest, bundle, ifMatch) {
10298
10658
  const form = new FormData;
10299
10659
  form.set("manifest", JSON.stringify(manifest));
@@ -10306,7 +10666,9 @@ class RemoteSkillsClient {
10306
10666
  return fetch(`${this.apiUrl}/api/v1/skills`, {
10307
10667
  method: "POST",
10308
10668
  headers,
10309
- body: form
10669
+ body: form,
10670
+ redirect: "error",
10671
+ signal: AbortSignal.timeout(15000)
10310
10672
  });
10311
10673
  }
10312
10674
  async deleteSkill(slug) {
@@ -10362,12 +10724,13 @@ class RemoteSkillsClient {
10362
10724
  if (!Array.isArray(payload)) {
10363
10725
  throw new Error("Remote tags payload did not match the expected contract (expected an array of tag names)");
10364
10726
  }
10365
- for (const tag of payload) {
10366
- if (typeof tag !== "string" || tag.trim().length === 0) {
10367
- throw new Error("Remote tags payload did not match the expected contract (every element must be a non-empty tag name)");
10368
- }
10727
+ const isName = (value) => typeof value === "string" && value.trim().length > 0;
10728
+ if (payload.every(isName))
10729
+ return payload;
10730
+ if (payload.every((tag) => tag !== null && typeof tag === "object" && !Array.isArray(tag) && isName(tag.name) && Number.isSafeInteger(tag.count) && tag.count >= 0)) {
10731
+ return payload.map((tag) => tag.name);
10369
10732
  }
10370
- return payload;
10733
+ throw new Error("Remote tags payload did not match the expected contract (every element must be a non-empty tag name, or every element must be a counted tag record)");
10371
10734
  }
10372
10735
  async skillsByTag(tag) {
10373
10736
  const path = `/api/v1/tags/${encodeURIComponent(tag)}/skills`;
@@ -10470,14 +10833,8 @@ function normalizeUpdatedSincePage(payload) {
10470
10833
  return { skills, nextCursor };
10471
10834
  }
10472
10835
  async function createRemoteSkillsClient(env = process.env) {
10473
- const fleet = resolveSkillsFleet(env);
10474
- if (fleet.mode !== "hosted")
10475
- return null;
10476
- const apiKey = await resolveSkillsApiKey(env);
10477
- if (!apiKey) {
10478
- throw new Error("A Skills authority resolved but no API key did. Sign in with: skills auth login");
10479
- }
10480
- return new RemoteSkillsClient(apiKey, fleet.apiOrigin);
10836
+ const connection = await resolveSkillsConnection(env);
10837
+ return connection ? new RemoteSkillsClient(connection.apiKey, connection.apiOrigin) : null;
10481
10838
  }
10482
10839
  // src/lib/scheduler.ts
10483
10840
  import { existsSync as existsSync14, readFileSync as readFileSync13, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7 } from "fs";
@@ -10688,7 +11045,7 @@ import { existsSync as existsSync15, mkdirSync as mkdirSync8, mkdtempSync as mkd
10688
11045
  import { dirname as dirname6, join as join17 } from "path";
10689
11046
 
10690
11047
  // src/lib/revision.ts
10691
- import { createHash as createHash3 } from "crypto";
11048
+ import { createHash as createHash4 } from "crypto";
10692
11049
  var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
10693
11050
  function revisionIdOf(content) {
10694
11051
  const canonical = JSON.stringify({
@@ -10704,14 +11061,14 @@ function revisionIdOf(content) {
10704
11061
  bundleSha256: content.bundleSha256 ?? null,
10705
11062
  bundleByteSize: content.bundleByteSize ?? null
10706
11063
  });
10707
- return createHash3("sha256").update(canonical).digest("hex");
11064
+ return createHash4("sha256").update(canonical).digest("hex");
10708
11065
  }
10709
11066
  function revisionIdOfRecord(record) {
10710
11067
  return revisionIdOf(record);
10711
11068
  }
10712
11069
 
10713
11070
  // src/lib/skill-bundle.ts
10714
- import { createHash as createHash4 } from "crypto";
11071
+ import { createHash as createHash5 } from "crypto";
10715
11072
  import { readFileSync as readFileSync14, readdirSync as readdirSync9, statSync as statSync9 } from "fs";
10716
11073
  import { join as join16, relative as relative3 } from "path";
10717
11074
  var BLOCK = 512;
@@ -10865,7 +11222,7 @@ function ownBytes(view) {
10865
11222
  return out;
10866
11223
  }
10867
11224
  function sha256Hex(bytes) {
10868
- return createHash4("sha256").update(bytes).digest("hex");
11225
+ return createHash5("sha256").update(bytes).digest("hex");
10869
11226
  }
10870
11227
  function collectSkillBundleEntries(dir) {
10871
11228
  const entries = [];
@@ -11586,7 +11943,7 @@ import { dirname as dirname7, relative as relative4 } from "path";
11586
11943
  // package.json
11587
11944
  var package_default = {
11588
11945
  name: "@hasna/skills",
11589
- version: "0.3.0",
11946
+ version: "0.4.0",
11590
11947
  description: "Skills library for AI coding agents",
11591
11948
  type: "module",
11592
11949
  bin: {
@@ -11645,8 +12002,7 @@ var package_default = {
11645
12002
  "verify:release": "bun run scripts/release-guard.ts",
11646
12003
  prepare: "bun run build:js",
11647
12004
  prepack: "bun run build && bun run verify:release",
11648
- prepublishOnly: "bun run typecheck && bun run test",
11649
- postinstall: "mkdir -p $HOME/.hasna/skills/custom 2>/dev/null || true"
12005
+ prepublishOnly: "bun run typecheck && bun run test"
11650
12006
  },
11651
12007
  keywords: [
11652
12008
  "skills",
@@ -11775,6 +12131,24 @@ function buildDocs(name) {
11775
12131
  best: docs.skillMd ?? docs.readme ?? docs.claudeMd ?? null
11776
12132
  };
11777
12133
  }
12134
+ // src/lib/remote-customer-operations.ts
12135
+ var REMOTE_CUSTOMER_OPERATIONS = [
12136
+ { name: "get_account", title: "Get Account Identity", parameter: null, mutates: false, invoke: (client) => client.getIdentity() },
12137
+ { name: "get_server_capabilities", title: "Get Server Capabilities", parameter: null, mutates: false, invoke: (client) => client.getCapabilities() },
12138
+ { name: "list_remote_skills", title: "List Remote Skills", parameter: null, mutates: false, invoke: (client) => client.listSkills() },
12139
+ { name: "get_billing_status", title: "Get Billing Status", parameter: null, mutates: false, invoke: (client) => client.getBillingStatus() },
12140
+ { name: "list_credit_packs", title: "List Credit Packs", parameter: null, mutates: false, invoke: (client) => client.listCreditPacks() },
12141
+ { name: "create_credit_checkout", title: "Create Credit Checkout", parameter: "pack_id", mutates: true, invoke: (client, value) => client.createCreditCheckout(value) },
12142
+ { name: "get_billing_usage", title: "Get Billing Usage", parameter: null, mutates: false, invoke: (client) => client.getUsage() },
12143
+ { name: "list_invoices", title: "List Invoices", parameter: null, mutates: false, invoke: (client) => client.listInvoices() },
12144
+ { name: "create_billing_checkout", title: "Create Billing Checkout", parameter: null, mutates: true, invoke: (client) => client.createBillingCheckout() },
12145
+ { name: "create_billing_portal", title: "Create Billing Portal", parameter: null, mutates: true, invoke: (client) => client.createBillingPortal() },
12146
+ { name: "get_run_logs", title: "Get Run Logs", parameter: "run_id", mutates: false, invoke: (client, value) => client.getRunLogs(value) },
12147
+ { name: "cancel_run", title: "Cancel Run", parameter: "run_id", mutates: true, invoke: (client, value) => client.cancelRun(value) },
12148
+ { name: "resume_run", title: "Resume Run", parameter: "run_id", mutates: true, invoke: (client, value) => client.resumeRun(value) },
12149
+ { name: "list_run_artifacts", title: "List Run Artifacts", parameter: "run_id", mutates: false, invoke: (client, value) => client.getRunArtifacts(value) }
12150
+ ];
12151
+
11778
12152
  // src/lib/mcp-contracts.ts
11779
12153
  var MCP_CONTRACT_SCHEMA_VERSION = 1;
11780
12154
  var stringSchema = (description) => ({
@@ -12166,11 +12540,44 @@ var toolContracts = [
12166
12540
  dependencies: objectSchema({}, [], "Package dependencies.", true)
12167
12541
  })
12168
12542
  },
12543
+ {
12544
+ name: "list_api_keys",
12545
+ title: "List API Keys",
12546
+ description: "List keys using fresh email OTP reauthentication.",
12547
+ params: ["email", "code"],
12548
+ category: "execution",
12549
+ sideEffects: "local-process-or-remote-run",
12550
+ stable: true,
12551
+ inputSchema: objectSchema({ email: { type: "string", format: "email" }, code: { type: "string", pattern: "^\\d{6}$" } }, ["email", "code"]),
12552
+ outputSchema: arraySchema(objectSchema({}, [], "API key metadata", true))
12553
+ },
12554
+ {
12555
+ name: "revoke_api_key",
12556
+ title: "Revoke API Key",
12557
+ description: "Revoke a key using fresh email OTP reauthentication.",
12558
+ params: ["key_id", "email", "code"],
12559
+ category: "execution",
12560
+ sideEffects: "local-process-or-remote-run",
12561
+ stable: true,
12562
+ inputSchema: objectSchema({ key_id: stringSchema("API key ID"), email: { type: "string", format: "email" }, code: { type: "string", pattern: "^\\d{6}$" } }, ["key_id", "email", "code"]),
12563
+ outputSchema: objectSchema({}, [], "Revocation result", true)
12564
+ },
12565
+ {
12566
+ name: "create_api_key",
12567
+ title: "Create API Key",
12568
+ description: "Create a key with fresh email OTP reauthentication; returns the secret once.",
12569
+ params: ["name", "email", "code", "scopes?"],
12570
+ category: "execution",
12571
+ sideEffects: "local-process-or-remote-run",
12572
+ stable: true,
12573
+ inputSchema: objectSchema({ name: stringSchema("Key name"), email: { type: "string", format: "email" }, code: { type: "string", pattern: "^\\d{6}$" }, scopes: arraySchema(stringSchema("Scope")) }, ["name", "email", "code"]),
12574
+ outputSchema: objectSchema({}, [], "Created key and one-time secret", true)
12575
+ },
12169
12576
  {
12170
12577
  name: "run_skill",
12171
12578
  title: "Run Skill",
12172
12579
  description: "Run a skill locally or through a configured remote runner. Returns compact stdout/stderr previews and run summaries by default; pass detail:true for full records.",
12173
- params: ["name", "input?", "args?", "detail?"],
12580
+ params: ["name", "input?", "args?", "detail?", "remote?", "maxCredits?", "maxCostCents?", "idempotency_key?", "files?"],
12174
12581
  category: "execution",
12175
12582
  sideEffects: "local-process-or-remote-run",
12176
12583
  stable: true,
@@ -12178,7 +12585,12 @@ var toolContracts = [
12178
12585
  name: skillNameInput,
12179
12586
  input: runInputSchema,
12180
12587
  args: runArgsSchema,
12181
- detail: { type: "boolean", default: false, description: "Return full stdout/stderr, remote run, and local run metadata." }
12588
+ detail: { type: "boolean", default: false, description: "Return full stdout/stderr, remote run, and local run metadata." },
12589
+ remote: { type: "boolean", description: "Use the configured server catalog." },
12590
+ maxCredits: { type: "integer", minimum: 0, description: "Maximum explicitly approved integer credits; omitted permits only free remote runs." },
12591
+ maxCostCents: { type: "integer", minimum: 0, description: "Legacy alias for maxCredits; both must agree." },
12592
+ idempotency_key: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$", description: "Stable retry key for the same remote submission." },
12593
+ files: { type: "array", maxItems: 10, items: objectSchema({ name: stringSchema("Safe basename"), base64: { type: "string", maxLength: 1398104 }, contentType: stringSchema("MIME type") }, ["name", "base64"]), description: "Inline remote inputs, at most 1 MiB combined." }
12182
12594
  }, ["name"]),
12183
12595
  outputSchema: runOutputSchema
12184
12596
  },
@@ -12445,7 +12857,40 @@ var toolContracts = [
12445
12857
  outputSchema: objectSchema({}, [], "Feedback save result.", true)
12446
12858
  }
12447
12859
  ];
12448
- var contracts = [...toolContracts].sort((a, b) => a.name.localeCompare(b.name));
12860
+ var remoteCustomerContracts = REMOTE_CUSTOMER_OPERATIONS.map((operation) => ({
12861
+ name: operation.name,
12862
+ title: operation.title,
12863
+ description: `${operation.title} on the configured server; unavailable capabilities fail explicitly.`,
12864
+ params: operation.parameter ? [operation.parameter] : [],
12865
+ category: "execution",
12866
+ sideEffects: operation.mutates ? "local-process-or-remote-run" : "none",
12867
+ stable: true,
12868
+ inputSchema: objectSchema(operation.parameter ? { [operation.parameter]: stringSchema("Server resource identifier.") } : {}, operation.parameter ? [operation.parameter] : []),
12869
+ outputSchema: { oneOf: [objectSchema({}, [], "Server response.", true), { type: "array", items: objectSchema({}, [], "Server record.", true) }] }
12870
+ }));
12871
+ remoteCustomerContracts.push({
12872
+ name: "quote_skill",
12873
+ title: "Quote Remote Skill",
12874
+ description: "Get a server credit quote without submitting a run.",
12875
+ params: ["name", "input?", "args?"],
12876
+ category: "execution",
12877
+ sideEffects: "none",
12878
+ stable: true,
12879
+ inputSchema: objectSchema({ name: skillNameInput, input: runInputSchema, args: runArgsSchema }, ["name"]),
12880
+ outputSchema: objectSchema({ skill: stringSchema("Canonical server skill."), pricing: objectSchema({}, [], "Quoted integer credits.", true) }, ["skill", "pricing"], undefined, true)
12881
+ });
12882
+ remoteCustomerContracts.push({
12883
+ name: "download_run_artifact",
12884
+ title: "Download Verified Run Artifact",
12885
+ description: "Return verified artifact bytes as base64, bounded to 1 MiB.",
12886
+ params: ["run_id", "artifact_id"],
12887
+ category: "execution",
12888
+ sideEffects: "none",
12889
+ stable: true,
12890
+ inputSchema: objectSchema({ run_id: stringSchema("Run identifier."), artifact_id: stringSchema("Artifact identifier.") }, ["run_id", "artifact_id"]),
12891
+ outputSchema: objectSchema({ id: stringSchema("Artifact identifier."), fileName: stringSchema("Artifact file name."), base64: stringSchema("Verified bytes."), sha256: stringSchema("SHA256 digest."), byteSize: { type: "integer", minimum: 0 } }, ["id", "fileName", "base64", "sha256", "byteSize"])
12892
+ });
12893
+ var contracts = [...toolContracts, ...remoteCustomerContracts].sort((a, b) => a.name.localeCompare(b.name));
12449
12894
  var resourceContracts = [
12450
12895
  {
12451
12896
  uri: "skills://mcp/contracts",
@@ -12664,7 +13109,7 @@ function isApiMode(env = process.env) {
12664
13109
  }
12665
13110
  }
12666
13111
  // src/lib/native-storage.ts
12667
- import { createHash as createHash5, createHmac as createHmac2 } from "crypto";
13112
+ import { createHash as createHash6, createHmac as createHmac2 } from "crypto";
12668
13113
  import {
12669
13114
  existsSync as existsSync17,
12670
13115
  mkdirSync as mkdirSync11,
@@ -12840,7 +13285,7 @@ function exportSkillsLocalSnapshot(targetDir = process.cwd(), options = {}) {
12840
13285
  files.push({
12841
13286
  path: relativePath,
12842
13287
  sizeBytes: bytes.byteLength,
12843
- sha256: createHash5("sha256").update(bytes).digest("hex"),
13288
+ sha256: createHash6("sha256").update(bytes).digest("hex"),
12844
13289
  ...options.includeFileContents ? { contentBase64: Buffer.from(bytes).toString("base64") } : {}
12845
13290
  });
12846
13291
  }
@@ -12865,7 +13310,7 @@ function importSkillsLocalSnapshot(snapshot, targetDir = process.cwd(), options
12865
13310
  continue;
12866
13311
  }
12867
13312
  const bytes = Buffer.from(file.contentBase64, "base64");
12868
- const hash = createHash5("sha256").update(bytes).digest("hex");
13313
+ const hash = createHash6("sha256").update(bytes).digest("hex");
12869
13314
  if (hash !== file.sha256) {
12870
13315
  throw new Error(`Snapshot file checksum mismatch: ${file.path}`);
12871
13316
  }
@@ -13197,7 +13642,7 @@ function toIsoString(value) {
13197
13642
  return Number.isNaN(date.getTime()) ? value : date.toISOString();
13198
13643
  }
13199
13644
  function sha256Hex2(value) {
13200
- return createHash5("sha256").update(value).digest("hex");
13645
+ return createHash6("sha256").update(value).digest("hex");
13201
13646
  }
13202
13647
  function normalizeHeaders(headers) {
13203
13648
  const result = {};
@@ -13245,7 +13690,7 @@ function toArrayBuffer(bytes) {
13245
13690
  return buffer;
13246
13691
  }
13247
13692
  // src/lib/station-snapshot.ts
13248
- import { createHash as createHash6 } from "crypto";
13693
+ import { createHash as createHash7 } from "crypto";
13249
13694
  import {
13250
13695
  copyFileSync as copyFileSync2,
13251
13696
  mkdirSync as mkdirSync12,
@@ -13420,7 +13865,7 @@ function validateStationId(stationId) {
13420
13865
  }
13421
13866
  }
13422
13867
  function sha256File(filePath) {
13423
- return createHash6("sha256").update(readFileSync17(filePath)).digest("hex");
13868
+ return createHash7("sha256").update(readFileSync17(filePath)).digest("hex");
13424
13869
  }
13425
13870
  function scanHome(definition, homesRoot) {
13426
13871
  const homePath = homePathFor(definition, homesRoot);
@@ -13545,7 +13990,7 @@ function writeStationSnapshot(options) {
13545
13990
  copyFileSync2(plan.source.fullPath, destination);
13546
13991
  written += 1;
13547
13992
  }
13548
- const unchanged = plans.length - untouched.length;
13993
+ const unchanged2 = plans.length - untouched.length;
13549
13994
  const manifest = {
13550
13995
  schema: STATION_SYNC_MANIFEST_SCHEMA,
13551
13996
  stationId: options.stationId,
@@ -13553,7 +13998,7 @@ function writeStationSnapshot(options) {
13553
13998
  producer: STATION_SNAPSHOT_PRODUCER,
13554
13999
  stats: {
13555
14000
  written,
13556
- unchanged,
14001
+ unchanged: unchanged2,
13557
14002
  files: plans.length,
13558
14003
  bytes: totalBytes
13559
14004
  },
@@ -13566,12 +14011,12 @@ function writeStationSnapshot(options) {
13566
14011
  return {
13567
14012
  ...base,
13568
14013
  mode: "populate",
13569
- stats: { files: plans.length, bytes: totalBytes, written, unchanged },
14014
+ stats: { files: plans.length, bytes: totalBytes, written, unchanged: unchanged2 },
13570
14015
  manifestPath
13571
14016
  };
13572
14017
  }
13573
14018
  // src/lib/station-hydrate.ts
13574
- import { createHash as createHash7 } from "crypto";
14019
+ import { createHash as createHash8 } from "crypto";
13575
14020
  import {
13576
14021
  copyFileSync as copyFileSync3,
13577
14022
  mkdirSync as mkdirSync13,
@@ -13777,7 +14222,7 @@ function skillSha256(skill) {
13777
14222
  return sha256File(skill.files[0].winner.fullPath);
13778
14223
  }
13779
14224
  const joined = skill.files.map((file) => sha256File(file.winner.fullPath));
13780
- return createHash7("sha256").update(joined.sort().join(`
14225
+ return createHash8("sha256").update(joined.sort().join(`
13781
14226
  `)).digest("hex");
13782
14227
  }
13783
14228
  function writeStationHydration(options) {
@@ -13840,7 +14285,7 @@ function writeStationHydration(options) {
13840
14285
  copyFileSync3(entry.fullPath, entry.destination);
13841
14286
  written += 1;
13842
14287
  }
13843
- const unchanged = plan.totalFiles - written;
14288
+ const unchanged2 = plan.totalFiles - written;
13844
14289
  const hydration = {
13845
14290
  schema: STATION_HYDRATION_MANIFEST_SCHEMA,
13846
14291
  stationId: options.stationId,
@@ -13851,7 +14296,7 @@ function writeStationHydration(options) {
13851
14296
  stats: {
13852
14297
  idents: plan.winners.length,
13853
14298
  written,
13854
- unchanged,
14299
+ unchanged: unchanged2,
13855
14300
  files: plan.totalFiles,
13856
14301
  bytes: plan.totalBytes
13857
14302
  },
@@ -13864,10 +14309,122 @@ function writeStationHydration(options) {
13864
14309
  return {
13865
14310
  ...base,
13866
14311
  mode: "apply",
13867
- stats: { ...base.stats, written, unchanged },
14312
+ stats: { ...base.stats, written, unchanged: unchanged2 },
13868
14313
  manifestPath: hydrationManifestPath
13869
14314
  };
13870
14315
  }
14316
+ // src/lib/remote-auth.ts
14317
+ var MAX_ERROR_DETAIL_LENGTH = 200;
14318
+
14319
+ class HostedApiError extends Error {
14320
+ status;
14321
+ code;
14322
+ detail;
14323
+ endpoint;
14324
+ apiUrl;
14325
+ constructor(message, options = {}) {
14326
+ super(message);
14327
+ this.name = "HostedApiError";
14328
+ this.status = options.status;
14329
+ this.code = options.code;
14330
+ this.detail = options.detail;
14331
+ this.endpoint = options.endpoint;
14332
+ this.apiUrl = options.apiUrl;
14333
+ }
14334
+ }
14335
+ async function requestAuthApi(instance, path, options) {
14336
+ const url = normalizeSkillsApiOrigin(instance);
14337
+ const safeUrl = url;
14338
+ const endpoint = `${(options?.method || "GET").toUpperCase()} ${safeUrl}${path}`;
14339
+ let res;
14340
+ try {
14341
+ res = await fetch(`${url}${path}`, {
14342
+ ...options,
14343
+ redirect: "error",
14344
+ signal: options?.signal ?? AbortSignal.timeout(15000),
14345
+ headers: { "Content-Type": "application/json", ...options?.headers }
14346
+ });
14347
+ } catch (err) {
14348
+ throw new HostedApiError(`Unable to reach the Skills API: ${err.message}`, {
14349
+ endpoint,
14350
+ apiUrl: safeUrl
14351
+ });
14352
+ }
14353
+ const text = await res.text();
14354
+ const body = text ? parseJsonBody(text) : {};
14355
+ if (!res.ok) {
14356
+ const record = isRecord4(body) ? body : {};
14357
+ const detail = typeof record.detail === "string" ? record.detail : undefined;
14358
+ const error = typeof record.error === "string" ? record.error : undefined;
14359
+ const code = typeof record.code === "string" ? record.code : undefined;
14360
+ throw new HostedApiError(detail || error || `${res.status} ${res.statusText}`, {
14361
+ status: res.status,
14362
+ code,
14363
+ detail,
14364
+ endpoint,
14365
+ apiUrl: safeUrl
14366
+ });
14367
+ }
14368
+ return body;
14369
+ }
14370
+ function parseJsonBody(text) {
14371
+ try {
14372
+ return JSON.parse(text);
14373
+ } catch {
14374
+ return { detail: condenseErrorBody(text) };
14375
+ }
14376
+ }
14377
+ function condenseErrorBody(text) {
14378
+ const stripped = /<[a-z!/]/i.test(text) ? text.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]*>/g, " ") : text;
14379
+ const collapsed = stripped.replace(/\s+/g, " ").trim();
14380
+ if (collapsed.length <= MAX_ERROR_DETAIL_LENGTH)
14381
+ return collapsed;
14382
+ return `${collapsed.slice(0, MAX_ERROR_DETAIL_LENGTH - 1).trimEnd()}\u2026`;
14383
+ }
14384
+ function isRecord4(value) {
14385
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
14386
+ }
14387
+
14388
+ class RemoteSkillsAuthClient {
14389
+ apiOrigin;
14390
+ constructor(apiUrl) {
14391
+ this.apiOrigin = normalizeSkillsApiOrigin(apiUrl);
14392
+ }
14393
+ requestCode(email) {
14394
+ return this.request("/api/auth/login", { method: "POST", body: JSON.stringify({ email }) });
14395
+ }
14396
+ verifyCode(email, code) {
14397
+ return this.request("/api/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) });
14398
+ }
14399
+ startDevice() {
14400
+ return this.request("/api/auth/device/start", { method: "POST", body: JSON.stringify({ client: "skills-sdk" }) });
14401
+ }
14402
+ pollDevice(deviceCode) {
14403
+ return this.request("/api/auth/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) });
14404
+ }
14405
+ async sessionClient(email, code) {
14406
+ if (!email.includes("@") || !/^\d{6}$/.test(code))
14407
+ throw new Error("Fresh email and six-digit verification code are required to manage API keys");
14408
+ const login = await this.verifyCode(email, code);
14409
+ if (!login || typeof login.token !== "string" || !login.token)
14410
+ throw new Error("The server did not return an authorized account session");
14411
+ return new RemoteSkillsClient(login.token, this.apiOrigin);
14412
+ }
14413
+ async createApiKey(email, code, name, scopes) {
14414
+ return (await this.sessionClient(email, code)).createApiKey(name, scopes);
14415
+ }
14416
+ async listApiKeys(email, code) {
14417
+ return (await this.sessionClient(email, code)).listApiKeys();
14418
+ }
14419
+ async revokeApiKey(email, code, keyId) {
14420
+ return (await this.sessionClient(email, code)).revokeApiKey(keyId);
14421
+ }
14422
+ request(path, options) {
14423
+ if (!["/api/auth/login", "/api/auth/verify", "/api/auth/device/start", "/api/auth/device/token", "/api/auth/keys", "/api/auth/whoami"].includes(path))
14424
+ throw new Error("Unsupported authentication operation");
14425
+ return requestAuthApi(this.apiOrigin, path, options);
14426
+ }
14427
+ }
13871
14428
  export {
13872
14429
  writeStationSnapshot,
13873
14430
  writeStationHydration,
@@ -14079,8 +14636,10 @@ export {
14079
14636
  SKILLS_API_KEY_ENV,
14080
14637
  SKILLS,
14081
14638
  RemoteSkillsClient,
14639
+ RemoteSkillsAuthClient,
14082
14640
  RemoteRouteUnsupportedError,
14083
14641
  RemoteRequestError,
14642
+ RemoteCreditApprovalError,
14084
14643
  REMOTE_SKILL_RUN_CONTRACT_VERSION,
14085
14644
  REFUSED_SCANNER_FLAGGED,
14086
14645
  PullSkillError,
@@ -14090,6 +14649,7 @@ export {
14090
14649
  PORTABLE_SKILL_DEFAULT_VERSION,
14091
14650
  MissingSkillsFleetError,
14092
14651
  MCP_CONTRACT_SCHEMA_VERSION,
14652
+ HostedApiError,
14093
14653
  DEFAULT_EXPORT_DIR,
14094
14654
  CATEGORIES,
14095
14655
  BASIC_SKILL_NAMES,