@solgridai/cli 0.1.1 → 0.2.0-beta.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.
package/dist/index.js CHANGED
@@ -2075,7 +2075,7 @@ function useColor() {
2075
2075
  // node_modules/commander/index.js
2076
2076
  var program = new Command;
2077
2077
  // src/build-info.ts
2078
- var buildInfo = { packageName: "@solgridai/cli", version: "0.1.1" };
2078
+ var buildInfo = { packageName: "@solgridai/cli", version: "0.2.0-beta.1" };
2079
2079
 
2080
2080
  // src/core/errors.ts
2081
2081
  class CliError extends Error {
@@ -2091,16 +2091,46 @@ class CliError extends Error {
2091
2091
  this.details = options.details ?? [];
2092
2092
  this.data = options.data;
2093
2093
  }
2094
+ copyWith(changes) {
2095
+ return new CliError(this.code, this.message, {
2096
+ hint: changes.hint ?? this.hint,
2097
+ details: this.details,
2098
+ data: changes.data ?? this.data,
2099
+ cause: this
2100
+ });
2101
+ }
2094
2102
  }
2095
2103
 
2096
2104
  class ApiError extends CliError {
2097
2105
  status;
2098
2106
  key;
2107
+ keyDetail;
2108
+ routeMissing;
2109
+ apiKeySentTo;
2110
+ signedUrl;
2099
2111
  constructor(code, message, options = {}) {
2100
2112
  super(code, message, options);
2101
2113
  this.name = "ApiError";
2102
2114
  this.status = options.status;
2103
2115
  this.key = options.key;
2116
+ this.keyDetail = options.keyDetail;
2117
+ this.routeMissing = options.routeMissing === true;
2118
+ this.apiKeySentTo = options.apiKeySentTo;
2119
+ this.signedUrl = options.signedUrl === true;
2120
+ }
2121
+ copyWith(changes) {
2122
+ return new ApiError(this.code, this.message, {
2123
+ ...this.status !== undefined ? { status: this.status } : {},
2124
+ ...this.key ? { key: this.key } : {},
2125
+ ...this.keyDetail ? { keyDetail: this.keyDetail } : {},
2126
+ routeMissing: this.routeMissing,
2127
+ ...this.apiKeySentTo ? { apiKeySentTo: this.apiKeySentTo } : {},
2128
+ signedUrl: this.signedUrl,
2129
+ hint: changes.hint ?? this.hint,
2130
+ details: this.details,
2131
+ data: changes.data ?? this.data,
2132
+ cause: this
2133
+ });
2104
2134
  }
2105
2135
  }
2106
2136
  function errorCodeForStatus(status) {
@@ -2181,7 +2211,19 @@ var isPrice = objectWith({
2181
2211
  selectors: optional(listOf(objectWith({ key: isString, label: optional(isText) }))),
2182
2212
  variants: optional(listOf(objectWith({ when: isObject, price: isLeafPrice }))),
2183
2213
  tiers: optional(listOf(objectWith({ label: isText, when: isObject, price: isLeafPrice }))),
2184
- notes: optional(listOf(isText))
2214
+ notes: optional(listOf(isText)),
2215
+ unit: optional(isText),
2216
+ unitSize: optional(isNumber),
2217
+ description: optional(isText),
2218
+ rates: optional(listOf(objectWith({
2219
+ meter: isString,
2220
+ unitPriceUsd: isNumber,
2221
+ unitSize: optional(isNumber),
2222
+ freeUnits: optional(isNumber),
2223
+ maximumUnits: optional(isNumber),
2224
+ roundUpTo: optional(isNumber),
2225
+ conditions: optional(isObject)
2226
+ })))
2185
2227
  });
2186
2228
  var isMetrics = objectWith({
2187
2229
  status: isString,
@@ -2225,12 +2267,12 @@ async function inspect(client, params) {
2225
2267
  const data = await client.post("/v1/tools/inspect", params);
2226
2268
  return expectShape("/v1/tools/inspect", data, TOOL_FIELDS);
2227
2269
  }
2228
- async function createRun(client, params, idempotencyKey) {
2270
+ async function createRun(client, params, idempotencyKey, request = {}) {
2229
2271
  const data = await client.post("/v1/runs/create", {
2230
2272
  provider: params.provider,
2231
2273
  endpoint: params.endpoint,
2232
2274
  ...params.input ? { input: params.input } : {}
2233
- }, { "Idempotency-Key": idempotencyKey });
2275
+ }, { "Idempotency-Key": idempotencyKey }, request);
2234
2276
  return expectShape("/v1/runs/create", data, RUN_FIELDS);
2235
2277
  }
2236
2278
  async function stopRun(client, runId) {
@@ -2265,7 +2307,8 @@ var TOOL_FIELDS = {
2265
2307
  provider: isString,
2266
2308
  endpoint: isString,
2267
2309
  description: isText,
2268
- price: isPrice,
2310
+ price: optional(isPrice),
2311
+ externalPrice: optional(isText),
2269
2312
  tags: optional(listOf(isText)),
2270
2313
  notes: optional(listOf(isText)),
2271
2314
  metrics: optional(isMetrics)
@@ -2501,6 +2544,41 @@ function formatAmount(amount) {
2501
2544
  const sign = negative ? "-" : "";
2502
2545
  return amount.currency === "USD" ? `${sign}$${number}` : `${sign}${number} ${amount.currency}`;
2503
2546
  }
2547
+ function plainDecimal(value) {
2548
+ if (!Number.isFinite(value))
2549
+ return;
2550
+ const text = String(value);
2551
+ const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);
2552
+ if (!match)
2553
+ return text;
2554
+ const [, sign = "", whole = "", fraction = "", exponent = "0"] = match;
2555
+ const digits = whole + fraction;
2556
+ const point = whole.length + Number(exponent);
2557
+ if (point <= 0)
2558
+ return `${sign}0.${"0".repeat(-point)}${digits}`;
2559
+ if (point >= digits.length)
2560
+ return `${sign}${digits}${"0".repeat(point - digits.length)}`;
2561
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
2562
+ }
2563
+ function ceilToSixDecimals(value) {
2564
+ const decimal = typeof value === "number" ? plainDecimal(value) : value.trim();
2565
+ if (decimal === undefined || !/^\d+(\.\d+)?$/.test(decimal))
2566
+ return;
2567
+ const [whole = "0", fraction = ""] = decimal.split(".");
2568
+ let micros = BigInt(whole + fraction.slice(0, 6).padEnd(6, "0"));
2569
+ if (/[1-9]/.test(fraction.slice(6)))
2570
+ micros += 1n;
2571
+ const text = micros.toString().padStart(7, "0");
2572
+ return `${text.slice(0, -6)}.${text.slice(-6)}`;
2573
+ }
2574
+ function formatUnitPrice(amount) {
2575
+ if (!amount || typeof amount.value !== "number" && typeof amount.value !== "string")
2576
+ return "-";
2577
+ const decimal = ceilToSixDecimals(amount.value);
2578
+ if (decimal === undefined)
2579
+ return formatAmount(amount);
2580
+ return amount.currency === "USD" ? `$${decimal}` : `${decimal} ${amount.currency}`;
2581
+ }
2504
2582
 
2505
2583
  // src/core/api/client.ts
2506
2584
  var DEFAULT_TIMEOUT_MS = 30000;
@@ -2540,10 +2618,36 @@ class ApiClient {
2540
2618
  }
2541
2619
  if (!response.ok) {
2542
2620
  const text = await response.text().catch(() => "");
2543
- throw envelopeError(response, parseEnvelope(text), text, url.split("?")[0] ?? url);
2621
+ throw envelopeError(response, parseEnvelope(text), text, url.split("?")[0] ?? url, {
2622
+ signedUrl: true
2623
+ });
2624
+ }
2625
+ return response;
2626
+ }
2627
+ async openDownload(path) {
2628
+ const url = `${this.baseUrl}${path}`;
2629
+ let response;
2630
+ try {
2631
+ response = await this.options.fetch(url, {
2632
+ method: "GET",
2633
+ headers: {
2634
+ "X-Solgrid-Client": this.options.clientId,
2635
+ ...this.options.apiKey ? { Authorization: `Bearer ${this.options.apiKey}` } : {}
2636
+ },
2637
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS)
2638
+ });
2639
+ } catch (cause) {
2640
+ throw transportError(cause, this.baseUrl, path, TRANSFER_TIMEOUT_MS);
2641
+ }
2642
+ if (!response.ok) {
2643
+ const text = await response.text().catch(() => "");
2644
+ throw envelopeError(response, parseEnvelope(text), text, url, this.sentWith());
2544
2645
  }
2545
2646
  return response;
2546
2647
  }
2648
+ sentWith() {
2649
+ return this.options.apiKey ? { apiKeySentTo: this.baseUrl } : {};
2650
+ }
2547
2651
  async send(method, path, options = {}) {
2548
2652
  const timeoutMs = options.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2549
2653
  let response;
@@ -2567,7 +2671,7 @@ class ApiClient {
2567
2671
  const url = `${this.baseUrl}${path.split("?")[0] ?? path}`;
2568
2672
  const envelope = parseEnvelope(text);
2569
2673
  if (!response.ok || envelope?.success === false) {
2570
- throw envelopeError(response, envelope, text, url);
2674
+ throw envelopeError(response, envelope, text, url, this.sentWith());
2571
2675
  }
2572
2676
  if (!envelope || !("data" in envelope)) {
2573
2677
  throw new ApiError("INVALID_RESPONSE", `Unexpected response from ${path}: ${describeBody(response, text)}`, { status: response.status });
@@ -2575,6 +2679,9 @@ class ApiClient {
2575
2679
  return envelope.data;
2576
2680
  }
2577
2681
  }
2682
+ function isResponseData(envelope) {
2683
+ return typeof envelope?.success === "boolean";
2684
+ }
2578
2685
  function parseEnvelope(text) {
2579
2686
  try {
2580
2687
  const value = JSON.parse(text);
@@ -2583,25 +2690,30 @@ function parseEnvelope(text) {
2583
2690
  return;
2584
2691
  }
2585
2692
  }
2586
- function envelopeError(response, envelope, text, url) {
2587
- if (!envelope && response.status === 404) {
2693
+ function envelopeError(response, envelope, text, url, auth) {
2694
+ if (response.status === 404 && !isResponseData(envelope)) {
2588
2695
  return new ApiError("NOT_FOUND", `${url} does not exist`, {
2589
2696
  status: 404,
2697
+ routeMissing: true,
2590
2698
  hint: "This Solgrid API may not support this command yet, or SOLGRID_API_BASE_URL points to the wrong server."
2591
2699
  });
2592
2700
  }
2593
2701
  const bodyCode = typeof envelope?.code === "number" && envelope.code >= 400 && envelope.code <= 599 ? envelope.code : undefined;
2594
2702
  const status = bodyCode ?? (response.ok ? 400 : response.status);
2595
- const key = typeof envelope?.message === "string" && envelope.message ? envelope.message : undefined;
2703
+ const serverMessage = typeof envelope?.message === "string" && envelope.message ? envelope.message : undefined;
2704
+ const key = errorKeyOf(envelope);
2705
+ const keyDetail = keyDetailOf(envelope);
2596
2706
  const details = [
2597
- ...validationDetails(envelope?.errorData),
2707
+ ...validationDetails(envelope?.errorData, key),
2598
2708
  ...frameworkErrorDetails(envelope?.errors)
2599
2709
  ];
2600
2710
  const serverJsonHint = hasSerializerErrors(envelope?.errors) ? "The server failed to read JSON while handling this request. This is most likely a problem on the Solgrid side, not in your input; try again later or report it." : undefined;
2601
- const message = key ?? (envelope ? `HTTP ${response.status}` : `HTTP ${response.status}: ${describeBody(response, text)}`);
2711
+ const message = serverMessage ?? (envelope ? `HTTP ${response.status}` : `HTTP ${response.status}: ${describeBody(response, text)}`);
2602
2712
  return new ApiError(errorCodeForStatus(status), message, {
2603
2713
  status,
2604
2714
  ...key ? { key } : {},
2715
+ ...keyDetail ? { keyDetail } : {},
2716
+ ...auth,
2605
2717
  ...serverJsonHint ? { hint: serverJsonHint } : {},
2606
2718
  details
2607
2719
  });
@@ -2616,10 +2728,31 @@ function hasSerializerErrors(errors) {
2616
2728
  const value = errors?.serializerErrors;
2617
2729
  return Array.isArray(value) ? value.length > 0 : typeof value === "string" && value.length > 0;
2618
2730
  }
2619
- function validationDetails(items) {
2731
+ function keyItemOf(envelope) {
2732
+ const items = Array.isArray(envelope?.errorData) ? envelope.errorData : [];
2733
+ return items.find((entry) => entry && typeof entry === "object" && !entry.propertyName && typeof entry.errorCode === "string" && ERROR_KEY.test(entry.errorCode));
2734
+ }
2735
+ function keyDetailOf(envelope) {
2736
+ const item = keyItemOf(envelope);
2737
+ if (!item)
2738
+ return;
2739
+ const { errorCode: _code, errorMessage: _message, propertyName: _property, ...rest } = item;
2740
+ return Object.keys(rest).length > 0 ? rest : undefined;
2741
+ }
2742
+ var ERROR_KEY = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
2743
+ function errorKeyOf(envelope) {
2744
+ const item = keyItemOf(envelope);
2745
+ if (item?.errorCode)
2746
+ return item.errorCode;
2747
+ const message = envelope?.message;
2748
+ return typeof message === "string" && ERROR_KEY.test(message) ? message : undefined;
2749
+ }
2750
+ function validationDetails(items, key) {
2620
2751
  if (!Array.isArray(items))
2621
2752
  return [];
2622
2753
  return items.flatMap((item) => {
2754
+ if (key && !item?.propertyName && item?.errorCode === key)
2755
+ return [];
2623
2756
  const text = item?.errorMessage?.trim();
2624
2757
  if (!text)
2625
2758
  return [];
@@ -2677,6 +2810,62 @@ var KEY_PRESENTATIONS = {
2677
2810
  message: "This tool is not available to run.",
2678
2811
  hint: "Check the provider and endpoint with `solgrid inspect`, or pick another tool with `solgrid discover`."
2679
2812
  },
2813
+ skill_archive_size_invalid: {
2814
+ message: "The Skill package is too large unpacked.",
2815
+ hint: "A Skill may hold at most 10,000,000 bytes of files."
2816
+ },
2817
+ skill_archive_entries_invalid: {
2818
+ message: "The Skill package is empty or has too many entries (at most 1,000).",
2819
+ hint: undefined
2820
+ },
2821
+ skill_archive_file_count_exceeded: {
2822
+ message: "The Skill package has more than 200 files.",
2823
+ hint: undefined
2824
+ },
2825
+ skill_archive_path_invalid: {
2826
+ message: "The Skill package has a path SkillHub does not accept.",
2827
+ hint: "Remove links, encrypted entries, absolute paths, `..`, backslashes, and names that differ only in case."
2828
+ },
2829
+ skill_archive_scripts_not_supported: {
2830
+ message: "This Solgrid server does not accept a scripts directory in a Skill.",
2831
+ hint: "Remove the scripts directories, or try again once the server allows them."
2832
+ },
2833
+ skill_archive_structure_invalid: {
2834
+ message: "The Skill package needs SKILL.md at its top, or inside a single top-level directory.",
2835
+ hint: "Pack the Skill directory with `--dir` and the CLI builds the right layout."
2836
+ },
2837
+ skill_archive_markdown_invalid: {
2838
+ message: "SKILL.md in the package is empty or not valid UTF-8.",
2839
+ hint: undefined
2840
+ },
2841
+ skill_archive_frontmatter_invalid: {
2842
+ message: "SKILL.md must start with YAML frontmatter between --- lines, followed by the instructions.",
2843
+ hint: "The frontmatter needs `name: <the Skill's slug>` and `description: <what it does>`."
2844
+ },
2845
+ skill_archive_name_invalid: {
2846
+ message: "The name in SKILL.md's frontmatter is missing or not valid.",
2847
+ hint: 'Use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".'
2848
+ },
2849
+ skill_archive_description_invalid: {
2850
+ message: "The description in SKILL.md's frontmatter is missing or longer than 1,024 characters.",
2851
+ hint: undefined
2852
+ },
2853
+ creative_case_skill_name_mismatch: {
2854
+ message: "The name in SKILL.md must equal the Skill's slug.",
2855
+ hint: "Set `name: <slug>` in SKILL.md's frontmatter, or pass the matching --slug."
2856
+ },
2857
+ creative_case_slug_invalid: {
2858
+ message: "The slug is not valid.",
2859
+ hint: 'Use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".'
2860
+ },
2861
+ skill_archive_content_invalid: {
2862
+ message: "The Skill package is damaged or could not be unpacked.",
2863
+ hint: "Pack it again, e.g. with `--dir`."
2864
+ },
2865
+ file_content_type_not_allowed: {
2866
+ message: "Solgrid does not accept this kind of file.",
2867
+ hint: undefined
2868
+ },
2680
2869
  file_size_invalid: {
2681
2870
  message: "The file is larger than Solgrid accepts for its type.",
2682
2871
  hint: "Limits: images and audio 20 MiB, video 200 MiB, Skill ZIP 10 MB."
@@ -2703,16 +2892,62 @@ function presentError(error) {
2703
2892
  }
2704
2893
  function presentApiError(error) {
2705
2894
  const base = explain(error);
2706
- const hint = [base.hint, error.hint].filter(Boolean).join(`
2895
+ const steps = [base.hint, error.hint];
2896
+ if (error.code === "AUTH_FAILED")
2897
+ steps.reverse();
2898
+ const hint = steps.filter(Boolean).join(`
2707
2899
  `) || undefined;
2708
2900
  return { message: base.message, hint };
2709
2901
  }
2902
+ function isAmount(value) {
2903
+ const amount = value;
2904
+ return typeof amount === "object" && amount !== null && (typeof amount.value === "string" || typeof amount.value === "number");
2905
+ }
2906
+ var RAISE_BUDGET = `Raise or disable the daily budget at ${CONSOLE_URLS.budgets}`;
2907
+ function explainDailyBudget(detail) {
2908
+ const { reason, spent, held, requestedReservation, dailyLimit, remaining } = detail ?? {};
2909
+ const amounts = [spent, held, requestedReservation, dailyLimit, remaining];
2910
+ if (!amounts.every(isAmount))
2911
+ return;
2912
+ const [spentText, heldText, needText, limitText, leftText] = amounts.map((amount) => formatAmount(amount));
2913
+ switch (reason) {
2914
+ case "daily_spend_limit_reached":
2915
+ return {
2916
+ message: `Today's spending (${spentText}) has reached the daily budget of ${limitText}.`,
2917
+ hint: `${RAISE_BUDGET}, or try again tomorrow (the budget resets at 00:00 UTC).`
2918
+ };
2919
+ case "requested_reservation_exceeds_remaining_budget":
2920
+ return {
2921
+ message: `This run requires a reservation of ${needText}, more than the ${leftText} left of today's ${limitText} budget (${spentText} spent).`,
2922
+ hint: RAISE_BUDGET
2923
+ };
2924
+ case "reservation_exceeds_remaining_budget":
2925
+ return {
2926
+ message: `Runs still in progress hold ${heldText} of today's ${limitText} budget, leaving ${leftText}; this run requires a reservation of ${needText}.`,
2927
+ hint: `Wait for those runs to finish (what they do not use is released), then retry with the same idempotency key, or submit runs one at a time.
2928
+ ${RAISE_BUDGET}`
2929
+ };
2930
+ default:
2931
+ return;
2932
+ }
2933
+ }
2710
2934
  function explain(error) {
2935
+ if (error.key === "daily_budget_exceeded") {
2936
+ const budget = explainDailyBudget(error.keyDetail);
2937
+ if (budget)
2938
+ return budget;
2939
+ }
2711
2940
  const known = error.key ? KEY_PRESENTATIONS[error.key] : undefined;
2712
2941
  if (known)
2713
2942
  return known;
2714
2943
  switch (error.code) {
2715
2944
  case "AUTH_FAILED":
2945
+ if (error.signedUrl) {
2946
+ return {
2947
+ message: "The download link was refused.",
2948
+ hint: "Signed links expire after 24 hours. Get a fresh one (e.g. `solgrid files url -i <fileId>`) and try again."
2949
+ };
2950
+ }
2716
2951
  return {
2717
2952
  message: "The API key is missing, invalid or revoked.",
2718
2953
  hint: `Create a key at ${CONSOLE_URLS.apiKeys}, then run: solgrid keys add -k <key> -l <label>`
@@ -2720,7 +2955,10 @@ function explain(error) {
2720
2955
  case "INVALID_INPUT":
2721
2956
  return { message: `Invalid request: ${error.message}`, hint: undefined };
2722
2957
  case "NOT_FOUND":
2723
- return { message: `Not found: ${error.message}`, hint: undefined };
2958
+ return {
2959
+ message: error.message === error.key ? `Not found: ${error.message}` : error.message,
2960
+ hint: undefined
2961
+ };
2724
2962
  case "TOO_LARGE":
2725
2963
  return {
2726
2964
  message: "The upload was rejected as too large.",
@@ -2826,6 +3064,7 @@ function createOutput(ctx, json) {
2826
3064
  message,
2827
3065
  ...cliError instanceof ApiError && cliError.status !== undefined ? { status: cliError.status } : {},
2828
3066
  ...cliError instanceof ApiError && cliError.key ? { key: cliError.key } : {},
3067
+ ...cliError instanceof ApiError && cliError.keyDetail ? { detail: cliError.keyDetail } : {},
2829
3068
  ...cliError.details.length > 0 ? { details: cliError.details } : {},
2830
3069
  ...hint ? { hint } : {}
2831
3070
  },
@@ -2864,13 +3103,14 @@ async function runAction(ctx, json, body) {
2864
3103
  try {
2865
3104
  await body(out);
2866
3105
  } catch (error) {
2867
- out.error(error);
3106
+ out.error(withAuthContext(ctx, error));
2868
3107
  ctx.exitCode = 1;
2869
3108
  }
2870
3109
  }
2871
3110
  function configStore(ctx) {
2872
3111
  return new ConfigStore(resolveConfigDir(ctx.env, ctx.homeDir));
2873
3112
  }
3113
+ var sentKeys = new WeakMap;
2874
3114
  function apiClient(ctx) {
2875
3115
  const resolved = resolveApiKey(configStore(ctx), ctx.env);
2876
3116
  if (!resolved) {
@@ -2878,6 +3118,7 @@ function apiClient(ctx) {
2878
3118
  hint: `Create a key at ${CONSOLE_URLS.apiKeys}, then run: solgrid keys add -k <key> -l <label>`
2879
3119
  });
2880
3120
  }
3121
+ sentKeys.set(ctx, resolved);
2881
3122
  return new ApiClient({
2882
3123
  baseUrl: ctx.env[ENV.apiBaseUrl]?.trim() || DEFAULT_API_BASE_URL,
2883
3124
  apiKey: resolved.key,
@@ -2885,6 +3126,21 @@ function apiClient(ctx) {
2885
3126
  fetch: ctx.fetch
2886
3127
  });
2887
3128
  }
3129
+ function withAuthContext(ctx, error) {
3130
+ const key = sentKeys.get(ctx);
3131
+ if (!(error instanceof ApiError) || error.code !== "AUTH_FAILED" || !key)
3132
+ return error;
3133
+ const baseUrl = error.apiKeySentTo;
3134
+ if (!baseUrl)
3135
+ return error;
3136
+ const which = key.source === "env" ? `the key from ${ENV.apiKey} (${maskApiKey(key.key)})` : `key "${key.label}" (${maskApiKey(key.key)})`;
3137
+ const where = baseUrl === DEFAULT_API_BASE_URL ? `${baseUrl}, the production API` : `${baseUrl}, set by ${ENV.apiBaseUrl}`;
3138
+ const advice = baseUrl === DEFAULT_API_BASE_URL ? `If this key is for another environment, set ${ENV.apiBaseUrl} to that environment's API.` : `If this key is for production, unset ${ENV.apiBaseUrl}.`;
3139
+ return error.copyWith({
3140
+ hint: [`Sent ${which} to ${where}. ${advice}`, error.hint].filter(Boolean).join(`
3141
+ `)
3142
+ });
3143
+ }
2888
3144
 
2889
3145
  // src/commands/account.ts
2890
3146
  function registerAccountCommands(program, ctx) {
@@ -3417,14 +3673,19 @@ var TRANSIENT = new Set([
3417
3673
  "RATE_LIMITED",
3418
3674
  "INVALID_RESPONSE"
3419
3675
  ]);
3676
+ var SUBMIT_TIMEOUT_MS = 10 * 60000;
3677
+ var LOOKUP_TIMEOUT_MS = 30000;
3420
3678
  var RESUBMIT_BACKOFF_MS = [1000, 2000, 4000];
3421
3679
  var LOOKUP_BACKOFF_MS = [1000, 2000];
3422
3680
  async function submitRun(client, request, options) {
3423
3681
  const { idempotencyKey } = request;
3682
+ const budget = submitBudget(idempotencyKey, options);
3424
3683
  for (let resubmissions = 0;; resubmissions++) {
3425
3684
  let failure;
3426
3685
  try {
3427
- const run = await createRun(client, request, idempotencyKey);
3686
+ const run = await createRun(client, request, idempotencyKey, {
3687
+ timeoutMs: budget.timeoutFor(SUBMIT_TIMEOUT_MS, { wholeBudget: true })
3688
+ });
3428
3689
  return { run, recovered: false };
3429
3690
  } catch (error) {
3430
3691
  if (!(error instanceof ApiError) || REJECTED.has(error.code)) {
@@ -3432,31 +3693,60 @@ async function submitRun(client, request, options) {
3432
3693
  }
3433
3694
  failure = error;
3434
3695
  }
3435
- const existing = await lookupByKey(client, idempotencyKey, options.sleep);
3696
+ const existing = await lookupByKey(client, idempotencyKey, budget);
3436
3697
  if (existing)
3437
3698
  return { run: verifyRecovered(existing, request), recovered: true };
3438
3699
  if (resubmissions >= RESUBMIT_BACKOFF_MS.length) {
3439
3700
  throw withContext(failure, { idempotencyKey });
3440
3701
  }
3441
3702
  options.onRetry?.(resubmissions + 1, failure);
3442
- await options.sleep(RESUBMIT_BACKOFF_MS[resubmissions] ?? 4000);
3703
+ await budget.pause(RESUBMIT_BACKOFF_MS[resubmissions] ?? 4000);
3443
3704
  }
3444
3705
  }
3445
- async function lookupByKey(client, idempotencyKey, sleep) {
3706
+ function isNoRunForKey(error) {
3707
+ if (!(error instanceof ApiError) || error.code !== "NOT_FOUND" || error.routeMissing)
3708
+ return false;
3709
+ return error.key === undefined || error.key === "tool_run_not_found";
3710
+ }
3711
+ async function lookupByKey(client, idempotencyKey, budget) {
3446
3712
  for (let attempt = 0;; attempt++) {
3447
3713
  try {
3448
- return await getRunByIdempotencyKey(client, idempotencyKey);
3714
+ return await getRunByIdempotencyKey(client, idempotencyKey, {
3715
+ timeoutMs: budget.timeoutFor(LOOKUP_TIMEOUT_MS)
3716
+ });
3449
3717
  } catch (error) {
3450
- if (error instanceof ApiError && error.key === "tool_run_not_found")
3718
+ if (isNoRunForKey(error))
3451
3719
  return;
3720
+ budget.check();
3452
3721
  const transient = error instanceof ApiError && TRANSIENT.has(error.code);
3453
3722
  if (!transient || attempt >= LOOKUP_BACKOFF_MS.length) {
3454
3723
  throw withContext(error, { idempotencyKey }, { lead: "Could not confirm whether the run was accepted." });
3455
3724
  }
3456
- await sleep(LOOKUP_BACKOFF_MS[attempt] ?? 2000);
3725
+ await budget.pause(LOOKUP_BACKOFF_MS[attempt] ?? 2000);
3457
3726
  }
3458
3727
  }
3459
3728
  }
3729
+ function submitBudget(idempotencyKey, options) {
3730
+ const { deadline } = options;
3731
+ const left = () => deadline === undefined ? Infinity : deadline - options.now().getTime();
3732
+ const check = () => {
3733
+ if (left() <= 0)
3734
+ throw withContext(submitTimeout(), { idempotencyKey });
3735
+ };
3736
+ return {
3737
+ check,
3738
+ timeoutFor(cap, { wholeBudget = false } = {}) {
3739
+ if (deadline === undefined)
3740
+ return cap;
3741
+ check();
3742
+ return wholeBudget ? left() : Math.min(cap, left());
3743
+ },
3744
+ async pause(ms) {
3745
+ check();
3746
+ await options.sleep(Math.min(ms, left()));
3747
+ }
3748
+ };
3749
+ }
3460
3750
  function verifyRecovered(run, request) {
3461
3751
  const data = { idempotencyKey: request.idempotencyKey, runId: run.runId };
3462
3752
  const check = `Inspect it with: solgrid runs get -r ${run.runId}`;
@@ -3490,18 +3780,11 @@ function withContext(error, data, options = {}) {
3490
3780
  } else if (data.idempotencyKey && options.retryable !== false) {
3491
3781
  steps.push(`Retry safely with --idempotency-key ${data.idempotencyKey}, or check: solgrid runs get --idempotency-key ${data.idempotencyKey}`);
3492
3782
  }
3493
- const rebuilt = {
3783
+ return error.copyWith({
3494
3784
  hint: [error.hint, ...steps].filter(Boolean).join(`
3495
3785
  `),
3496
- details: error.details,
3497
- data: { ...error.data, ...data },
3498
- cause: error
3499
- };
3500
- return error instanceof ApiError ? new ApiError(error.code, error.message, {
3501
- ...rebuilt,
3502
- ...error.status !== undefined ? { status: error.status } : {},
3503
- ...error.key ? { key: error.key } : {}
3504
- }) : new CliError(error.code, error.message, rebuilt);
3786
+ data: { ...error.data, ...data }
3787
+ });
3505
3788
  }
3506
3789
  function sameInput(stored, submitted) {
3507
3790
  return canonical(normalize(stored)) === canonical(normalize(submitted));
@@ -3534,7 +3817,7 @@ var MAX_POLL_MS = 1e4;
3534
3817
  async function waitFor(fetchRun, options) {
3535
3818
  const deadline = options.now().getTime() + options.timeoutMs;
3536
3819
  const remaining = () => deadline - options.now().getTime();
3537
- const timedOut = () => waitTimeout(options.description, last, options.timeoutMs, options.checkCommand);
3820
+ const timedOut = () => waitTimeout(options.description, last, options.totalMs ?? options.timeoutMs, options.checkCommand);
3538
3821
  let delay = FIRST_POLL_MS;
3539
3822
  let last;
3540
3823
  for (;; ) {
@@ -3566,6 +3849,11 @@ function waitForRun(client, runId, options) {
3566
3849
  checkCommand: `solgrid runs get -r ${runId} --wait`
3567
3850
  });
3568
3851
  }
3852
+ function submitTimeout() {
3853
+ return new CliError("WAIT_TIMEOUT", "The wait ended before the run could be confirmed.", {
3854
+ hint: "It may still be running on Solgrid, or may not have started."
3855
+ });
3856
+ }
3569
3857
  function waitTimeout(description, last, timeoutMs, checkCommand) {
3570
3858
  const runId = last?.runId;
3571
3859
  const status = last?.status;
@@ -3721,6 +4009,8 @@ function registerRunCommand(program, ctx) {
3721
4009
  const idempotencyKey = opts.idempotencyKey ?? ctx.randomUUID();
3722
4010
  validateIdempotencyKey(idempotencyKey);
3723
4011
  const waitSeconds = opts.wait === true ? DEFAULT_WAIT_SECONDS : opts.wait;
4012
+ const budgetMs = waitSeconds !== undefined || opts.output !== undefined ? (waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000 : undefined;
4013
+ const deadline = budgetMs !== undefined ? ctx.now().getTime() + budgetMs : undefined;
3724
4014
  const client = apiClient(ctx);
3725
4015
  out.progress.start(`Submitting ${opts.provider} ${opts.endpoint}…`);
3726
4016
  const { run: submitted, recovered } = await submitRun(client, {
@@ -3729,15 +4019,17 @@ function registerRunCommand(program, ctx) {
3729
4019
  ...input ? { input } : {},
3730
4020
  idempotencyKey
3731
4021
  }, {
4022
+ now: () => ctx.now(),
3732
4023
  sleep: (ms) => ctx.sleep(ms),
3733
- onRetry: (attempt) => out.progress.update(`Submission interrupted, retrying (${attempt})…`)
4024
+ onRetry: (attempt) => out.progress.update(`Submission interrupted, retrying (${attempt})…`),
4025
+ ...deadline !== undefined ? { deadline } : {}
3734
4026
  });
3735
4027
  try {
3736
4028
  await finishRun(ctx, client, out, {
3737
4029
  submitted,
3738
4030
  recovered,
3739
4031
  idempotencyKey,
3740
- ...waitSeconds !== undefined ? { waitSeconds } : {},
4032
+ ...budgetMs !== undefined && deadline !== undefined ? { budgetMs, deadline } : {},
3741
4033
  ...opts.output !== undefined ? { outputPath: opts.output } : {}
3742
4034
  });
3743
4035
  } catch (error) {
@@ -3747,13 +4039,14 @@ function registerRunCommand(program, ctx) {
3747
4039
  }
3748
4040
  async function finishRun(ctx, client, out, params) {
3749
4041
  const { submitted, recovered, idempotencyKey, outputPath } = params;
3750
- const wait = params.waitSeconds !== undefined || outputPath !== undefined;
4042
+ const wait = params.deadline !== undefined;
3751
4043
  if (!wait && !isTerminal(submitted.status)) {
3752
4044
  out.result({ ...submitted, idempotencyKey, recovered }, () => renderSubmitted(submitted.runId, submitted.status, idempotencyKey, recovered, out));
3753
4045
  return;
3754
4046
  }
3755
4047
  const finished = isTerminal(submitted.status) ? submitted : await waitForRun(client, submitted.runId, {
3756
- timeoutMs: (params.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000,
4048
+ timeoutMs: params.deadline - ctx.now().getTime(),
4049
+ totalMs: params.budgetMs,
3757
4050
  now: () => ctx.now(),
3758
4051
  sleep: (ms) => ctx.sleep(ms),
3759
4052
  onPoll: (run) => out.progress.update(`Waiting for ${run.runId} (${run.status})…`)
@@ -3863,6 +4156,893 @@ function registerSessionIdCommand(program, ctx) {
3863
4156
  }));
3864
4157
  }
3865
4158
 
4159
+ // src/commands/skills.ts
4160
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
4161
+
4162
+ // src/core/skill-markdown.ts
4163
+ var MAX_SKILL_NAME_LENGTH = 64;
4164
+ var MAX_SKILL_DESCRIPTION_LENGTH = 1024;
4165
+ var SKILL_NAME = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/;
4166
+ function isSkillName(value) {
4167
+ return value.length <= MAX_SKILL_NAME_LENGTH && SKILL_NAME.test(value) && !value.endsWith("-");
4168
+ }
4169
+ function readFrontmatter(text) {
4170
+ const lines = text.replace(/^\uFEFF/, "").split(/\r\n|\r|\n/);
4171
+ if (lines[0] !== "---")
4172
+ return;
4173
+ const end = lines.indexOf("---", 1);
4174
+ if (end === -1)
4175
+ return;
4176
+ if (!lines.slice(end + 1).join(`
4177
+ `).trim())
4178
+ return;
4179
+ const fields = topLevelScalars(lines.slice(1, end));
4180
+ return { name: fields.get("name"), description: fields.get("description") };
4181
+ }
4182
+ function frontmatterProblem(frontmatter) {
4183
+ if (!frontmatter)
4184
+ return "missing";
4185
+ const { name, description } = frontmatter;
4186
+ if (name === undefined || name !== null && !isSkillName(name))
4187
+ return "name";
4188
+ if (description === undefined || description !== null && (!description.trim() || description.length > MAX_SKILL_DESCRIPTION_LENGTH)) {
4189
+ return "description";
4190
+ }
4191
+ return;
4192
+ }
4193
+ function topLevelScalars(lines) {
4194
+ const fields = new Map;
4195
+ for (let i = 0;i < lines.length; i++) {
4196
+ const match = /^(?:"([^"\\]*)"|'([^']*)'|([^\s#:'"][^:]*?))[ \t]*:(?:[ \t]+(.*))?$/.exec(lines[i]);
4197
+ if (!match)
4198
+ continue;
4199
+ const key = (match[1] ?? match[2] ?? match[3]).trim();
4200
+ const rest = [];
4201
+ while (i + 1 < lines.length && /^(?:[ \t]|$)/.test(lines[i + 1])) {
4202
+ rest.push(lines[++i]);
4203
+ }
4204
+ let value;
4205
+ try {
4206
+ value = scalar((match[4] ?? "").trim(), rest);
4207
+ } catch {
4208
+ value = null;
4209
+ }
4210
+ if (value !== undefined)
4211
+ fields.set(key, value);
4212
+ }
4213
+ return fields;
4214
+ }
4215
+ var NESTED = /^(?:-(?:[ \t]|$)|(?:"[^"\\]*"|'[^']*'|[^\s#'"][^:]*?)[ \t]*:(?:[ \t]|$))/;
4216
+ function scalar(head, rest) {
4217
+ if (!head) {
4218
+ const index = rest.findIndex((line) => line.trim() && !line.trim().startsWith("#"));
4219
+ if (index === -1)
4220
+ return "";
4221
+ const first = rest[index].trim();
4222
+ if (NESTED.test(first))
4223
+ return;
4224
+ return scalar(first, rest.slice(index + 1));
4225
+ }
4226
+ if (head.startsWith('"') || head.startsWith("'"))
4227
+ return quoted([head, ...rest]);
4228
+ const more = rest.map((line) => line.trim()).filter(Boolean);
4229
+ if (/^[|>]/.test(head)) {
4230
+ return more.join(head.startsWith("|") ? `
4231
+ ` : " ");
4232
+ }
4233
+ if (/^[[{]/.test(head))
4234
+ return;
4235
+ if (/^[&*!]/.test(head))
4236
+ return null;
4237
+ return [head, ...more].filter((line) => !line.startsWith("#")).join(" ").replace(/\s+#.*$/, "");
4238
+ }
4239
+ var ESCAPES = {
4240
+ "0": "\x00",
4241
+ a: "\x07",
4242
+ b: "\b",
4243
+ t: "\t",
4244
+ "\t": "\t",
4245
+ n: `
4246
+ `,
4247
+ v: "\v",
4248
+ f: "\f",
4249
+ r: "\r",
4250
+ e: "\x1B",
4251
+ " ": " ",
4252
+ '"': '"',
4253
+ "/": "/",
4254
+ "\\": "\\",
4255
+ N: "…",
4256
+ _: " ",
4257
+ L: "\u2028",
4258
+ P: "\u2029"
4259
+ };
4260
+ var HEX_ESCAPES = { x: 2, u: 4, U: 8 };
4261
+ function quoted(lines) {
4262
+ const text = lines.join(`
4263
+ `);
4264
+ const quote = text[0];
4265
+ let value = "";
4266
+ let i = 1;
4267
+ for (;; ) {
4268
+ if (i >= text.length)
4269
+ return null;
4270
+ const char = text[i];
4271
+ if (char === quote) {
4272
+ if (quote === "'" && text[i + 1] === "'") {
4273
+ value += "'";
4274
+ i += 2;
4275
+ continue;
4276
+ }
4277
+ break;
4278
+ }
4279
+ if (char === "\\" && quote === '"') {
4280
+ const next = text[i + 1] ?? "";
4281
+ if (next === `
4282
+ `) {
4283
+ i += 2;
4284
+ while (text[i] === " " || text[i] === "\t")
4285
+ i++;
4286
+ continue;
4287
+ }
4288
+ const digits = HEX_ESCAPES[next];
4289
+ if (digits !== undefined) {
4290
+ const hex = text.slice(i + 2, i + 2 + digits);
4291
+ if (!new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(hex))
4292
+ return null;
4293
+ const codePoint = Number.parseInt(hex, 16);
4294
+ if (codePoint > 1114111)
4295
+ return null;
4296
+ value += String.fromCodePoint(codePoint);
4297
+ i += 2 + digits;
4298
+ continue;
4299
+ }
4300
+ const escaped = ESCAPES[next];
4301
+ if (escaped === undefined)
4302
+ return null;
4303
+ value += escaped;
4304
+ i += 2;
4305
+ continue;
4306
+ }
4307
+ if (char === `
4308
+ `) {
4309
+ value = value.replace(/[ \t]+$/, "");
4310
+ let breaks = 0;
4311
+ while (text[i] === `
4312
+ `) {
4313
+ breaks++;
4314
+ i++;
4315
+ while (text[i] === " " || text[i] === "\t")
4316
+ i++;
4317
+ }
4318
+ value += breaks === 1 ? " " : `
4319
+ `.repeat(breaks - 1);
4320
+ continue;
4321
+ }
4322
+ value += char;
4323
+ i++;
4324
+ }
4325
+ return /^[ \t]*(?:#[^\n]*)?(?:\n[ \t]*(?:#[^\n]*)?)*$/.test(text.slice(i + 1)) ? value : null;
4326
+ }
4327
+
4328
+ // src/core/skill-package.ts
4329
+ import { lstatSync, mkdtempSync, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
4330
+ import { tmpdir } from "node:os";
4331
+ import { basename as basename2, join as join3, relative, sep } from "node:path";
4332
+ import { deflateRawSync } from "node:zlib";
4333
+ var MAX_SKILL_BYTES = 1e7;
4334
+ var MAX_SKILL_FILES = 200;
4335
+ var MAX_DEPTH = 16;
4336
+ var MAX_PATH_LENGTH = 1024;
4337
+ var SKIPPED = new Set([".git", ".DS_Store"]);
4338
+ function invalid(dir, reason, hint) {
4339
+ return new CliError("USAGE", `${dir} is not a valid Skill: ${reason}.`, hint ? { hint } : {});
4340
+ }
4341
+ function collect(dir) {
4342
+ const entries = [];
4343
+ const seen = new Map;
4344
+ let total = 0;
4345
+ const walk = (current, depth) => {
4346
+ for (const name of readdirSync(current).sort()) {
4347
+ if (SKIPPED.has(name))
4348
+ continue;
4349
+ const full = join3(current, name);
4350
+ const zipName = relative(dir, full).split(sep).join("/");
4351
+ if (/[:\\\u0000-\u001f\u007f]/.test(name)) {
4352
+ throw invalid(dir, `"${zipName}" has a ":", a backslash or a control character in its name`);
4353
+ }
4354
+ if (zipName.length > MAX_PATH_LENGTH || depth > MAX_DEPTH) {
4355
+ throw invalid(dir, `"${zipName}" is nested too deeply or its path is too long`);
4356
+ }
4357
+ const lower = zipName.toLowerCase();
4358
+ const clash = seen.get(lower);
4359
+ if (clash)
4360
+ throw invalid(dir, `"${clash}" and "${zipName}" differ only in case`);
4361
+ seen.set(lower, zipName);
4362
+ const stat = lstatSync(full);
4363
+ if (stat.isSymbolicLink()) {
4364
+ throw invalid(dir, `"${zipName}" is a symbolic link`, "Replace links with the files themselves.");
4365
+ }
4366
+ if (stat.isDirectory()) {
4367
+ walk(full, depth + 1);
4368
+ } else if (stat.isFile()) {
4369
+ if (entries.length >= MAX_SKILL_FILES) {
4370
+ throw invalid(dir, `it has more than ${MAX_SKILL_FILES} files`);
4371
+ }
4372
+ total += stat.size;
4373
+ if (total > MAX_SKILL_BYTES) {
4374
+ throw invalid(dir, `its files add up to more than ${MAX_SKILL_BYTES.toLocaleString("en-US")} bytes`);
4375
+ }
4376
+ entries.push({ name: zipName, data: readFileSync3(full) });
4377
+ } else {
4378
+ throw invalid(dir, `"${zipName}" is not a regular file or directory`);
4379
+ }
4380
+ }
4381
+ };
4382
+ walk(dir, 1);
4383
+ return entries;
4384
+ }
4385
+ var FRONTMATTER_EXAMPLE = `Start SKILL.md with:
4386
+ ---
4387
+ name: my-skill
4388
+ description: What the Skill does and when to use it.
4389
+ ---
4390
+ followed by the instructions.`;
4391
+ function checkSkillMarkdown(dir, entries) {
4392
+ const markdown = entries.find((entry) => entry.name === "SKILL.md");
4393
+ if (!markdown) {
4394
+ const wrongCase = entries.find((entry) => entry.name.toLowerCase() === "skill.md");
4395
+ throw invalid(dir, wrongCase ? `the file must be named SKILL.md, not ${wrongCase.name}` : "it has no SKILL.md at the top", "Put SKILL.md directly in the directory you pass to --dir.");
4396
+ }
4397
+ let text;
4398
+ try {
4399
+ text = new TextDecoder("utf-8", { fatal: true }).decode(markdown.data);
4400
+ } catch {
4401
+ throw invalid(dir, "SKILL.md is not valid UTF-8");
4402
+ }
4403
+ if (!text.trim())
4404
+ throw invalid(dir, "SKILL.md is empty");
4405
+ const frontmatter = readFrontmatter(text);
4406
+ switch (frontmatterProblem(frontmatter)) {
4407
+ case "missing":
4408
+ throw invalid(dir, "SKILL.md must start with frontmatter between --- lines, followed by the instructions", FRONTMATTER_EXAMPLE);
4409
+ case "name":
4410
+ throw invalid(dir, frontmatter?.name === undefined ? "SKILL.md's frontmatter has no name" : `the name "${frontmatter.name}" in SKILL.md is not valid`, `Use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--". It must equal the Skill's slug.`);
4411
+ case "description":
4412
+ throw invalid(dir, `SKILL.md's frontmatter needs a description of 1–${MAX_SKILL_DESCRIPTION_LENGTH.toLocaleString("en-US")} characters`, "Say what the Skill does and when to use it.");
4413
+ }
4414
+ return frontmatter?.name ?? undefined;
4415
+ }
4416
+ function packSkillDirectory(dir) {
4417
+ try {
4418
+ if (!lstatSync(dir).isDirectory())
4419
+ throw new Error("not a directory");
4420
+ } catch (cause) {
4421
+ throw new CliError("USAGE", `Cannot read the directory ${dir}.`, { cause });
4422
+ }
4423
+ const entries = collect(dir);
4424
+ const name = checkSkillMarkdown(dir, entries);
4425
+ const zip = buildZip(entries);
4426
+ if (zip.length > MAX_SKILL_BYTES) {
4427
+ throw invalid(dir, `the package would be ${zip.length.toLocaleString("en-US")} bytes, over the limit`);
4428
+ }
4429
+ const temp = mkdtempSync(join3(tmpdir(), "solgrid-skill-"));
4430
+ const path = join3(temp, `${basename2(dir) || "skill"}.zip`);
4431
+ writeFileSync3(path, zip);
4432
+ return {
4433
+ path,
4434
+ files: entries.length,
4435
+ bytes: zip.length,
4436
+ name,
4437
+ cleanup: () => rmSync(temp, { recursive: true, force: true })
4438
+ };
4439
+ }
4440
+ var CRC_TABLE = (() => {
4441
+ const table = new Uint32Array(256);
4442
+ for (let n = 0;n < 256; n++) {
4443
+ let c = n;
4444
+ for (let k = 0;k < 8; k++)
4445
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
4446
+ table[n] = c >>> 0;
4447
+ }
4448
+ return table;
4449
+ })();
4450
+ function crc32(data) {
4451
+ let crc = 4294967295;
4452
+ for (const byte of data)
4453
+ crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
4454
+ return (crc ^ 4294967295) >>> 0;
4455
+ }
4456
+ var DOS_TIME = 0;
4457
+ var DOS_DATE = 1 << 5 | 1;
4458
+ var UTF8_NAMES = 2048;
4459
+ function buildZip(entries) {
4460
+ const parts = [];
4461
+ const central = [];
4462
+ let offset = 0;
4463
+ for (const entry of entries) {
4464
+ const name = Buffer.from(entry.name, "utf8");
4465
+ const deflated = deflateRawSync(entry.data);
4466
+ const stored = deflated.length >= entry.data.length;
4467
+ const body = stored ? Buffer.from(entry.data) : deflated;
4468
+ const method = stored ? 0 : 8;
4469
+ const crc = crc32(entry.data);
4470
+ const local = Buffer.alloc(30);
4471
+ local.writeUInt32LE(67324752, 0);
4472
+ local.writeUInt16LE(20, 4);
4473
+ local.writeUInt16LE(UTF8_NAMES, 6);
4474
+ local.writeUInt16LE(method, 8);
4475
+ local.writeUInt16LE(DOS_TIME, 10);
4476
+ local.writeUInt16LE(DOS_DATE, 12);
4477
+ local.writeUInt32LE(crc, 14);
4478
+ local.writeUInt32LE(body.length, 18);
4479
+ local.writeUInt32LE(entry.data.length, 22);
4480
+ local.writeUInt16LE(name.length, 26);
4481
+ local.writeUInt16LE(0, 28);
4482
+ parts.push(local, name, body);
4483
+ const header = Buffer.alloc(46);
4484
+ header.writeUInt32LE(33639248, 0);
4485
+ header.writeUInt16LE(3 << 8 | 20, 4);
4486
+ header.writeUInt16LE(20, 6);
4487
+ header.writeUInt16LE(UTF8_NAMES, 8);
4488
+ header.writeUInt16LE(method, 10);
4489
+ header.writeUInt16LE(DOS_TIME, 12);
4490
+ header.writeUInt16LE(DOS_DATE, 14);
4491
+ header.writeUInt32LE(crc, 16);
4492
+ header.writeUInt32LE(body.length, 20);
4493
+ header.writeUInt32LE(entry.data.length, 24);
4494
+ header.writeUInt16LE(name.length, 28);
4495
+ header.writeUInt32LE(33188 << 16 >>> 0, 38);
4496
+ header.writeUInt32LE(offset, 42);
4497
+ central.push(header, name);
4498
+ offset += local.length + name.length + body.length;
4499
+ }
4500
+ const directory = Buffer.concat(central);
4501
+ const end = Buffer.alloc(22);
4502
+ end.writeUInt32LE(101010256, 0);
4503
+ end.writeUInt16LE(entries.length, 8);
4504
+ end.writeUInt16LE(entries.length, 10);
4505
+ end.writeUInt32LE(directory.length, 12);
4506
+ end.writeUInt32LE(offset, 16);
4507
+ return Buffer.concat([...parts, directory, end]);
4508
+ }
4509
+
4510
+ // src/core/skills.ts
4511
+ import { openAsBlob as openAsBlob2, statSync as statSync2 } from "node:fs";
4512
+ import { basename as basename3, extname as extname2 } from "node:path";
4513
+ var SKILL_KINDS = ["image", "video", "web", "audio"];
4514
+ var KIND_NAME = {
4515
+ image: "Image",
4516
+ video: "Video",
4517
+ web: "Web",
4518
+ audio: "Audio"
4519
+ };
4520
+ var KIND_BY_NUMBER2 = { 1: "image", 2: "video", 3: "web", 4: "audio" };
4521
+ function kindName(value) {
4522
+ if (typeof value === "number")
4523
+ return KIND_BY_NUMBER2[value];
4524
+ if (typeof value === "string") {
4525
+ const name = value.toLowerCase();
4526
+ return SKILL_KINDS.find((kind) => kind === name);
4527
+ }
4528
+ return;
4529
+ }
4530
+ var CARD_FIELDS = {
4531
+ id: isString,
4532
+ title: isText,
4533
+ tags: optional(listOf(isText)),
4534
+ author: optional(objectWith({ nickname: optional(isText) }))
4535
+ };
4536
+ async function listSkills(client, params) {
4537
+ const path = params.mine ? "/v1/creative-cases/list-mine" : "/v1/creative-cases/list";
4538
+ const data = await client.post(path, {
4539
+ ...params.page !== undefined ? { pageIndex: params.page } : {},
4540
+ ...params.pageSize !== undefined ? { pageSize: params.pageSize } : {},
4541
+ ...params.kind ? { kind: KIND_NAME[params.kind] } : {},
4542
+ ...params.keyword ? { keyword: params.keyword } : {},
4543
+ ...params.slug ? { slug: params.slug } : {}
4544
+ });
4545
+ return expectShape(path, data, {
4546
+ items: listOf(objectWith(CARD_FIELDS)),
4547
+ total: isNumber,
4548
+ pageIndex: isNumber,
4549
+ pageSize: isNumber
4550
+ });
4551
+ }
4552
+ async function getSkill(client, target) {
4553
+ const path = "/v1/creative-cases/get";
4554
+ const data = await client.post(path, target);
4555
+ return expectShape(path, data, {
4556
+ ...CARD_FIELDS,
4557
+ slug: isString,
4558
+ description: isText,
4559
+ skillFileAssetId: isString
4560
+ });
4561
+ }
4562
+ function contentBody(content) {
4563
+ return {
4564
+ title: content.title,
4565
+ kind: KIND_NAME[content.kind],
4566
+ description: content.description,
4567
+ tags: content.tags,
4568
+ coverFileAssetId: content.coverFileAssetId ?? null,
4569
+ previewFileAssetId: content.previewFileAssetId ?? null,
4570
+ skillFileAssetId: content.skillFileAssetId
4571
+ };
4572
+ }
4573
+ var MUTATION_FIELDS = { id: isString };
4574
+ async function createSkill(client, slug, content) {
4575
+ const path = "/v1/creative-cases/create";
4576
+ const data = await client.post(path, { ...contentBody(content), slug });
4577
+ return expectShape(path, data, MUTATION_FIELDS);
4578
+ }
4579
+ async function updateSkill(client, id, content) {
4580
+ const path = "/v1/creative-cases/update";
4581
+ const data = await client.post(path, { id, ...contentBody(content) });
4582
+ return expectShape(path, data, MUTATION_FIELDS);
4583
+ }
4584
+ async function deleteSkill(client, id) {
4585
+ await client.post("/v1/creative-cases/delete", { id });
4586
+ }
4587
+ function contentOf(skill) {
4588
+ const kind = kindName(skill.kind);
4589
+ if (!kind) {
4590
+ throw new CliError("INVALID_RESPONSE", `Unexpected kind ${JSON.stringify(skill.kind)} for this Skill.`);
4591
+ }
4592
+ return {
4593
+ title: skill.title,
4594
+ kind,
4595
+ description: skill.description,
4596
+ tags: skill.tags ?? [],
4597
+ coverFileAssetId: skill.cover?.fileAssetId ?? null,
4598
+ previewFileAssetId: skill.previewFileAssetId ?? null,
4599
+ skillFileAssetId: skill.skillFileAssetId
4600
+ };
4601
+ }
4602
+ var MAX_TAGS = 3;
4603
+ async function listSkillTags(client) {
4604
+ const path = "/v1/creative-cases/list-tags";
4605
+ const data = await client.post(path);
4606
+ const checked = expectShape(path, { tags: data }, {
4607
+ tags: listOf(objectWith({ id: isString, name: isText }))
4608
+ });
4609
+ return checked.tags;
4610
+ }
4611
+ function checkTags(tags, available) {
4612
+ if (tags.length > MAX_TAGS) {
4613
+ throw new CliError("USAGE", `A Skill takes at most ${MAX_TAGS} tags; ${tags.length} were given.`);
4614
+ }
4615
+ const known = new Set(available.map((tag) => tag.id));
4616
+ const unknown = tags.filter((tag) => !known.has(tag));
4617
+ if (unknown.length > 0) {
4618
+ throw new CliError("USAGE", `Unknown tag${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, {
4619
+ hint: `Pick from: ${available.map((tag) => tag.id).join(", ")} (see \`solgrid skills tags\`).`
4620
+ });
4621
+ }
4622
+ }
4623
+ var PUBLIC_DISPLAY = "1";
4624
+ function prepareSkillFile(path, role) {
4625
+ let size;
4626
+ try {
4627
+ const stat = statSync2(path);
4628
+ if (!stat.isFile())
4629
+ throw new Error("not a file");
4630
+ size = stat.size;
4631
+ } catch (cause) {
4632
+ throw new CliError("USAGE", `Cannot read ${path}.`, { cause });
4633
+ }
4634
+ if (size === 0)
4635
+ throw new CliError("USAGE", `${path} is empty.`);
4636
+ const kind = guessKind(path);
4637
+ const allowed = {
4638
+ cover: ["image"],
4639
+ preview: ["image", "video", "audio"],
4640
+ skill: ["skill"]
4641
+ };
4642
+ if (!kind || !allowed[role].includes(kind)) {
4643
+ const expected = role === "skill" ? "a .zip file" : `an ${allowed[role].join(", ")} file`;
4644
+ throw new CliError("USAGE", `The ${role === "skill" ? "Skill package" : role} must be ${expected}: ${basename3(path)}.`);
4645
+ }
4646
+ if (size > SIZE_LIMIT[kind]) {
4647
+ throw new CliError("USAGE", `${basename3(path)} is ${formatBytes(size)}, over the ${formatBytes(SIZE_LIMIT[kind])} limit for ${kind} files.`);
4648
+ }
4649
+ return { kind, size };
4650
+ }
4651
+ var CONTENT_TYPE = {
4652
+ image: {
4653
+ ".png": "image/png",
4654
+ ".jpg": "image/jpeg",
4655
+ ".jpeg": "image/jpeg",
4656
+ ".webp": "image/webp",
4657
+ ".gif": "image/gif"
4658
+ },
4659
+ video: { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" },
4660
+ audio: { ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg" },
4661
+ skill: { ".zip": "application/zip" }
4662
+ };
4663
+ async function uploadSkillFile(client, path, role) {
4664
+ const { kind } = prepareSkillFile(path, role);
4665
+ const form = new FormData;
4666
+ form.append("fileType", String(FILE_TYPE_CODE[kind]));
4667
+ form.append("purpose", PUBLIC_DISPLAY);
4668
+ const type = CONTENT_TYPE[kind][extname2(path).toLowerCase()] ?? "application/octet-stream";
4669
+ form.append("file", await openAsBlob2(path, { type }), basename3(path));
4670
+ const data = await client.upload("/v1/files/upload", form);
4671
+ return expectShape("/v1/files/upload", data, { fileAssetId: isString });
4672
+ }
4673
+ function openSkillPackage(client, slug) {
4674
+ return client.openDownload(`/v1/skills/${encodeURIComponent(slug)}/content`);
4675
+ }
4676
+
4677
+ // src/commands/skills.ts
4678
+ var MAX_INLINE_DESCRIPTION_CHARS = 1500;
4679
+ function parseKind2(value) {
4680
+ const kind = kindName(value);
4681
+ if (!kind)
4682
+ throw new InvalidArgumentError(`must be one of ${SKILL_KINDS.join(", ")}.`);
4683
+ return kind;
4684
+ }
4685
+ function parseSlug(value) {
4686
+ if (!isSkillName(value)) {
4687
+ throw new InvalidArgumentError('use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".');
4688
+ }
4689
+ return value;
4690
+ }
4691
+ function collect2(value, previous = []) {
4692
+ return [...previous, value];
4693
+ }
4694
+ function addContentOptions(command, required) {
4695
+ const note = required ? "" : " (replaces the current one)";
4696
+ command.option("-t, --title <title>", `title${note}`).option("--kind <kind>", `what the Skill makes: ${SKILL_KINDS.join(", ")}${note}`, parseKind2).option("-d, --description <text>", `a short description shown under the title${note}`).option("--description-file <path>", "read the description from a file instead").option("--tag <id>", `a tag ID from \`solgrid skills tags\`; up to ${MAX_TAGS}, repeat for several${required ? "" : " (replaces all current tags)"}`, collect2).option("--cover <path>", `cover image; required unless the kind is video${note}`).option("--preview <path>", `preview image, video or audio; required for video and audio${note}`).option("--dir <path>", `a Skill directory with SKILL.md at its top, packed for you${note}`).option("--zip <path>", `or an already packed Skill (.zip with SKILL.md)${note}`);
4697
+ return command;
4698
+ }
4699
+ function readDescription(opts) {
4700
+ if (opts.description !== undefined && opts.descriptionFile !== undefined) {
4701
+ throw new CliError("USAGE", "Use either --description or --description-file, not both.");
4702
+ }
4703
+ if (opts.descriptionFile === undefined)
4704
+ return opts.description;
4705
+ try {
4706
+ return readFileSync4(opts.descriptionFile, "utf8");
4707
+ } catch (cause) {
4708
+ throw new CliError("USAGE", `Cannot read ${opts.descriptionFile}.`, { cause });
4709
+ }
4710
+ }
4711
+ function aSkill(kind) {
4712
+ return `${kind === "image" || kind === "audio" ? "An" : "A"} ${kind} Skill`;
4713
+ }
4714
+ function checkMedia(kind, hasCover, hasPreview) {
4715
+ if (kind !== "video" && !hasCover) {
4716
+ throw new CliError("USAGE", `${aSkill(kind)} needs a cover image.`, {
4717
+ hint: "Pass --cover <image>."
4718
+ });
4719
+ }
4720
+ if ((kind === "video" || kind === "audio") && !hasPreview) {
4721
+ throw new CliError("USAGE", `${aSkill(kind)} needs a preview.`, {
4722
+ hint: `Pass --preview <${kind} or image file>.`
4723
+ });
4724
+ }
4725
+ }
4726
+ function pickTarget(opts) {
4727
+ if (opts.slug === undefined === (opts.id === undefined)) {
4728
+ throw new CliError("USAGE", "Pass exactly one of --slug or --id.");
4729
+ }
4730
+ return opts.slug !== undefined ? { slug: opts.slug } : { id: opts.id };
4731
+ }
4732
+ function targetName(target) {
4733
+ return "slug" in target ? target.slug : target.id;
4734
+ }
4735
+ function isApiNotFound(error) {
4736
+ return error instanceof ApiError && error.code === "NOT_FOUND" && !error.routeMissing;
4737
+ }
4738
+ async function findSkill(client, target) {
4739
+ try {
4740
+ return await getSkill(client, target);
4741
+ } catch (error) {
4742
+ if (!isApiNotFound(error))
4743
+ throw error;
4744
+ throw new CliError("NOT_FOUND", `No Skill with ${"slug" in target ? "slug" : "ID"} "${targetName(target)}".`, {
4745
+ hint: "Search with `solgrid skills list -q <words>`, or list yours with `solgrid skills list --mine`.",
4746
+ cause: error
4747
+ });
4748
+ }
4749
+ }
4750
+ function notYours(error, slug) {
4751
+ if (!isApiNotFound(error))
4752
+ return error;
4753
+ return new CliError("NOT_FOUND", `Skill ${slug} is not one of yours.`, {
4754
+ hint: "Only your own Skills can be changed. List them with `solgrid skills list --mine`.",
4755
+ cause: error
4756
+ });
4757
+ }
4758
+ function skillPackage(opts) {
4759
+ if (opts.dir !== undefined && opts.zip !== undefined) {
4760
+ throw new CliError("USAGE", "Use either --dir or --zip, not both.");
4761
+ }
4762
+ if (opts.dir !== undefined)
4763
+ return packSkillDirectory(opts.dir);
4764
+ if (opts.zip !== undefined) {
4765
+ prepareSkillFile(opts.zip, "skill");
4766
+ return { path: opts.zip, cleanup: () => {} };
4767
+ }
4768
+ return;
4769
+ }
4770
+ function slugFor(pkg, opts) {
4771
+ const { slug } = opts;
4772
+ if (pkg.name === undefined) {
4773
+ if (slug === undefined) {
4774
+ throw new CliError("USAGE", opts.zip !== undefined ? "Pass --slug with --zip." : "Could not read the name in SKILL.md; pass --slug.", {
4775
+ hint: opts.zip !== undefined ? "It must equal the name in the package's SKILL.md. With --dir the CLI reads it for you." : "It must equal the name in SKILL.md's frontmatter."
4776
+ });
4777
+ }
4778
+ return slug;
4779
+ }
4780
+ if (slug !== undefined && slug !== pkg.name)
4781
+ throw nameMismatch(pkg.name, slug);
4782
+ return pkg.name;
4783
+ }
4784
+ function nameMismatch(name, slug) {
4785
+ return new CliError("USAGE", `The name in SKILL.md is "${name}", but the Skill's slug is "${slug}". They must be the same.`, { hint: `Set \`name: ${slug}\` in SKILL.md's frontmatter.` });
4786
+ }
4787
+ async function checkTagOptions(client, out, tags) {
4788
+ if (tags === undefined)
4789
+ return;
4790
+ if (tags.length > MAX_TAGS)
4791
+ checkTags(tags, []);
4792
+ out.progress.update("Checking tags…");
4793
+ checkTags(tags, await listSkillTags(client));
4794
+ }
4795
+ async function confirmOrRefuse(ctx, out, opts, question, refusal) {
4796
+ if (opts.yes)
4797
+ return true;
4798
+ if (opts.json || !ctx.stdinIsTTY) {
4799
+ throw new CliError("USAGE", refusal, { hint: "Pass --yes to confirm in non-interactive use." });
4800
+ }
4801
+ out.progress.stop();
4802
+ return ctx.confirm(question);
4803
+ }
4804
+ async function uploadIfGiven(client, out, path, role) {
4805
+ if (path === undefined)
4806
+ return;
4807
+ out.progress.update(`Uploading ${path}…`);
4808
+ return (await uploadSkillFile(client, path, role)).fileAssetId;
4809
+ }
4810
+ function registerSkillsCommand(program, ctx) {
4811
+ const skills = program.command("skills").description("Find, publish and manage Skills on SkillHub.").addHelpText("after", `
4812
+ A Skill is public as soon as it is created. Its slug identifies it everywhere and cannot
4813
+ be changed later.`);
4814
+ skills.command("list").description("Search Skills, newest first.").option("-q, --query <words>", "match titles and tags").option("--kind <kind>", `only this kind: ${SKILL_KINDS.join(", ")}`, parseKind2).option("--mine", "only your own Skills").option("-l, --limit <n>", "Skills per page (default 20, up to 100)", parsePositiveInt).option("--page <n>", "page number (default 1)", parsePositiveInt).option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4815
+ const client = apiClient(ctx);
4816
+ out.progress.start("Searching Skills…");
4817
+ const data = await listSkills(client, {
4818
+ ...opts.mine ? { mine: true } : {},
4819
+ ...opts.query ? { keyword: opts.query } : {},
4820
+ ...opts.kind ? { kind: opts.kind } : {},
4821
+ ...opts.limit !== undefined ? { pageSize: opts.limit } : {},
4822
+ ...opts.page !== undefined ? { page: opts.page } : {}
4823
+ });
4824
+ out.result(data, () => {
4825
+ if (data.items.length === 0) {
4826
+ return opts.query || opts.kind ? "No Skills match. Try other words, or drop --kind." : opts.mine ? "You have not published any Skills yet. Publish one with `solgrid skills create`." : "No Skills yet.";
4827
+ }
4828
+ const lines = [
4829
+ renderTable(["ID", "TITLE", "KIND", "AUTHOR", "UPDATED"], data.items.map((skill) => [
4830
+ skill.id,
4831
+ truncate(skill.title, 40),
4832
+ kindName(skill.kind) ?? "-",
4833
+ skill.author?.nickname || "-",
4834
+ formatTime(skill.updatedAt ?? skill.createdAt)
4835
+ ]), { formatHeader: (text) => out.style("dim", text) })
4836
+ ];
4837
+ const shown = (data.pageIndex - 1) * data.pageSize + data.items.length;
4838
+ if (shown < data.total) {
4839
+ lines.push("", out.style("dim", `${shown} of ${data.total}. Next page: add --page ${data.pageIndex + 1}`));
4840
+ }
4841
+ lines.push("", out.style("dim", "Details: solgrid skills get -i <id>"));
4842
+ return lines.join(`
4843
+ `);
4844
+ });
4845
+ }));
4846
+ skills.command("get").description("Show a Skill's details.").option("-s, --slug <slug>", "the Skill's slug").option("-i, --id <id>", "the Skill's ID").option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4847
+ const target = pickTarget(opts);
4848
+ const client = apiClient(ctx);
4849
+ out.progress.start("Fetching Skill…");
4850
+ const skill = await findSkill(client, target);
4851
+ out.result(skill, () => renderSkill(skill, out.style));
4852
+ }));
4853
+ skills.command("tags").description(`List the tags a Skill can carry (up to ${MAX_TAGS} each).`).option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4854
+ const client = apiClient(ctx);
4855
+ out.progress.start("Fetching tags…");
4856
+ const tags = await listSkillTags(client);
4857
+ out.result({ tags }, () => [
4858
+ renderTable(["ID", "NAME"], tags.map((tag) => [tag.id, tag.name]), { formatHeader: (text) => out.style("dim", text) }),
4859
+ "",
4860
+ out.style("dim", "Use the ID with --tag, e.g. --tag cat_anime.")
4861
+ ].join(`
4862
+ `));
4863
+ }));
4864
+ addContentOptions(skills.command("create").description("Publish a new Skill. It is public as soon as it is created.").option("-s, --slug <slug>", "the Skill's unique ID; must equal the name in SKILL.md (default: that name)", parseSlug), true).option("-y, --yes", "publish without asking").option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4865
+ const description = readDescription(opts);
4866
+ const missing = [
4867
+ ["--title", opts.title],
4868
+ ["--kind", opts.kind],
4869
+ ["--description or --description-file", description],
4870
+ ["--dir or --zip", opts.dir ?? opts.zip]
4871
+ ].filter(([, value]) => !value).map(([name]) => name);
4872
+ if (missing.length > 0)
4873
+ throw new CliError("USAGE", `Missing ${missing.join(", ")}.`);
4874
+ const kind = opts.kind;
4875
+ checkMedia(kind, opts.cover !== undefined, opts.preview !== undefined);
4876
+ if (opts.cover)
4877
+ prepareSkillFile(opts.cover, "cover");
4878
+ if (opts.preview)
4879
+ prepareSkillFile(opts.preview, "preview");
4880
+ const pkg = skillPackage(opts);
4881
+ try {
4882
+ const slug = slugFor(pkg, opts);
4883
+ const client = apiClient(ctx);
4884
+ out.progress.start("Checking…");
4885
+ await checkTagOptions(client, out, opts.tag);
4886
+ const confirmed = await confirmOrRefuse(ctx, out, opts, `Publish "${opts.title}" (${slug}) publicly on SkillHub?`, "Refusing to publish a Skill without confirmation.");
4887
+ if (!confirmed) {
4888
+ out.result({ slug, created: false }, () => "Cancelled.");
4889
+ return;
4890
+ }
4891
+ out.progress.start("Uploading…");
4892
+ const content = {
4893
+ title: opts.title,
4894
+ kind,
4895
+ description,
4896
+ tags: opts.tag ?? [],
4897
+ skillFileAssetId: await uploadIfGiven(client, out, pkg.path, "skill"),
4898
+ coverFileAssetId: await uploadIfGiven(client, out, opts.cover, "cover") ?? null,
4899
+ previewFileAssetId: await uploadIfGiven(client, out, opts.preview, "preview") ?? null
4900
+ };
4901
+ out.progress.update("Publishing…");
4902
+ const { id } = await createSkill(client, slug, content).catch((error) => {
4903
+ if (error instanceof ApiError && error.code === "CONFLICT") {
4904
+ throw new CliError("CONFLICT", `The slug "${slug}" is already taken.`, {
4905
+ hint: "Choose another name in SKILL.md (and --slug).",
4906
+ cause: error
4907
+ });
4908
+ }
4909
+ throw error;
4910
+ });
4911
+ out.result({ id, slug, created: true }, () => [
4912
+ `Published "${content.title}" as ${slug}.`,
4913
+ ...renderFields([["ID", id]], out.style),
4914
+ "",
4915
+ out.style("dim", `See it with: solgrid skills get -s ${slug}`)
4916
+ ].join(`
4917
+ `));
4918
+ } finally {
4919
+ pkg.cleanup();
4920
+ }
4921
+ }));
4922
+ addContentOptions(skills.command("update").description("Change one of your Skills. Only the options you pass are changed; the slug never is.").option("-s, --slug <slug>", "the Skill's slug").option("-i, --id <id>", "the Skill's ID"), false).option("--clear-tags", "remove all tags").option("--clear-preview", "remove the preview (image and web Skills only)").option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4923
+ const target = pickTarget(opts);
4924
+ const description = readDescription(opts);
4925
+ if (opts.clearTags && opts.tag !== undefined) {
4926
+ throw new CliError("USAGE", "Use either --tag or --clear-tags, not both.");
4927
+ }
4928
+ const tags = opts.clearTags ? [] : opts.tag;
4929
+ if (opts.clearPreview && opts.preview !== undefined) {
4930
+ throw new CliError("USAGE", "Use either --preview or --clear-preview, not both.");
4931
+ }
4932
+ const changes = [
4933
+ opts.title,
4934
+ opts.kind,
4935
+ description,
4936
+ tags,
4937
+ opts.cover,
4938
+ opts.preview,
4939
+ opts.clearPreview,
4940
+ opts.dir,
4941
+ opts.zip
4942
+ ];
4943
+ if (changes.every((value) => value === undefined)) {
4944
+ throw new CliError("USAGE", "Nothing to change.", {
4945
+ hint: "Pass at least one of --title, --kind, --description, --tag, --clear-tags, --cover, --preview, --clear-preview, --dir or --zip."
4946
+ });
4947
+ }
4948
+ if (opts.cover)
4949
+ prepareSkillFile(opts.cover, "cover");
4950
+ if (opts.preview)
4951
+ prepareSkillFile(opts.preview, "preview");
4952
+ const pkg = skillPackage(opts);
4953
+ try {
4954
+ const client = apiClient(ctx);
4955
+ out.progress.start("Fetching Skill…");
4956
+ const skill = await findSkill(client, target);
4957
+ if (pkg?.name !== undefined && pkg.name !== skill.slug) {
4958
+ throw nameMismatch(pkg.name, skill.slug);
4959
+ }
4960
+ const next = {
4961
+ ...contentOf(skill),
4962
+ ...opts.title !== undefined ? { title: opts.title } : {},
4963
+ ...opts.kind !== undefined ? { kind: opts.kind } : {},
4964
+ ...description !== undefined ? { description } : {},
4965
+ ...tags !== undefined ? { tags } : {},
4966
+ ...opts.clearPreview ? { previewFileAssetId: null } : {}
4967
+ };
4968
+ if (opts.clearPreview && (next.kind === "video" || next.kind === "audio")) {
4969
+ throw new CliError("USAGE", `${aSkill(next.kind)} needs a preview; it cannot be removed.`, {
4970
+ hint: "Replace it with --preview <file> instead."
4971
+ });
4972
+ }
4973
+ checkMedia(next.kind, Boolean(opts.cover ?? next.coverFileAssetId), Boolean(opts.preview ?? next.previewFileAssetId));
4974
+ await checkTagOptions(client, out, opts.tag);
4975
+ next.skillFileAssetId = await uploadIfGiven(client, out, pkg?.path, "skill") ?? next.skillFileAssetId;
4976
+ next.coverFileAssetId = await uploadIfGiven(client, out, opts.cover, "cover") ?? next.coverFileAssetId;
4977
+ next.previewFileAssetId = await uploadIfGiven(client, out, opts.preview, "preview") ?? next.previewFileAssetId;
4978
+ out.progress.update("Saving…");
4979
+ await updateSkill(client, skill.id, next).catch((error) => {
4980
+ throw notYours(error, skill.slug);
4981
+ });
4982
+ out.result({ id: skill.id, slug: skill.slug, updated: true }, () => `Updated Skill ${skill.slug}.`);
4983
+ } finally {
4984
+ pkg?.cleanup();
4985
+ }
4986
+ }));
4987
+ skills.command("delete").description("Delete one of your Skills. It disappears from SkillHub.").option("-s, --slug <slug>", "the Skill's slug").option("-i, --id <id>", "the Skill's ID").option("-y, --yes", "delete without asking").option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
4988
+ const target = pickTarget(opts);
4989
+ const client = apiClient(ctx);
4990
+ out.progress.start("Finding Skill…");
4991
+ const { id, slug } = await findSkill(client, target);
4992
+ const confirmed = await confirmOrRefuse(ctx, out, opts, `Delete Skill ${slug}? It will no longer be listed on SkillHub.`, "Refusing to delete a Skill without confirmation.");
4993
+ if (!confirmed) {
4994
+ out.result({ id, slug, deleted: false }, () => "Cancelled.");
4995
+ return;
4996
+ }
4997
+ out.progress.start("Deleting…");
4998
+ await deleteSkill(client, id).catch((error) => {
4999
+ throw notYours(error, slug);
5000
+ });
5001
+ out.result({ id, slug, deleted: true }, () => `Deleted Skill ${slug}.`);
5002
+ }));
5003
+ skills.command("download").description("Download a Skill's package (.zip).").requiredOption("-s, --slug <slug>", "the Skill's slug").option("-o, --output <path>", "where to save it (default: <slug>.zip in the current directory)").option("--force", "overwrite the output file if it exists").option(...JSON_FLAG).action((opts) => runAction(ctx, opts.json, async (out) => {
5004
+ const client = apiClient(ctx);
5005
+ out.progress.start("Downloading…");
5006
+ const response = await openSkillPackage(client, opts.slug);
5007
+ const destination = opts.output ?? filenameFromDisposition(response.headers.get("content-disposition")) ?? `${opts.slug}.zip`;
5008
+ if (existsSync3(destination) && !opts.force) {
5009
+ await response.body?.cancel();
5010
+ throw new CliError("USAGE", `${destination} already exists.`, {
5011
+ hint: "Pass --force to overwrite it, or choose another path with -o."
5012
+ });
5013
+ }
5014
+ if (!response.body)
5015
+ throw new CliError("INVALID_RESPONSE", "The download has no content.");
5016
+ const bytes = await saveDownload(response.body, destination, {
5017
+ overwrite: opts.force === true
5018
+ });
5019
+ out.result({ slug: opts.slug, path: destination, sizeBytes: bytes }, () => `Saved ${destination} (${formatBytes(bytes)}).`);
5020
+ }));
5021
+ }
5022
+ function renderSkill(skill, style) {
5023
+ const lines = [
5024
+ style("bold", skill.title),
5025
+ "",
5026
+ ...renderFields([
5027
+ ["Slug", skill.slug],
5028
+ ["ID", skill.id],
5029
+ ["Kind", kindName(skill.kind)],
5030
+ ["Author", skill.author?.nickname || undefined],
5031
+ ["Tags", skill.tags?.length ? skill.tags.join(", ") : undefined],
5032
+ ["Created", skill.createdAt ? formatTime(skill.createdAt) : undefined],
5033
+ ["Updated", skill.updatedAt ? formatTime(skill.updatedAt) : undefined]
5034
+ ], style)
5035
+ ];
5036
+ if (skill.description) {
5037
+ const text = skill.description.trim();
5038
+ lines.push("", text.length > MAX_INLINE_DESCRIPTION_CHARS ? `${text.slice(0, MAX_INLINE_DESCRIPTION_CHARS)}
5039
+ ${style("dim", `… ${text.length - MAX_INLINE_DESCRIPTION_CHARS} more characters; use --json for the full description.`)}` : text);
5040
+ }
5041
+ lines.push("", style("dim", `Download it with: solgrid skills download -s ${skill.slug}`));
5042
+ return lines.join(`
5043
+ `);
5044
+ }
5045
+
3866
5046
  // src/output/price.ts
3867
5047
  function periodText(period) {
3868
5048
  const unit = period.unit.toLowerCase();
@@ -3896,28 +5076,100 @@ function withUnit(amount, unit) {
3896
5076
  return unit ? `${amount} ${unit}` : amount;
3897
5077
  }
3898
5078
  function describeLeaf(leaf) {
3899
- const text = withUnit(formatAmount(leaf.amount), unitText(leaf));
3900
- return leaf.flatFee ? `${text} + ${formatAmount(leaf.flatFee)} flat` : text;
3901
- }
3902
- function summarizePrice(price) {
5079
+ const text = withUnit(formatUnitPrice(leaf.amount), unitText(leaf));
5080
+ return leaf.flatFee ? `${text} + ${formatUnitPrice(leaf.flatFee)} flat` : text;
5081
+ }
5082
+ var METER_UNITS = {
5083
+ Calls: ["call", "calls"],
5084
+ WebSearchCalls: ["web search", "web searches"],
5085
+ InputImages: ["input image", "input images"],
5086
+ OutputImages: ["output image", "output images"],
5087
+ InputVideoSeconds: ["second of input video", "seconds of input video"],
5088
+ OutputVideoSeconds: ["second of output video", "seconds of output video"],
5089
+ TotalVideoSeconds: ["second of video", "seconds of video"],
5090
+ BilledTokens: ["token", "tokens"],
5091
+ Token: ["token", "tokens"],
5092
+ BilledCharacters: ["character", "characters"],
5093
+ Characters: ["character", "characters"],
5094
+ Credits: ["credit", "credits"],
5095
+ ProviderUnits: ["Unit", "Units"],
5096
+ PROVIDER_UNIT: ["Unit", "Units"],
5097
+ Unit: ["Unit", "Units"]
5098
+ };
5099
+ function meterText(meter, size = 1) {
5100
+ const [one, many] = METER_UNITS[meter] ?? [meter, meter];
5101
+ return size === 1 ? `per ${one}` : `per ${countText(size)} ${many}`;
5102
+ }
5103
+ function rateAmount(rate) {
5104
+ return formatUnitPrice({ value: rate.unitPriceUsd, currency: "USD" });
5105
+ }
5106
+ function describeRate(rate) {
5107
+ return `${rateAmount(rate)} ${meterText(rate.meter, rate.unitSize)}`;
5108
+ }
5109
+ function ratesByMeter(rates) {
5110
+ const groups = new Map;
5111
+ for (const rate of rates)
5112
+ groups.set(rate.meter, [...groups.get(rate.meter) ?? [], rate]);
5113
+ return [...groups.values()];
5114
+ }
5115
+ function perUnit(rate) {
5116
+ return rate.unitPriceUsd / (rate.unitSize || 1);
5117
+ }
5118
+ function hasConditions(rate) {
5119
+ return Boolean(rate.conditions && Object.keys(rate.conditions).length > 0);
5120
+ }
5121
+ function summarizeRates(price, rates) {
5122
+ const only = rates.length === 1 ? rates[0] : undefined;
5123
+ if (only) {
5124
+ const amount = price.amount ? formatUnitPrice(price.amount) : rateAmount(only);
5125
+ const text = `${amount} ${meterText(only.meter, only.unitSize)}`;
5126
+ return hasConditions(only) ? `${text} (${conditionsText(only)})` : text;
5127
+ }
5128
+ return ratesByMeter(rates).map((group) => {
5129
+ const cheapest = group.reduce((a, b) => perUnit(b) < perUnit(a) ? b : a);
5130
+ const varies = new Set(group.map(describeRate)).size > 1;
5131
+ return `${varies ? "from " : ""}${describeRate(cheapest)}`;
5132
+ }).join(" + ");
5133
+ }
5134
+ function rateNotes(rate) {
5135
+ const notes = [];
5136
+ if (rate.freeUnits)
5137
+ notes.push(`first ${rate.freeUnits} free`);
5138
+ if (typeof rate.maximumUnits === "number")
5139
+ notes.push(`billed up to ${rate.maximumUnits}`);
5140
+ if (rate.roundUpTo)
5141
+ notes.push(`rounded up to ${rate.roundUpTo}`);
5142
+ return notes.join("; ");
5143
+ }
5144
+ function conditionsText(rate) {
5145
+ const conditions = rate.conditions && typeof rate.conditions === "object" ? rate.conditions : {};
5146
+ return whenText(conditions) || "-";
5147
+ }
5148
+ function summarizePrice(price, fallback) {
5149
+ if (!price)
5150
+ return fallback || "-";
5151
+ if (price.rates?.length)
5152
+ return summarizeRates(price, price.rates);
5153
+ if (!price.amount)
5154
+ return fallback || "-";
3903
5155
  let text;
3904
5156
  switch (price.type) {
3905
5157
  case "PER_UNIT_MATRIX": {
3906
5158
  const first = price.variants?.[0]?.price;
3907
- text = `from ${withUnit(formatAmount(price.amount), first ? unitText(first) : "")}`;
5159
+ text = `from ${withUnit(formatUnitPrice(price.amount), first ? unitText(first) : "")}`;
3908
5160
  break;
3909
5161
  }
3910
5162
  case "TIERED":
3911
5163
  text = price.default ? describeLeaf(price.default) : "varies";
3912
5164
  break;
3913
5165
  case "BY_PERIOD":
3914
- text = withUnit(formatAmount(price.amount), price.period ? periodText(price.period) : "");
5166
+ text = withUnit(formatUnitPrice(price.amount), price.period ? periodText(price.period) : "");
3915
5167
  break;
3916
5168
  default:
3917
- text = withUnit(formatAmount(price.amount), unitText(price));
5169
+ text = withUnit(formatUnitPrice(price.amount), unitText(price));
3918
5170
  }
3919
5171
  if (price.flatFee)
3920
- text += ` + ${formatAmount(price.flatFee)} flat`;
5172
+ text += ` + ${formatUnitPrice(price.flatFee)} flat`;
3921
5173
  if (price.type === "TIERED" && price.tiers?.length)
3922
5174
  text += " + add-ons";
3923
5175
  return text;
@@ -3925,9 +5177,41 @@ function summarizePrice(price) {
3925
5177
  function whenText(when) {
3926
5178
  return Object.entries(when).map(([key, value]) => `${key}=${String(value)}`).join(", ");
3927
5179
  }
3928
- function priceDetail(price) {
5180
+ function priceDetail(price, fallback) {
3929
5181
  const rows = [];
3930
5182
  const lines = [];
5183
+ const summary = summarizePrice(price, fallback);
5184
+ if (!price)
5185
+ return { summary, rows, lines };
5186
+ const rates = price.rates ?? [];
5187
+ if (rates.length > 1 || rates.some(hasConditions)) {
5188
+ const notes = rates.map(rateNotes);
5189
+ const withNotes = notes.some(Boolean);
5190
+ rows.push(["CHARGE", "CONDITIONS", "PRICE", ...withNotes ? ["NOTES"] : []]);
5191
+ rates.forEach((rate, index) => {
5192
+ const [one] = METER_UNITS[rate.meter] ?? [rate.meter];
5193
+ rows.push([
5194
+ one,
5195
+ conditionsText(rate),
5196
+ describeRate(rate),
5197
+ ...withNotes ? [notes[index] || "-"] : []
5198
+ ]);
5199
+ });
5200
+ if (ratesByMeter(rates).length > 1) {
5201
+ lines.push("Different charges add up; for each charge, the row matching the request applies.");
5202
+ } else if (rates.length > 1) {
5203
+ lines.push("The row matching the request applies.");
5204
+ } else {
5205
+ lines.push("This price applies when the request matches its conditions.");
5206
+ }
5207
+ } else if (rates[0] && rateNotes(rates[0])) {
5208
+ lines.push(rateNotes(rates[0]));
5209
+ }
5210
+ if (price.description)
5211
+ lines.push(price.description);
5212
+ if (rates.length > 0) {
5213
+ lines.push("The final cost depends on actual usage: see `cost` in `solgrid runs get`.");
5214
+ }
3931
5215
  if (price.type === "PER_UNIT_MATRIX" && price.variants?.length) {
3932
5216
  const keys = price.selectors?.map((s) => s.key) ?? Object.keys(price.variants[0]?.when ?? {});
3933
5217
  const labels = keys.map((key) => price.selectors?.find((s) => s.key === key)?.label ?? key);
@@ -3944,11 +5228,11 @@ function priceDetail(price) {
3944
5228
  }
3945
5229
  for (const note of price.notes ?? [])
3946
5230
  lines.push(note);
3947
- return { summary: summarizePrice(price), rows, lines };
5231
+ return { summary, rows, lines };
3948
5232
  }
3949
5233
 
3950
5234
  // src/output/schema.ts
3951
- var MAX_DEPTH = 2;
5235
+ var MAX_DEPTH2 = 2;
3952
5236
  var MAX_ENUM_VALUES = 6;
3953
5237
  function isSchema(value) {
3954
5238
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -3993,7 +5277,7 @@ function schemaFields(schema, prefix = "", depth = 0) {
3993
5277
  required: required.has(name),
3994
5278
  description: describe(property)
3995
5279
  });
3996
- if (property.type === "object" && depth + 1 < MAX_DEPTH) {
5280
+ if (property.type === "object" && depth + 1 < MAX_DEPTH2) {
3997
5281
  fields.push(...schemaFields(property, `${path}.`, depth + 1));
3998
5282
  }
3999
5283
  }
@@ -4021,7 +5305,7 @@ function registerToolCommands(program, ctx) {
4021
5305
  const table = renderTable(["PROVIDER", "ENDPOINT", "PRICE", "HEALTH", "DESCRIPTION"], data.results.map((r) => [
4022
5306
  r.provider,
4023
5307
  r.endpoint,
4024
- summarizePrice(r.price),
5308
+ truncate(summarizePrice(r.price, r.externalPrice), 60),
4025
5309
  formatHealth(r.metrics, out.style),
4026
5310
  truncate(r.description, 60)
4027
5311
  ]), { formatHeader: (text) => out.style("dim", text) });
@@ -4045,7 +5329,7 @@ function registerToolCommands(program, ctx) {
4045
5329
  }));
4046
5330
  }
4047
5331
  function renderInspect(tool, style) {
4048
- const price = priceDetail(tool.price);
5332
+ const price = priceDetail(tool.price, tool.externalPrice);
4049
5333
  const runtime = tool.metrics?.runTimeMs;
4050
5334
  const lines = [
4051
5335
  style("bold", `${tool.providerName || tool.provider} · ${tool.endpoint}`),
@@ -4119,6 +5403,7 @@ function createProgram(ctx, options = {}) {
4119
5403
  registerRunCommand(program, ctx);
4120
5404
  registerRunsCommand(program, ctx);
4121
5405
  registerFilesCommand(program, ctx);
5406
+ registerSkillsCommand(program, ctx);
4122
5407
  registerAccountCommands(program, ctx);
4123
5408
  registerKeysCommand(program, ctx);
4124
5409
  registerSessionIdCommand(program, ctx);