@solgridai/cli 0.1.1 → 0.2.0-beta.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
@@ -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.0" };
2079
2079
 
2080
2080
  // src/core/errors.ts
2081
2081
  class CliError extends Error {
@@ -2091,16 +2091,43 @@ 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
+ routeMissing;
2108
+ apiKeySentTo;
2109
+ signedUrl;
2099
2110
  constructor(code, message, options = {}) {
2100
2111
  super(code, message, options);
2101
2112
  this.name = "ApiError";
2102
2113
  this.status = options.status;
2103
2114
  this.key = options.key;
2115
+ this.routeMissing = options.routeMissing === true;
2116
+ this.apiKeySentTo = options.apiKeySentTo;
2117
+ this.signedUrl = options.signedUrl === true;
2118
+ }
2119
+ copyWith(changes) {
2120
+ return new ApiError(this.code, this.message, {
2121
+ ...this.status !== undefined ? { status: this.status } : {},
2122
+ ...this.key ? { key: this.key } : {},
2123
+ routeMissing: this.routeMissing,
2124
+ ...this.apiKeySentTo ? { apiKeySentTo: this.apiKeySentTo } : {},
2125
+ signedUrl: this.signedUrl,
2126
+ hint: changes.hint ?? this.hint,
2127
+ details: this.details,
2128
+ data: changes.data ?? this.data,
2129
+ cause: this
2130
+ });
2104
2131
  }
2105
2132
  }
2106
2133
  function errorCodeForStatus(status) {
@@ -2181,7 +2208,19 @@ var isPrice = objectWith({
2181
2208
  selectors: optional(listOf(objectWith({ key: isString, label: optional(isText) }))),
2182
2209
  variants: optional(listOf(objectWith({ when: isObject, price: isLeafPrice }))),
2183
2210
  tiers: optional(listOf(objectWith({ label: isText, when: isObject, price: isLeafPrice }))),
2184
- notes: optional(listOf(isText))
2211
+ notes: optional(listOf(isText)),
2212
+ unit: optional(isText),
2213
+ unitSize: optional(isNumber),
2214
+ description: optional(isText),
2215
+ rates: optional(listOf(objectWith({
2216
+ meter: isString,
2217
+ unitPriceUsd: isNumber,
2218
+ unitSize: optional(isNumber),
2219
+ freeUnits: optional(isNumber),
2220
+ maximumUnits: optional(isNumber),
2221
+ roundUpTo: optional(isNumber),
2222
+ conditions: optional(isObject)
2223
+ })))
2185
2224
  });
2186
2225
  var isMetrics = objectWith({
2187
2226
  status: isString,
@@ -2225,12 +2264,12 @@ async function inspect(client, params) {
2225
2264
  const data = await client.post("/v1/tools/inspect", params);
2226
2265
  return expectShape("/v1/tools/inspect", data, TOOL_FIELDS);
2227
2266
  }
2228
- async function createRun(client, params, idempotencyKey) {
2267
+ async function createRun(client, params, idempotencyKey, request = {}) {
2229
2268
  const data = await client.post("/v1/runs/create", {
2230
2269
  provider: params.provider,
2231
2270
  endpoint: params.endpoint,
2232
2271
  ...params.input ? { input: params.input } : {}
2233
- }, { "Idempotency-Key": idempotencyKey });
2272
+ }, { "Idempotency-Key": idempotencyKey }, request);
2234
2273
  return expectShape("/v1/runs/create", data, RUN_FIELDS);
2235
2274
  }
2236
2275
  async function stopRun(client, runId) {
@@ -2265,7 +2304,8 @@ var TOOL_FIELDS = {
2265
2304
  provider: isString,
2266
2305
  endpoint: isString,
2267
2306
  description: isText,
2268
- price: isPrice,
2307
+ price: optional(isPrice),
2308
+ externalPrice: optional(isText),
2269
2309
  tags: optional(listOf(isText)),
2270
2310
  notes: optional(listOf(isText)),
2271
2311
  metrics: optional(isMetrics)
@@ -2540,10 +2580,36 @@ class ApiClient {
2540
2580
  }
2541
2581
  if (!response.ok) {
2542
2582
  const text = await response.text().catch(() => "");
2543
- throw envelopeError(response, parseEnvelope(text), text, url.split("?")[0] ?? url);
2583
+ throw envelopeError(response, parseEnvelope(text), text, url.split("?")[0] ?? url, {
2584
+ signedUrl: true
2585
+ });
2544
2586
  }
2545
2587
  return response;
2546
2588
  }
2589
+ async openDownload(path) {
2590
+ const url = `${this.baseUrl}${path}`;
2591
+ let response;
2592
+ try {
2593
+ response = await this.options.fetch(url, {
2594
+ method: "GET",
2595
+ headers: {
2596
+ "X-Solgrid-Client": this.options.clientId,
2597
+ ...this.options.apiKey ? { Authorization: `Bearer ${this.options.apiKey}` } : {}
2598
+ },
2599
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS)
2600
+ });
2601
+ } catch (cause) {
2602
+ throw transportError(cause, this.baseUrl, path, TRANSFER_TIMEOUT_MS);
2603
+ }
2604
+ if (!response.ok) {
2605
+ const text = await response.text().catch(() => "");
2606
+ throw envelopeError(response, parseEnvelope(text), text, url, this.sentWith());
2607
+ }
2608
+ return response;
2609
+ }
2610
+ sentWith() {
2611
+ return this.options.apiKey ? { apiKeySentTo: this.baseUrl } : {};
2612
+ }
2547
2613
  async send(method, path, options = {}) {
2548
2614
  const timeoutMs = options.timeoutMs ?? this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2549
2615
  let response;
@@ -2567,7 +2633,7 @@ class ApiClient {
2567
2633
  const url = `${this.baseUrl}${path.split("?")[0] ?? path}`;
2568
2634
  const envelope = parseEnvelope(text);
2569
2635
  if (!response.ok || envelope?.success === false) {
2570
- throw envelopeError(response, envelope, text, url);
2636
+ throw envelopeError(response, envelope, text, url, this.sentWith());
2571
2637
  }
2572
2638
  if (!envelope || !("data" in envelope)) {
2573
2639
  throw new ApiError("INVALID_RESPONSE", `Unexpected response from ${path}: ${describeBody(response, text)}`, { status: response.status });
@@ -2575,6 +2641,9 @@ class ApiClient {
2575
2641
  return envelope.data;
2576
2642
  }
2577
2643
  }
2644
+ function isResponseData(envelope) {
2645
+ return typeof envelope?.success === "boolean";
2646
+ }
2578
2647
  function parseEnvelope(text) {
2579
2648
  try {
2580
2649
  const value = JSON.parse(text);
@@ -2583,25 +2652,28 @@ function parseEnvelope(text) {
2583
2652
  return;
2584
2653
  }
2585
2654
  }
2586
- function envelopeError(response, envelope, text, url) {
2587
- if (!envelope && response.status === 404) {
2655
+ function envelopeError(response, envelope, text, url, auth) {
2656
+ if (response.status === 404 && !isResponseData(envelope)) {
2588
2657
  return new ApiError("NOT_FOUND", `${url} does not exist`, {
2589
2658
  status: 404,
2659
+ routeMissing: true,
2590
2660
  hint: "This Solgrid API may not support this command yet, or SOLGRID_API_BASE_URL points to the wrong server."
2591
2661
  });
2592
2662
  }
2593
2663
  const bodyCode = typeof envelope?.code === "number" && envelope.code >= 400 && envelope.code <= 599 ? envelope.code : undefined;
2594
2664
  const status = bodyCode ?? (response.ok ? 400 : response.status);
2595
- const key = typeof envelope?.message === "string" && envelope.message ? envelope.message : undefined;
2665
+ const serverMessage = typeof envelope?.message === "string" && envelope.message ? envelope.message : undefined;
2666
+ const key = errorKeyOf(envelope);
2596
2667
  const details = [
2597
- ...validationDetails(envelope?.errorData),
2668
+ ...validationDetails(envelope?.errorData, key),
2598
2669
  ...frameworkErrorDetails(envelope?.errors)
2599
2670
  ];
2600
2671
  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)}`);
2672
+ const message = serverMessage ?? (envelope ? `HTTP ${response.status}` : `HTTP ${response.status}: ${describeBody(response, text)}`);
2602
2673
  return new ApiError(errorCodeForStatus(status), message, {
2603
2674
  status,
2604
2675
  ...key ? { key } : {},
2676
+ ...auth,
2605
2677
  ...serverJsonHint ? { hint: serverJsonHint } : {},
2606
2678
  details
2607
2679
  });
@@ -2616,10 +2688,21 @@ function hasSerializerErrors(errors) {
2616
2688
  const value = errors?.serializerErrors;
2617
2689
  return Array.isArray(value) ? value.length > 0 : typeof value === "string" && value.length > 0;
2618
2690
  }
2619
- function validationDetails(items) {
2691
+ var ERROR_KEY = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
2692
+ function errorKeyOf(envelope) {
2693
+ const items = Array.isArray(envelope?.errorData) ? envelope.errorData : [];
2694
+ const item = items.find((entry) => entry && !entry.propertyName && typeof entry.errorCode === "string" && ERROR_KEY.test(entry.errorCode));
2695
+ if (item?.errorCode)
2696
+ return item.errorCode;
2697
+ const message = envelope?.message;
2698
+ return typeof message === "string" && ERROR_KEY.test(message) ? message : undefined;
2699
+ }
2700
+ function validationDetails(items, key) {
2620
2701
  if (!Array.isArray(items))
2621
2702
  return [];
2622
2703
  return items.flatMap((item) => {
2704
+ if (key && !item?.propertyName && item?.errorCode === key)
2705
+ return [];
2623
2706
  const text = item?.errorMessage?.trim();
2624
2707
  if (!text)
2625
2708
  return [];
@@ -2677,6 +2760,62 @@ var KEY_PRESENTATIONS = {
2677
2760
  message: "This tool is not available to run.",
2678
2761
  hint: "Check the provider and endpoint with `solgrid inspect`, or pick another tool with `solgrid discover`."
2679
2762
  },
2763
+ skill_archive_size_invalid: {
2764
+ message: "The Skill package is too large unpacked.",
2765
+ hint: "A Skill may hold at most 10,000,000 bytes of files."
2766
+ },
2767
+ skill_archive_entries_invalid: {
2768
+ message: "The Skill package is empty or has too many entries (at most 1,000).",
2769
+ hint: undefined
2770
+ },
2771
+ skill_archive_file_count_exceeded: {
2772
+ message: "The Skill package has more than 200 files.",
2773
+ hint: undefined
2774
+ },
2775
+ skill_archive_path_invalid: {
2776
+ message: "The Skill package has a path SkillHub does not accept.",
2777
+ hint: "Remove links, encrypted entries, absolute paths, `..`, backslashes, and names that differ only in case."
2778
+ },
2779
+ skill_archive_scripts_not_supported: {
2780
+ message: "This Solgrid server does not accept a scripts directory in a Skill.",
2781
+ hint: "Remove the scripts directories, or try again once the server allows them."
2782
+ },
2783
+ skill_archive_structure_invalid: {
2784
+ message: "The Skill package needs SKILL.md at its top, or inside a single top-level directory.",
2785
+ hint: "Pack the Skill directory with `--dir` and the CLI builds the right layout."
2786
+ },
2787
+ skill_archive_markdown_invalid: {
2788
+ message: "SKILL.md in the package is empty or not valid UTF-8.",
2789
+ hint: undefined
2790
+ },
2791
+ skill_archive_frontmatter_invalid: {
2792
+ message: "SKILL.md must start with YAML frontmatter between --- lines, followed by the instructions.",
2793
+ hint: "The frontmatter needs `name: <the Skill's slug>` and `description: <what it does>`."
2794
+ },
2795
+ skill_archive_name_invalid: {
2796
+ message: "The name in SKILL.md's frontmatter is missing or not valid.",
2797
+ hint: 'Use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".'
2798
+ },
2799
+ skill_archive_description_invalid: {
2800
+ message: "The description in SKILL.md's frontmatter is missing or longer than 1,024 characters.",
2801
+ hint: undefined
2802
+ },
2803
+ creative_case_skill_name_mismatch: {
2804
+ message: "The name in SKILL.md must equal the Skill's slug.",
2805
+ hint: "Set `name: <slug>` in SKILL.md's frontmatter, or pass the matching --slug."
2806
+ },
2807
+ creative_case_slug_invalid: {
2808
+ message: "The slug is not valid.",
2809
+ hint: 'Use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".'
2810
+ },
2811
+ skill_archive_content_invalid: {
2812
+ message: "The Skill package is damaged or could not be unpacked.",
2813
+ hint: "Pack it again, e.g. with `--dir`."
2814
+ },
2815
+ file_content_type_not_allowed: {
2816
+ message: "Solgrid does not accept this kind of file.",
2817
+ hint: undefined
2818
+ },
2680
2819
  file_size_invalid: {
2681
2820
  message: "The file is larger than Solgrid accepts for its type.",
2682
2821
  hint: "Limits: images and audio 20 MiB, video 200 MiB, Skill ZIP 10 MB."
@@ -2703,7 +2842,10 @@ function presentError(error) {
2703
2842
  }
2704
2843
  function presentApiError(error) {
2705
2844
  const base = explain(error);
2706
- const hint = [base.hint, error.hint].filter(Boolean).join(`
2845
+ const steps = [base.hint, error.hint];
2846
+ if (error.code === "AUTH_FAILED")
2847
+ steps.reverse();
2848
+ const hint = steps.filter(Boolean).join(`
2707
2849
  `) || undefined;
2708
2850
  return { message: base.message, hint };
2709
2851
  }
@@ -2713,6 +2855,12 @@ function explain(error) {
2713
2855
  return known;
2714
2856
  switch (error.code) {
2715
2857
  case "AUTH_FAILED":
2858
+ if (error.signedUrl) {
2859
+ return {
2860
+ message: "The download link was refused.",
2861
+ hint: "Signed links expire after 24 hours. Get a fresh one (e.g. `solgrid files url -i <fileId>`) and try again."
2862
+ };
2863
+ }
2716
2864
  return {
2717
2865
  message: "The API key is missing, invalid or revoked.",
2718
2866
  hint: `Create a key at ${CONSOLE_URLS.apiKeys}, then run: solgrid keys add -k <key> -l <label>`
@@ -2720,7 +2868,10 @@ function explain(error) {
2720
2868
  case "INVALID_INPUT":
2721
2869
  return { message: `Invalid request: ${error.message}`, hint: undefined };
2722
2870
  case "NOT_FOUND":
2723
- return { message: `Not found: ${error.message}`, hint: undefined };
2871
+ return {
2872
+ message: error.message === error.key ? `Not found: ${error.message}` : error.message,
2873
+ hint: undefined
2874
+ };
2724
2875
  case "TOO_LARGE":
2725
2876
  return {
2726
2877
  message: "The upload was rejected as too large.",
@@ -2864,13 +3015,14 @@ async function runAction(ctx, json, body) {
2864
3015
  try {
2865
3016
  await body(out);
2866
3017
  } catch (error) {
2867
- out.error(error);
3018
+ out.error(withAuthContext(ctx, error));
2868
3019
  ctx.exitCode = 1;
2869
3020
  }
2870
3021
  }
2871
3022
  function configStore(ctx) {
2872
3023
  return new ConfigStore(resolveConfigDir(ctx.env, ctx.homeDir));
2873
3024
  }
3025
+ var sentKeys = new WeakMap;
2874
3026
  function apiClient(ctx) {
2875
3027
  const resolved = resolveApiKey(configStore(ctx), ctx.env);
2876
3028
  if (!resolved) {
@@ -2878,6 +3030,7 @@ function apiClient(ctx) {
2878
3030
  hint: `Create a key at ${CONSOLE_URLS.apiKeys}, then run: solgrid keys add -k <key> -l <label>`
2879
3031
  });
2880
3032
  }
3033
+ sentKeys.set(ctx, resolved);
2881
3034
  return new ApiClient({
2882
3035
  baseUrl: ctx.env[ENV.apiBaseUrl]?.trim() || DEFAULT_API_BASE_URL,
2883
3036
  apiKey: resolved.key,
@@ -2885,6 +3038,21 @@ function apiClient(ctx) {
2885
3038
  fetch: ctx.fetch
2886
3039
  });
2887
3040
  }
3041
+ function withAuthContext(ctx, error) {
3042
+ const key = sentKeys.get(ctx);
3043
+ if (!(error instanceof ApiError) || error.code !== "AUTH_FAILED" || !key)
3044
+ return error;
3045
+ const baseUrl = error.apiKeySentTo;
3046
+ if (!baseUrl)
3047
+ return error;
3048
+ const which = key.source === "env" ? `the key from ${ENV.apiKey} (${maskApiKey(key.key)})` : `key "${key.label}" (${maskApiKey(key.key)})`;
3049
+ const where = baseUrl === DEFAULT_API_BASE_URL ? `${baseUrl}, the production API` : `${baseUrl}, set by ${ENV.apiBaseUrl}`;
3050
+ 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}.`;
3051
+ return error.copyWith({
3052
+ hint: [`Sent ${which} to ${where}. ${advice}`, error.hint].filter(Boolean).join(`
3053
+ `)
3054
+ });
3055
+ }
2888
3056
 
2889
3057
  // src/commands/account.ts
2890
3058
  function registerAccountCommands(program, ctx) {
@@ -3417,14 +3585,19 @@ var TRANSIENT = new Set([
3417
3585
  "RATE_LIMITED",
3418
3586
  "INVALID_RESPONSE"
3419
3587
  ]);
3588
+ var SUBMIT_TIMEOUT_MS = 10 * 60000;
3589
+ var LOOKUP_TIMEOUT_MS = 30000;
3420
3590
  var RESUBMIT_BACKOFF_MS = [1000, 2000, 4000];
3421
3591
  var LOOKUP_BACKOFF_MS = [1000, 2000];
3422
3592
  async function submitRun(client, request, options) {
3423
3593
  const { idempotencyKey } = request;
3594
+ const budget = submitBudget(idempotencyKey, options);
3424
3595
  for (let resubmissions = 0;; resubmissions++) {
3425
3596
  let failure;
3426
3597
  try {
3427
- const run = await createRun(client, request, idempotencyKey);
3598
+ const run = await createRun(client, request, idempotencyKey, {
3599
+ timeoutMs: budget.timeoutFor(SUBMIT_TIMEOUT_MS, { wholeBudget: true })
3600
+ });
3428
3601
  return { run, recovered: false };
3429
3602
  } catch (error) {
3430
3603
  if (!(error instanceof ApiError) || REJECTED.has(error.code)) {
@@ -3432,31 +3605,60 @@ async function submitRun(client, request, options) {
3432
3605
  }
3433
3606
  failure = error;
3434
3607
  }
3435
- const existing = await lookupByKey(client, idempotencyKey, options.sleep);
3608
+ const existing = await lookupByKey(client, idempotencyKey, budget);
3436
3609
  if (existing)
3437
3610
  return { run: verifyRecovered(existing, request), recovered: true };
3438
3611
  if (resubmissions >= RESUBMIT_BACKOFF_MS.length) {
3439
3612
  throw withContext(failure, { idempotencyKey });
3440
3613
  }
3441
3614
  options.onRetry?.(resubmissions + 1, failure);
3442
- await options.sleep(RESUBMIT_BACKOFF_MS[resubmissions] ?? 4000);
3615
+ await budget.pause(RESUBMIT_BACKOFF_MS[resubmissions] ?? 4000);
3443
3616
  }
3444
3617
  }
3445
- async function lookupByKey(client, idempotencyKey, sleep) {
3618
+ function isNoRunForKey(error) {
3619
+ if (!(error instanceof ApiError) || error.code !== "NOT_FOUND" || error.routeMissing)
3620
+ return false;
3621
+ return error.key === undefined || error.key === "tool_run_not_found";
3622
+ }
3623
+ async function lookupByKey(client, idempotencyKey, budget) {
3446
3624
  for (let attempt = 0;; attempt++) {
3447
3625
  try {
3448
- return await getRunByIdempotencyKey(client, idempotencyKey);
3626
+ return await getRunByIdempotencyKey(client, idempotencyKey, {
3627
+ timeoutMs: budget.timeoutFor(LOOKUP_TIMEOUT_MS)
3628
+ });
3449
3629
  } catch (error) {
3450
- if (error instanceof ApiError && error.key === "tool_run_not_found")
3630
+ if (isNoRunForKey(error))
3451
3631
  return;
3632
+ budget.check();
3452
3633
  const transient = error instanceof ApiError && TRANSIENT.has(error.code);
3453
3634
  if (!transient || attempt >= LOOKUP_BACKOFF_MS.length) {
3454
3635
  throw withContext(error, { idempotencyKey }, { lead: "Could not confirm whether the run was accepted." });
3455
3636
  }
3456
- await sleep(LOOKUP_BACKOFF_MS[attempt] ?? 2000);
3637
+ await budget.pause(LOOKUP_BACKOFF_MS[attempt] ?? 2000);
3457
3638
  }
3458
3639
  }
3459
3640
  }
3641
+ function submitBudget(idempotencyKey, options) {
3642
+ const { deadline } = options;
3643
+ const left = () => deadline === undefined ? Infinity : deadline - options.now().getTime();
3644
+ const check = () => {
3645
+ if (left() <= 0)
3646
+ throw withContext(submitTimeout(), { idempotencyKey });
3647
+ };
3648
+ return {
3649
+ check,
3650
+ timeoutFor(cap, { wholeBudget = false } = {}) {
3651
+ if (deadline === undefined)
3652
+ return cap;
3653
+ check();
3654
+ return wholeBudget ? left() : Math.min(cap, left());
3655
+ },
3656
+ async pause(ms) {
3657
+ check();
3658
+ await options.sleep(Math.min(ms, left()));
3659
+ }
3660
+ };
3661
+ }
3460
3662
  function verifyRecovered(run, request) {
3461
3663
  const data = { idempotencyKey: request.idempotencyKey, runId: run.runId };
3462
3664
  const check = `Inspect it with: solgrid runs get -r ${run.runId}`;
@@ -3490,18 +3692,11 @@ function withContext(error, data, options = {}) {
3490
3692
  } else if (data.idempotencyKey && options.retryable !== false) {
3491
3693
  steps.push(`Retry safely with --idempotency-key ${data.idempotencyKey}, or check: solgrid runs get --idempotency-key ${data.idempotencyKey}`);
3492
3694
  }
3493
- const rebuilt = {
3695
+ return error.copyWith({
3494
3696
  hint: [error.hint, ...steps].filter(Boolean).join(`
3495
3697
  `),
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);
3698
+ data: { ...error.data, ...data }
3699
+ });
3505
3700
  }
3506
3701
  function sameInput(stored, submitted) {
3507
3702
  return canonical(normalize(stored)) === canonical(normalize(submitted));
@@ -3534,7 +3729,7 @@ var MAX_POLL_MS = 1e4;
3534
3729
  async function waitFor(fetchRun, options) {
3535
3730
  const deadline = options.now().getTime() + options.timeoutMs;
3536
3731
  const remaining = () => deadline - options.now().getTime();
3537
- const timedOut = () => waitTimeout(options.description, last, options.timeoutMs, options.checkCommand);
3732
+ const timedOut = () => waitTimeout(options.description, last, options.totalMs ?? options.timeoutMs, options.checkCommand);
3538
3733
  let delay = FIRST_POLL_MS;
3539
3734
  let last;
3540
3735
  for (;; ) {
@@ -3566,6 +3761,11 @@ function waitForRun(client, runId, options) {
3566
3761
  checkCommand: `solgrid runs get -r ${runId} --wait`
3567
3762
  });
3568
3763
  }
3764
+ function submitTimeout() {
3765
+ return new CliError("WAIT_TIMEOUT", "The wait ended before the run could be confirmed.", {
3766
+ hint: "It may still be running on Solgrid, or may not have started."
3767
+ });
3768
+ }
3569
3769
  function waitTimeout(description, last, timeoutMs, checkCommand) {
3570
3770
  const runId = last?.runId;
3571
3771
  const status = last?.status;
@@ -3721,6 +3921,8 @@ function registerRunCommand(program, ctx) {
3721
3921
  const idempotencyKey = opts.idempotencyKey ?? ctx.randomUUID();
3722
3922
  validateIdempotencyKey(idempotencyKey);
3723
3923
  const waitSeconds = opts.wait === true ? DEFAULT_WAIT_SECONDS : opts.wait;
3924
+ const budgetMs = waitSeconds !== undefined || opts.output !== undefined ? (waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000 : undefined;
3925
+ const deadline = budgetMs !== undefined ? ctx.now().getTime() + budgetMs : undefined;
3724
3926
  const client = apiClient(ctx);
3725
3927
  out.progress.start(`Submitting ${opts.provider} ${opts.endpoint}…`);
3726
3928
  const { run: submitted, recovered } = await submitRun(client, {
@@ -3729,15 +3931,17 @@ function registerRunCommand(program, ctx) {
3729
3931
  ...input ? { input } : {},
3730
3932
  idempotencyKey
3731
3933
  }, {
3934
+ now: () => ctx.now(),
3732
3935
  sleep: (ms) => ctx.sleep(ms),
3733
- onRetry: (attempt) => out.progress.update(`Submission interrupted, retrying (${attempt})…`)
3936
+ onRetry: (attempt) => out.progress.update(`Submission interrupted, retrying (${attempt})…`),
3937
+ ...deadline !== undefined ? { deadline } : {}
3734
3938
  });
3735
3939
  try {
3736
3940
  await finishRun(ctx, client, out, {
3737
3941
  submitted,
3738
3942
  recovered,
3739
3943
  idempotencyKey,
3740
- ...waitSeconds !== undefined ? { waitSeconds } : {},
3944
+ ...budgetMs !== undefined && deadline !== undefined ? { budgetMs, deadline } : {},
3741
3945
  ...opts.output !== undefined ? { outputPath: opts.output } : {}
3742
3946
  });
3743
3947
  } catch (error) {
@@ -3747,13 +3951,14 @@ function registerRunCommand(program, ctx) {
3747
3951
  }
3748
3952
  async function finishRun(ctx, client, out, params) {
3749
3953
  const { submitted, recovered, idempotencyKey, outputPath } = params;
3750
- const wait = params.waitSeconds !== undefined || outputPath !== undefined;
3954
+ const wait = params.deadline !== undefined;
3751
3955
  if (!wait && !isTerminal(submitted.status)) {
3752
3956
  out.result({ ...submitted, idempotencyKey, recovered }, () => renderSubmitted(submitted.runId, submitted.status, idempotencyKey, recovered, out));
3753
3957
  return;
3754
3958
  }
3755
3959
  const finished = isTerminal(submitted.status) ? submitted : await waitForRun(client, submitted.runId, {
3756
- timeoutMs: (params.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000,
3960
+ timeoutMs: params.deadline - ctx.now().getTime(),
3961
+ totalMs: params.budgetMs,
3757
3962
  now: () => ctx.now(),
3758
3963
  sleep: (ms) => ctx.sleep(ms),
3759
3964
  onPoll: (run) => out.progress.update(`Waiting for ${run.runId} (${run.status})…`)
@@ -3863,6 +4068,893 @@ function registerSessionIdCommand(program, ctx) {
3863
4068
  }));
3864
4069
  }
3865
4070
 
4071
+ // src/commands/skills.ts
4072
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
4073
+
4074
+ // src/core/skill-markdown.ts
4075
+ var MAX_SKILL_NAME_LENGTH = 64;
4076
+ var MAX_SKILL_DESCRIPTION_LENGTH = 1024;
4077
+ var SKILL_NAME = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/;
4078
+ function isSkillName(value) {
4079
+ return value.length <= MAX_SKILL_NAME_LENGTH && SKILL_NAME.test(value) && !value.endsWith("-");
4080
+ }
4081
+ function readFrontmatter(text) {
4082
+ const lines = text.replace(/^\uFEFF/, "").split(/\r\n|\r|\n/);
4083
+ if (lines[0] !== "---")
4084
+ return;
4085
+ const end = lines.indexOf("---", 1);
4086
+ if (end === -1)
4087
+ return;
4088
+ if (!lines.slice(end + 1).join(`
4089
+ `).trim())
4090
+ return;
4091
+ const fields = topLevelScalars(lines.slice(1, end));
4092
+ return { name: fields.get("name"), description: fields.get("description") };
4093
+ }
4094
+ function frontmatterProblem(frontmatter) {
4095
+ if (!frontmatter)
4096
+ return "missing";
4097
+ const { name, description } = frontmatter;
4098
+ if (name === undefined || name !== null && !isSkillName(name))
4099
+ return "name";
4100
+ if (description === undefined || description !== null && (!description.trim() || description.length > MAX_SKILL_DESCRIPTION_LENGTH)) {
4101
+ return "description";
4102
+ }
4103
+ return;
4104
+ }
4105
+ function topLevelScalars(lines) {
4106
+ const fields = new Map;
4107
+ for (let i = 0;i < lines.length; i++) {
4108
+ const match = /^(?:"([^"\\]*)"|'([^']*)'|([^\s#:'"][^:]*?))[ \t]*:(?:[ \t]+(.*))?$/.exec(lines[i]);
4109
+ if (!match)
4110
+ continue;
4111
+ const key = (match[1] ?? match[2] ?? match[3]).trim();
4112
+ const rest = [];
4113
+ while (i + 1 < lines.length && /^(?:[ \t]|$)/.test(lines[i + 1])) {
4114
+ rest.push(lines[++i]);
4115
+ }
4116
+ let value;
4117
+ try {
4118
+ value = scalar((match[4] ?? "").trim(), rest);
4119
+ } catch {
4120
+ value = null;
4121
+ }
4122
+ if (value !== undefined)
4123
+ fields.set(key, value);
4124
+ }
4125
+ return fields;
4126
+ }
4127
+ var NESTED = /^(?:-(?:[ \t]|$)|(?:"[^"\\]*"|'[^']*'|[^\s#'"][^:]*?)[ \t]*:(?:[ \t]|$))/;
4128
+ function scalar(head, rest) {
4129
+ if (!head) {
4130
+ const index = rest.findIndex((line) => line.trim() && !line.trim().startsWith("#"));
4131
+ if (index === -1)
4132
+ return "";
4133
+ const first = rest[index].trim();
4134
+ if (NESTED.test(first))
4135
+ return;
4136
+ return scalar(first, rest.slice(index + 1));
4137
+ }
4138
+ if (head.startsWith('"') || head.startsWith("'"))
4139
+ return quoted([head, ...rest]);
4140
+ const more = rest.map((line) => line.trim()).filter(Boolean);
4141
+ if (/^[|>]/.test(head)) {
4142
+ return more.join(head.startsWith("|") ? `
4143
+ ` : " ");
4144
+ }
4145
+ if (/^[[{]/.test(head))
4146
+ return;
4147
+ if (/^[&*!]/.test(head))
4148
+ return null;
4149
+ return [head, ...more].filter((line) => !line.startsWith("#")).join(" ").replace(/\s+#.*$/, "");
4150
+ }
4151
+ var ESCAPES = {
4152
+ "0": "\x00",
4153
+ a: "\x07",
4154
+ b: "\b",
4155
+ t: "\t",
4156
+ "\t": "\t",
4157
+ n: `
4158
+ `,
4159
+ v: "\v",
4160
+ f: "\f",
4161
+ r: "\r",
4162
+ e: "\x1B",
4163
+ " ": " ",
4164
+ '"': '"',
4165
+ "/": "/",
4166
+ "\\": "\\",
4167
+ N: "…",
4168
+ _: " ",
4169
+ L: "\u2028",
4170
+ P: "\u2029"
4171
+ };
4172
+ var HEX_ESCAPES = { x: 2, u: 4, U: 8 };
4173
+ function quoted(lines) {
4174
+ const text = lines.join(`
4175
+ `);
4176
+ const quote = text[0];
4177
+ let value = "";
4178
+ let i = 1;
4179
+ for (;; ) {
4180
+ if (i >= text.length)
4181
+ return null;
4182
+ const char = text[i];
4183
+ if (char === quote) {
4184
+ if (quote === "'" && text[i + 1] === "'") {
4185
+ value += "'";
4186
+ i += 2;
4187
+ continue;
4188
+ }
4189
+ break;
4190
+ }
4191
+ if (char === "\\" && quote === '"') {
4192
+ const next = text[i + 1] ?? "";
4193
+ if (next === `
4194
+ `) {
4195
+ i += 2;
4196
+ while (text[i] === " " || text[i] === "\t")
4197
+ i++;
4198
+ continue;
4199
+ }
4200
+ const digits = HEX_ESCAPES[next];
4201
+ if (digits !== undefined) {
4202
+ const hex = text.slice(i + 2, i + 2 + digits);
4203
+ if (!new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(hex))
4204
+ return null;
4205
+ const codePoint = Number.parseInt(hex, 16);
4206
+ if (codePoint > 1114111)
4207
+ return null;
4208
+ value += String.fromCodePoint(codePoint);
4209
+ i += 2 + digits;
4210
+ continue;
4211
+ }
4212
+ const escaped = ESCAPES[next];
4213
+ if (escaped === undefined)
4214
+ return null;
4215
+ value += escaped;
4216
+ i += 2;
4217
+ continue;
4218
+ }
4219
+ if (char === `
4220
+ `) {
4221
+ value = value.replace(/[ \t]+$/, "");
4222
+ let breaks = 0;
4223
+ while (text[i] === `
4224
+ `) {
4225
+ breaks++;
4226
+ i++;
4227
+ while (text[i] === " " || text[i] === "\t")
4228
+ i++;
4229
+ }
4230
+ value += breaks === 1 ? " " : `
4231
+ `.repeat(breaks - 1);
4232
+ continue;
4233
+ }
4234
+ value += char;
4235
+ i++;
4236
+ }
4237
+ return /^[ \t]*(?:#[^\n]*)?(?:\n[ \t]*(?:#[^\n]*)?)*$/.test(text.slice(i + 1)) ? value : null;
4238
+ }
4239
+
4240
+ // src/core/skill-package.ts
4241
+ import { lstatSync, mkdtempSync, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
4242
+ import { tmpdir } from "node:os";
4243
+ import { basename as basename2, join as join3, relative, sep } from "node:path";
4244
+ import { deflateRawSync } from "node:zlib";
4245
+ var MAX_SKILL_BYTES = 1e7;
4246
+ var MAX_SKILL_FILES = 200;
4247
+ var MAX_DEPTH = 16;
4248
+ var MAX_PATH_LENGTH = 1024;
4249
+ var SKIPPED = new Set([".git", ".DS_Store"]);
4250
+ function invalid(dir, reason, hint) {
4251
+ return new CliError("USAGE", `${dir} is not a valid Skill: ${reason}.`, hint ? { hint } : {});
4252
+ }
4253
+ function collect(dir) {
4254
+ const entries = [];
4255
+ const seen = new Map;
4256
+ let total = 0;
4257
+ const walk = (current, depth) => {
4258
+ for (const name of readdirSync(current).sort()) {
4259
+ if (SKIPPED.has(name))
4260
+ continue;
4261
+ const full = join3(current, name);
4262
+ const zipName = relative(dir, full).split(sep).join("/");
4263
+ if (/[:\\\u0000-\u001f\u007f]/.test(name)) {
4264
+ throw invalid(dir, `"${zipName}" has a ":", a backslash or a control character in its name`);
4265
+ }
4266
+ if (zipName.length > MAX_PATH_LENGTH || depth > MAX_DEPTH) {
4267
+ throw invalid(dir, `"${zipName}" is nested too deeply or its path is too long`);
4268
+ }
4269
+ const lower = zipName.toLowerCase();
4270
+ const clash = seen.get(lower);
4271
+ if (clash)
4272
+ throw invalid(dir, `"${clash}" and "${zipName}" differ only in case`);
4273
+ seen.set(lower, zipName);
4274
+ const stat = lstatSync(full);
4275
+ if (stat.isSymbolicLink()) {
4276
+ throw invalid(dir, `"${zipName}" is a symbolic link`, "Replace links with the files themselves.");
4277
+ }
4278
+ if (stat.isDirectory()) {
4279
+ walk(full, depth + 1);
4280
+ } else if (stat.isFile()) {
4281
+ if (entries.length >= MAX_SKILL_FILES) {
4282
+ throw invalid(dir, `it has more than ${MAX_SKILL_FILES} files`);
4283
+ }
4284
+ total += stat.size;
4285
+ if (total > MAX_SKILL_BYTES) {
4286
+ throw invalid(dir, `its files add up to more than ${MAX_SKILL_BYTES.toLocaleString("en-US")} bytes`);
4287
+ }
4288
+ entries.push({ name: zipName, data: readFileSync3(full) });
4289
+ } else {
4290
+ throw invalid(dir, `"${zipName}" is not a regular file or directory`);
4291
+ }
4292
+ }
4293
+ };
4294
+ walk(dir, 1);
4295
+ return entries;
4296
+ }
4297
+ var FRONTMATTER_EXAMPLE = `Start SKILL.md with:
4298
+ ---
4299
+ name: my-skill
4300
+ description: What the Skill does and when to use it.
4301
+ ---
4302
+ followed by the instructions.`;
4303
+ function checkSkillMarkdown(dir, entries) {
4304
+ const markdown = entries.find((entry) => entry.name === "SKILL.md");
4305
+ if (!markdown) {
4306
+ const wrongCase = entries.find((entry) => entry.name.toLowerCase() === "skill.md");
4307
+ 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.");
4308
+ }
4309
+ let text;
4310
+ try {
4311
+ text = new TextDecoder("utf-8", { fatal: true }).decode(markdown.data);
4312
+ } catch {
4313
+ throw invalid(dir, "SKILL.md is not valid UTF-8");
4314
+ }
4315
+ if (!text.trim())
4316
+ throw invalid(dir, "SKILL.md is empty");
4317
+ const frontmatter = readFrontmatter(text);
4318
+ switch (frontmatterProblem(frontmatter)) {
4319
+ case "missing":
4320
+ throw invalid(dir, "SKILL.md must start with frontmatter between --- lines, followed by the instructions", FRONTMATTER_EXAMPLE);
4321
+ case "name":
4322
+ 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.`);
4323
+ case "description":
4324
+ 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.");
4325
+ }
4326
+ return frontmatter?.name ?? undefined;
4327
+ }
4328
+ function packSkillDirectory(dir) {
4329
+ try {
4330
+ if (!lstatSync(dir).isDirectory())
4331
+ throw new Error("not a directory");
4332
+ } catch (cause) {
4333
+ throw new CliError("USAGE", `Cannot read the directory ${dir}.`, { cause });
4334
+ }
4335
+ const entries = collect(dir);
4336
+ const name = checkSkillMarkdown(dir, entries);
4337
+ const zip = buildZip(entries);
4338
+ if (zip.length > MAX_SKILL_BYTES) {
4339
+ throw invalid(dir, `the package would be ${zip.length.toLocaleString("en-US")} bytes, over the limit`);
4340
+ }
4341
+ const temp = mkdtempSync(join3(tmpdir(), "solgrid-skill-"));
4342
+ const path = join3(temp, `${basename2(dir) || "skill"}.zip`);
4343
+ writeFileSync3(path, zip);
4344
+ return {
4345
+ path,
4346
+ files: entries.length,
4347
+ bytes: zip.length,
4348
+ name,
4349
+ cleanup: () => rmSync(temp, { recursive: true, force: true })
4350
+ };
4351
+ }
4352
+ var CRC_TABLE = (() => {
4353
+ const table = new Uint32Array(256);
4354
+ for (let n = 0;n < 256; n++) {
4355
+ let c = n;
4356
+ for (let k = 0;k < 8; k++)
4357
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
4358
+ table[n] = c >>> 0;
4359
+ }
4360
+ return table;
4361
+ })();
4362
+ function crc32(data) {
4363
+ let crc = 4294967295;
4364
+ for (const byte of data)
4365
+ crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
4366
+ return (crc ^ 4294967295) >>> 0;
4367
+ }
4368
+ var DOS_TIME = 0;
4369
+ var DOS_DATE = 1 << 5 | 1;
4370
+ var UTF8_NAMES = 2048;
4371
+ function buildZip(entries) {
4372
+ const parts = [];
4373
+ const central = [];
4374
+ let offset = 0;
4375
+ for (const entry of entries) {
4376
+ const name = Buffer.from(entry.name, "utf8");
4377
+ const deflated = deflateRawSync(entry.data);
4378
+ const stored = deflated.length >= entry.data.length;
4379
+ const body = stored ? Buffer.from(entry.data) : deflated;
4380
+ const method = stored ? 0 : 8;
4381
+ const crc = crc32(entry.data);
4382
+ const local = Buffer.alloc(30);
4383
+ local.writeUInt32LE(67324752, 0);
4384
+ local.writeUInt16LE(20, 4);
4385
+ local.writeUInt16LE(UTF8_NAMES, 6);
4386
+ local.writeUInt16LE(method, 8);
4387
+ local.writeUInt16LE(DOS_TIME, 10);
4388
+ local.writeUInt16LE(DOS_DATE, 12);
4389
+ local.writeUInt32LE(crc, 14);
4390
+ local.writeUInt32LE(body.length, 18);
4391
+ local.writeUInt32LE(entry.data.length, 22);
4392
+ local.writeUInt16LE(name.length, 26);
4393
+ local.writeUInt16LE(0, 28);
4394
+ parts.push(local, name, body);
4395
+ const header = Buffer.alloc(46);
4396
+ header.writeUInt32LE(33639248, 0);
4397
+ header.writeUInt16LE(3 << 8 | 20, 4);
4398
+ header.writeUInt16LE(20, 6);
4399
+ header.writeUInt16LE(UTF8_NAMES, 8);
4400
+ header.writeUInt16LE(method, 10);
4401
+ header.writeUInt16LE(DOS_TIME, 12);
4402
+ header.writeUInt16LE(DOS_DATE, 14);
4403
+ header.writeUInt32LE(crc, 16);
4404
+ header.writeUInt32LE(body.length, 20);
4405
+ header.writeUInt32LE(entry.data.length, 24);
4406
+ header.writeUInt16LE(name.length, 28);
4407
+ header.writeUInt32LE(33188 << 16 >>> 0, 38);
4408
+ header.writeUInt32LE(offset, 42);
4409
+ central.push(header, name);
4410
+ offset += local.length + name.length + body.length;
4411
+ }
4412
+ const directory = Buffer.concat(central);
4413
+ const end = Buffer.alloc(22);
4414
+ end.writeUInt32LE(101010256, 0);
4415
+ end.writeUInt16LE(entries.length, 8);
4416
+ end.writeUInt16LE(entries.length, 10);
4417
+ end.writeUInt32LE(directory.length, 12);
4418
+ end.writeUInt32LE(offset, 16);
4419
+ return Buffer.concat([...parts, directory, end]);
4420
+ }
4421
+
4422
+ // src/core/skills.ts
4423
+ import { openAsBlob as openAsBlob2, statSync as statSync2 } from "node:fs";
4424
+ import { basename as basename3, extname as extname2 } from "node:path";
4425
+ var SKILL_KINDS = ["image", "video", "web", "audio"];
4426
+ var KIND_NAME = {
4427
+ image: "Image",
4428
+ video: "Video",
4429
+ web: "Web",
4430
+ audio: "Audio"
4431
+ };
4432
+ var KIND_BY_NUMBER2 = { 1: "image", 2: "video", 3: "web", 4: "audio" };
4433
+ function kindName(value) {
4434
+ if (typeof value === "number")
4435
+ return KIND_BY_NUMBER2[value];
4436
+ if (typeof value === "string") {
4437
+ const name = value.toLowerCase();
4438
+ return SKILL_KINDS.find((kind) => kind === name);
4439
+ }
4440
+ return;
4441
+ }
4442
+ var CARD_FIELDS = {
4443
+ id: isString,
4444
+ title: isText,
4445
+ tags: optional(listOf(isText)),
4446
+ author: optional(objectWith({ nickname: optional(isText) }))
4447
+ };
4448
+ async function listSkills(client, params) {
4449
+ const path = params.mine ? "/v1/creative-cases/list-mine" : "/v1/creative-cases/list";
4450
+ const data = await client.post(path, {
4451
+ ...params.page !== undefined ? { pageIndex: params.page } : {},
4452
+ ...params.pageSize !== undefined ? { pageSize: params.pageSize } : {},
4453
+ ...params.kind ? { kind: KIND_NAME[params.kind] } : {},
4454
+ ...params.keyword ? { keyword: params.keyword } : {},
4455
+ ...params.slug ? { slug: params.slug } : {}
4456
+ });
4457
+ return expectShape(path, data, {
4458
+ items: listOf(objectWith(CARD_FIELDS)),
4459
+ total: isNumber,
4460
+ pageIndex: isNumber,
4461
+ pageSize: isNumber
4462
+ });
4463
+ }
4464
+ async function getSkill(client, target) {
4465
+ const path = "/v1/creative-cases/get";
4466
+ const data = await client.post(path, target);
4467
+ return expectShape(path, data, {
4468
+ ...CARD_FIELDS,
4469
+ slug: isString,
4470
+ description: isText,
4471
+ skillFileAssetId: isString
4472
+ });
4473
+ }
4474
+ function contentBody(content) {
4475
+ return {
4476
+ title: content.title,
4477
+ kind: KIND_NAME[content.kind],
4478
+ description: content.description,
4479
+ tags: content.tags,
4480
+ coverFileAssetId: content.coverFileAssetId ?? null,
4481
+ previewFileAssetId: content.previewFileAssetId ?? null,
4482
+ skillFileAssetId: content.skillFileAssetId
4483
+ };
4484
+ }
4485
+ var MUTATION_FIELDS = { id: isString };
4486
+ async function createSkill(client, slug, content) {
4487
+ const path = "/v1/creative-cases/create";
4488
+ const data = await client.post(path, { ...contentBody(content), slug });
4489
+ return expectShape(path, data, MUTATION_FIELDS);
4490
+ }
4491
+ async function updateSkill(client, id, content) {
4492
+ const path = "/v1/creative-cases/update";
4493
+ const data = await client.post(path, { id, ...contentBody(content) });
4494
+ return expectShape(path, data, MUTATION_FIELDS);
4495
+ }
4496
+ async function deleteSkill(client, id) {
4497
+ await client.post("/v1/creative-cases/delete", { id });
4498
+ }
4499
+ function contentOf(skill) {
4500
+ const kind = kindName(skill.kind);
4501
+ if (!kind) {
4502
+ throw new CliError("INVALID_RESPONSE", `Unexpected kind ${JSON.stringify(skill.kind)} for this Skill.`);
4503
+ }
4504
+ return {
4505
+ title: skill.title,
4506
+ kind,
4507
+ description: skill.description,
4508
+ tags: skill.tags ?? [],
4509
+ coverFileAssetId: skill.cover?.fileAssetId ?? null,
4510
+ previewFileAssetId: skill.previewFileAssetId ?? null,
4511
+ skillFileAssetId: skill.skillFileAssetId
4512
+ };
4513
+ }
4514
+ var MAX_TAGS = 3;
4515
+ async function listSkillTags(client) {
4516
+ const path = "/v1/creative-cases/list-tags";
4517
+ const data = await client.post(path);
4518
+ const checked = expectShape(path, { tags: data }, {
4519
+ tags: listOf(objectWith({ id: isString, name: isText }))
4520
+ });
4521
+ return checked.tags;
4522
+ }
4523
+ function checkTags(tags, available) {
4524
+ if (tags.length > MAX_TAGS) {
4525
+ throw new CliError("USAGE", `A Skill takes at most ${MAX_TAGS} tags; ${tags.length} were given.`);
4526
+ }
4527
+ const known = new Set(available.map((tag) => tag.id));
4528
+ const unknown = tags.filter((tag) => !known.has(tag));
4529
+ if (unknown.length > 0) {
4530
+ throw new CliError("USAGE", `Unknown tag${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, {
4531
+ hint: `Pick from: ${available.map((tag) => tag.id).join(", ")} (see \`solgrid skills tags\`).`
4532
+ });
4533
+ }
4534
+ }
4535
+ var PUBLIC_DISPLAY = "1";
4536
+ function prepareSkillFile(path, role) {
4537
+ let size;
4538
+ try {
4539
+ const stat = statSync2(path);
4540
+ if (!stat.isFile())
4541
+ throw new Error("not a file");
4542
+ size = stat.size;
4543
+ } catch (cause) {
4544
+ throw new CliError("USAGE", `Cannot read ${path}.`, { cause });
4545
+ }
4546
+ if (size === 0)
4547
+ throw new CliError("USAGE", `${path} is empty.`);
4548
+ const kind = guessKind(path);
4549
+ const allowed = {
4550
+ cover: ["image"],
4551
+ preview: ["image", "video", "audio"],
4552
+ skill: ["skill"]
4553
+ };
4554
+ if (!kind || !allowed[role].includes(kind)) {
4555
+ const expected = role === "skill" ? "a .zip file" : `an ${allowed[role].join(", ")} file`;
4556
+ throw new CliError("USAGE", `The ${role === "skill" ? "Skill package" : role} must be ${expected}: ${basename3(path)}.`);
4557
+ }
4558
+ if (size > SIZE_LIMIT[kind]) {
4559
+ throw new CliError("USAGE", `${basename3(path)} is ${formatBytes(size)}, over the ${formatBytes(SIZE_LIMIT[kind])} limit for ${kind} files.`);
4560
+ }
4561
+ return { kind, size };
4562
+ }
4563
+ var CONTENT_TYPE = {
4564
+ image: {
4565
+ ".png": "image/png",
4566
+ ".jpg": "image/jpeg",
4567
+ ".jpeg": "image/jpeg",
4568
+ ".webp": "image/webp",
4569
+ ".gif": "image/gif"
4570
+ },
4571
+ video: { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" },
4572
+ audio: { ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg" },
4573
+ skill: { ".zip": "application/zip" }
4574
+ };
4575
+ async function uploadSkillFile(client, path, role) {
4576
+ const { kind } = prepareSkillFile(path, role);
4577
+ const form = new FormData;
4578
+ form.append("fileType", String(FILE_TYPE_CODE[kind]));
4579
+ form.append("purpose", PUBLIC_DISPLAY);
4580
+ const type = CONTENT_TYPE[kind][extname2(path).toLowerCase()] ?? "application/octet-stream";
4581
+ form.append("file", await openAsBlob2(path, { type }), basename3(path));
4582
+ const data = await client.upload("/v1/files/upload", form);
4583
+ return expectShape("/v1/files/upload", data, { fileAssetId: isString });
4584
+ }
4585
+ function openSkillPackage(client, slug) {
4586
+ return client.openDownload(`/v1/skills/${encodeURIComponent(slug)}/content`);
4587
+ }
4588
+
4589
+ // src/commands/skills.ts
4590
+ var MAX_INLINE_DESCRIPTION_CHARS = 1500;
4591
+ function parseKind2(value) {
4592
+ const kind = kindName(value);
4593
+ if (!kind)
4594
+ throw new InvalidArgumentError(`must be one of ${SKILL_KINDS.join(", ")}.`);
4595
+ return kind;
4596
+ }
4597
+ function parseSlug(value) {
4598
+ if (!isSkillName(value)) {
4599
+ throw new InvalidArgumentError('use 1–64 lowercase letters, digits or hyphens, not starting or ending with a hyphen and without "--".');
4600
+ }
4601
+ return value;
4602
+ }
4603
+ function collect2(value, previous = []) {
4604
+ return [...previous, value];
4605
+ }
4606
+ function addContentOptions(command, required) {
4607
+ const note = required ? "" : " (replaces the current one)";
4608
+ 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}`);
4609
+ return command;
4610
+ }
4611
+ function readDescription(opts) {
4612
+ if (opts.description !== undefined && opts.descriptionFile !== undefined) {
4613
+ throw new CliError("USAGE", "Use either --description or --description-file, not both.");
4614
+ }
4615
+ if (opts.descriptionFile === undefined)
4616
+ return opts.description;
4617
+ try {
4618
+ return readFileSync4(opts.descriptionFile, "utf8");
4619
+ } catch (cause) {
4620
+ throw new CliError("USAGE", `Cannot read ${opts.descriptionFile}.`, { cause });
4621
+ }
4622
+ }
4623
+ function aSkill(kind) {
4624
+ return `${kind === "image" || kind === "audio" ? "An" : "A"} ${kind} Skill`;
4625
+ }
4626
+ function checkMedia(kind, hasCover, hasPreview) {
4627
+ if (kind !== "video" && !hasCover) {
4628
+ throw new CliError("USAGE", `${aSkill(kind)} needs a cover image.`, {
4629
+ hint: "Pass --cover <image>."
4630
+ });
4631
+ }
4632
+ if ((kind === "video" || kind === "audio") && !hasPreview) {
4633
+ throw new CliError("USAGE", `${aSkill(kind)} needs a preview.`, {
4634
+ hint: `Pass --preview <${kind} or image file>.`
4635
+ });
4636
+ }
4637
+ }
4638
+ function pickTarget(opts) {
4639
+ if (opts.slug === undefined === (opts.id === undefined)) {
4640
+ throw new CliError("USAGE", "Pass exactly one of --slug or --id.");
4641
+ }
4642
+ return opts.slug !== undefined ? { slug: opts.slug } : { id: opts.id };
4643
+ }
4644
+ function targetName(target) {
4645
+ return "slug" in target ? target.slug : target.id;
4646
+ }
4647
+ function isApiNotFound(error) {
4648
+ return error instanceof ApiError && error.code === "NOT_FOUND" && !error.routeMissing;
4649
+ }
4650
+ async function findSkill(client, target) {
4651
+ try {
4652
+ return await getSkill(client, target);
4653
+ } catch (error) {
4654
+ if (!isApiNotFound(error))
4655
+ throw error;
4656
+ throw new CliError("NOT_FOUND", `No Skill with ${"slug" in target ? "slug" : "ID"} "${targetName(target)}".`, {
4657
+ hint: "Search with `solgrid skills list -q <words>`, or list yours with `solgrid skills list --mine`.",
4658
+ cause: error
4659
+ });
4660
+ }
4661
+ }
4662
+ function notYours(error, slug) {
4663
+ if (!isApiNotFound(error))
4664
+ return error;
4665
+ return new CliError("NOT_FOUND", `Skill ${slug} is not one of yours.`, {
4666
+ hint: "Only your own Skills can be changed. List them with `solgrid skills list --mine`.",
4667
+ cause: error
4668
+ });
4669
+ }
4670
+ function skillPackage(opts) {
4671
+ if (opts.dir !== undefined && opts.zip !== undefined) {
4672
+ throw new CliError("USAGE", "Use either --dir or --zip, not both.");
4673
+ }
4674
+ if (opts.dir !== undefined)
4675
+ return packSkillDirectory(opts.dir);
4676
+ if (opts.zip !== undefined) {
4677
+ prepareSkillFile(opts.zip, "skill");
4678
+ return { path: opts.zip, cleanup: () => {} };
4679
+ }
4680
+ return;
4681
+ }
4682
+ function slugFor(pkg, opts) {
4683
+ const { slug } = opts;
4684
+ if (pkg.name === undefined) {
4685
+ if (slug === undefined) {
4686
+ throw new CliError("USAGE", opts.zip !== undefined ? "Pass --slug with --zip." : "Could not read the name in SKILL.md; pass --slug.", {
4687
+ 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."
4688
+ });
4689
+ }
4690
+ return slug;
4691
+ }
4692
+ if (slug !== undefined && slug !== pkg.name)
4693
+ throw nameMismatch(pkg.name, slug);
4694
+ return pkg.name;
4695
+ }
4696
+ function nameMismatch(name, slug) {
4697
+ 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.` });
4698
+ }
4699
+ async function checkTagOptions(client, out, tags) {
4700
+ if (tags === undefined)
4701
+ return;
4702
+ if (tags.length > MAX_TAGS)
4703
+ checkTags(tags, []);
4704
+ out.progress.update("Checking tags…");
4705
+ checkTags(tags, await listSkillTags(client));
4706
+ }
4707
+ async function confirmOrRefuse(ctx, out, opts, question, refusal) {
4708
+ if (opts.yes)
4709
+ return true;
4710
+ if (opts.json || !ctx.stdinIsTTY) {
4711
+ throw new CliError("USAGE", refusal, { hint: "Pass --yes to confirm in non-interactive use." });
4712
+ }
4713
+ out.progress.stop();
4714
+ return ctx.confirm(question);
4715
+ }
4716
+ async function uploadIfGiven(client, out, path, role) {
4717
+ if (path === undefined)
4718
+ return;
4719
+ out.progress.update(`Uploading ${path}…`);
4720
+ return (await uploadSkillFile(client, path, role)).fileAssetId;
4721
+ }
4722
+ function registerSkillsCommand(program, ctx) {
4723
+ const skills = program.command("skills").description("Find, publish and manage Skills on SkillHub.").addHelpText("after", `
4724
+ A Skill is public as soon as it is created. Its slug identifies it everywhere and cannot
4725
+ be changed later.`);
4726
+ 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) => {
4727
+ const client = apiClient(ctx);
4728
+ out.progress.start("Searching Skills…");
4729
+ const data = await listSkills(client, {
4730
+ ...opts.mine ? { mine: true } : {},
4731
+ ...opts.query ? { keyword: opts.query } : {},
4732
+ ...opts.kind ? { kind: opts.kind } : {},
4733
+ ...opts.limit !== undefined ? { pageSize: opts.limit } : {},
4734
+ ...opts.page !== undefined ? { page: opts.page } : {}
4735
+ });
4736
+ out.result(data, () => {
4737
+ if (data.items.length === 0) {
4738
+ 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.";
4739
+ }
4740
+ const lines = [
4741
+ renderTable(["ID", "TITLE", "KIND", "AUTHOR", "UPDATED"], data.items.map((skill) => [
4742
+ skill.id,
4743
+ truncate(skill.title, 40),
4744
+ kindName(skill.kind) ?? "-",
4745
+ skill.author?.nickname || "-",
4746
+ formatTime(skill.updatedAt ?? skill.createdAt)
4747
+ ]), { formatHeader: (text) => out.style("dim", text) })
4748
+ ];
4749
+ const shown = (data.pageIndex - 1) * data.pageSize + data.items.length;
4750
+ if (shown < data.total) {
4751
+ lines.push("", out.style("dim", `${shown} of ${data.total}. Next page: add --page ${data.pageIndex + 1}`));
4752
+ }
4753
+ lines.push("", out.style("dim", "Details: solgrid skills get -i <id>"));
4754
+ return lines.join(`
4755
+ `);
4756
+ });
4757
+ }));
4758
+ 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) => {
4759
+ const target = pickTarget(opts);
4760
+ const client = apiClient(ctx);
4761
+ out.progress.start("Fetching Skill…");
4762
+ const skill = await findSkill(client, target);
4763
+ out.result(skill, () => renderSkill(skill, out.style));
4764
+ }));
4765
+ 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) => {
4766
+ const client = apiClient(ctx);
4767
+ out.progress.start("Fetching tags…");
4768
+ const tags = await listSkillTags(client);
4769
+ out.result({ tags }, () => [
4770
+ renderTable(["ID", "NAME"], tags.map((tag) => [tag.id, tag.name]), { formatHeader: (text) => out.style("dim", text) }),
4771
+ "",
4772
+ out.style("dim", "Use the ID with --tag, e.g. --tag cat_anime.")
4773
+ ].join(`
4774
+ `));
4775
+ }));
4776
+ 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) => {
4777
+ const description = readDescription(opts);
4778
+ const missing = [
4779
+ ["--title", opts.title],
4780
+ ["--kind", opts.kind],
4781
+ ["--description or --description-file", description],
4782
+ ["--dir or --zip", opts.dir ?? opts.zip]
4783
+ ].filter(([, value]) => !value).map(([name]) => name);
4784
+ if (missing.length > 0)
4785
+ throw new CliError("USAGE", `Missing ${missing.join(", ")}.`);
4786
+ const kind = opts.kind;
4787
+ checkMedia(kind, opts.cover !== undefined, opts.preview !== undefined);
4788
+ if (opts.cover)
4789
+ prepareSkillFile(opts.cover, "cover");
4790
+ if (opts.preview)
4791
+ prepareSkillFile(opts.preview, "preview");
4792
+ const pkg = skillPackage(opts);
4793
+ try {
4794
+ const slug = slugFor(pkg, opts);
4795
+ const client = apiClient(ctx);
4796
+ out.progress.start("Checking…");
4797
+ await checkTagOptions(client, out, opts.tag);
4798
+ const confirmed = await confirmOrRefuse(ctx, out, opts, `Publish "${opts.title}" (${slug}) publicly on SkillHub?`, "Refusing to publish a Skill without confirmation.");
4799
+ if (!confirmed) {
4800
+ out.result({ slug, created: false }, () => "Cancelled.");
4801
+ return;
4802
+ }
4803
+ out.progress.start("Uploading…");
4804
+ const content = {
4805
+ title: opts.title,
4806
+ kind,
4807
+ description,
4808
+ tags: opts.tag ?? [],
4809
+ skillFileAssetId: await uploadIfGiven(client, out, pkg.path, "skill"),
4810
+ coverFileAssetId: await uploadIfGiven(client, out, opts.cover, "cover") ?? null,
4811
+ previewFileAssetId: await uploadIfGiven(client, out, opts.preview, "preview") ?? null
4812
+ };
4813
+ out.progress.update("Publishing…");
4814
+ const { id } = await createSkill(client, slug, content).catch((error) => {
4815
+ if (error instanceof ApiError && error.code === "CONFLICT") {
4816
+ throw new CliError("CONFLICT", `The slug "${slug}" is already taken.`, {
4817
+ hint: "Choose another name in SKILL.md (and --slug).",
4818
+ cause: error
4819
+ });
4820
+ }
4821
+ throw error;
4822
+ });
4823
+ out.result({ id, slug, created: true }, () => [
4824
+ `Published "${content.title}" as ${slug}.`,
4825
+ ...renderFields([["ID", id]], out.style),
4826
+ "",
4827
+ out.style("dim", `See it with: solgrid skills get -s ${slug}`)
4828
+ ].join(`
4829
+ `));
4830
+ } finally {
4831
+ pkg.cleanup();
4832
+ }
4833
+ }));
4834
+ 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) => {
4835
+ const target = pickTarget(opts);
4836
+ const description = readDescription(opts);
4837
+ if (opts.clearTags && opts.tag !== undefined) {
4838
+ throw new CliError("USAGE", "Use either --tag or --clear-tags, not both.");
4839
+ }
4840
+ const tags = opts.clearTags ? [] : opts.tag;
4841
+ if (opts.clearPreview && opts.preview !== undefined) {
4842
+ throw new CliError("USAGE", "Use either --preview or --clear-preview, not both.");
4843
+ }
4844
+ const changes = [
4845
+ opts.title,
4846
+ opts.kind,
4847
+ description,
4848
+ tags,
4849
+ opts.cover,
4850
+ opts.preview,
4851
+ opts.clearPreview,
4852
+ opts.dir,
4853
+ opts.zip
4854
+ ];
4855
+ if (changes.every((value) => value === undefined)) {
4856
+ throw new CliError("USAGE", "Nothing to change.", {
4857
+ hint: "Pass at least one of --title, --kind, --description, --tag, --clear-tags, --cover, --preview, --clear-preview, --dir or --zip."
4858
+ });
4859
+ }
4860
+ if (opts.cover)
4861
+ prepareSkillFile(opts.cover, "cover");
4862
+ if (opts.preview)
4863
+ prepareSkillFile(opts.preview, "preview");
4864
+ const pkg = skillPackage(opts);
4865
+ try {
4866
+ const client = apiClient(ctx);
4867
+ out.progress.start("Fetching Skill…");
4868
+ const skill = await findSkill(client, target);
4869
+ if (pkg?.name !== undefined && pkg.name !== skill.slug) {
4870
+ throw nameMismatch(pkg.name, skill.slug);
4871
+ }
4872
+ const next = {
4873
+ ...contentOf(skill),
4874
+ ...opts.title !== undefined ? { title: opts.title } : {},
4875
+ ...opts.kind !== undefined ? { kind: opts.kind } : {},
4876
+ ...description !== undefined ? { description } : {},
4877
+ ...tags !== undefined ? { tags } : {},
4878
+ ...opts.clearPreview ? { previewFileAssetId: null } : {}
4879
+ };
4880
+ if (opts.clearPreview && (next.kind === "video" || next.kind === "audio")) {
4881
+ throw new CliError("USAGE", `${aSkill(next.kind)} needs a preview; it cannot be removed.`, {
4882
+ hint: "Replace it with --preview <file> instead."
4883
+ });
4884
+ }
4885
+ checkMedia(next.kind, Boolean(opts.cover ?? next.coverFileAssetId), Boolean(opts.preview ?? next.previewFileAssetId));
4886
+ await checkTagOptions(client, out, opts.tag);
4887
+ next.skillFileAssetId = await uploadIfGiven(client, out, pkg?.path, "skill") ?? next.skillFileAssetId;
4888
+ next.coverFileAssetId = await uploadIfGiven(client, out, opts.cover, "cover") ?? next.coverFileAssetId;
4889
+ next.previewFileAssetId = await uploadIfGiven(client, out, opts.preview, "preview") ?? next.previewFileAssetId;
4890
+ out.progress.update("Saving…");
4891
+ await updateSkill(client, skill.id, next).catch((error) => {
4892
+ throw notYours(error, skill.slug);
4893
+ });
4894
+ out.result({ id: skill.id, slug: skill.slug, updated: true }, () => `Updated Skill ${skill.slug}.`);
4895
+ } finally {
4896
+ pkg?.cleanup();
4897
+ }
4898
+ }));
4899
+ 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) => {
4900
+ const target = pickTarget(opts);
4901
+ const client = apiClient(ctx);
4902
+ out.progress.start("Finding Skill…");
4903
+ const { id, slug } = await findSkill(client, target);
4904
+ 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.");
4905
+ if (!confirmed) {
4906
+ out.result({ id, slug, deleted: false }, () => "Cancelled.");
4907
+ return;
4908
+ }
4909
+ out.progress.start("Deleting…");
4910
+ await deleteSkill(client, id).catch((error) => {
4911
+ throw notYours(error, slug);
4912
+ });
4913
+ out.result({ id, slug, deleted: true }, () => `Deleted Skill ${slug}.`);
4914
+ }));
4915
+ 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) => {
4916
+ const client = apiClient(ctx);
4917
+ out.progress.start("Downloading…");
4918
+ const response = await openSkillPackage(client, opts.slug);
4919
+ const destination = opts.output ?? filenameFromDisposition(response.headers.get("content-disposition")) ?? `${opts.slug}.zip`;
4920
+ if (existsSync3(destination) && !opts.force) {
4921
+ await response.body?.cancel();
4922
+ throw new CliError("USAGE", `${destination} already exists.`, {
4923
+ hint: "Pass --force to overwrite it, or choose another path with -o."
4924
+ });
4925
+ }
4926
+ if (!response.body)
4927
+ throw new CliError("INVALID_RESPONSE", "The download has no content.");
4928
+ const bytes = await saveDownload(response.body, destination, {
4929
+ overwrite: opts.force === true
4930
+ });
4931
+ out.result({ slug: opts.slug, path: destination, sizeBytes: bytes }, () => `Saved ${destination} (${formatBytes(bytes)}).`);
4932
+ }));
4933
+ }
4934
+ function renderSkill(skill, style) {
4935
+ const lines = [
4936
+ style("bold", skill.title),
4937
+ "",
4938
+ ...renderFields([
4939
+ ["Slug", skill.slug],
4940
+ ["ID", skill.id],
4941
+ ["Kind", kindName(skill.kind)],
4942
+ ["Author", skill.author?.nickname || undefined],
4943
+ ["Tags", skill.tags?.length ? skill.tags.join(", ") : undefined],
4944
+ ["Created", skill.createdAt ? formatTime(skill.createdAt) : undefined],
4945
+ ["Updated", skill.updatedAt ? formatTime(skill.updatedAt) : undefined]
4946
+ ], style)
4947
+ ];
4948
+ if (skill.description) {
4949
+ const text = skill.description.trim();
4950
+ lines.push("", text.length > MAX_INLINE_DESCRIPTION_CHARS ? `${text.slice(0, MAX_INLINE_DESCRIPTION_CHARS)}
4951
+ ${style("dim", `… ${text.length - MAX_INLINE_DESCRIPTION_CHARS} more characters; use --json for the full description.`)}` : text);
4952
+ }
4953
+ lines.push("", style("dim", `Download it with: solgrid skills download -s ${skill.slug}`));
4954
+ return lines.join(`
4955
+ `);
4956
+ }
4957
+
3866
4958
  // src/output/price.ts
3867
4959
  function periodText(period) {
3868
4960
  const unit = period.unit.toLowerCase();
@@ -3899,7 +4991,80 @@ function describeLeaf(leaf) {
3899
4991
  const text = withUnit(formatAmount(leaf.amount), unitText(leaf));
3900
4992
  return leaf.flatFee ? `${text} + ${formatAmount(leaf.flatFee)} flat` : text;
3901
4993
  }
3902
- function summarizePrice(price) {
4994
+ var METER_UNITS = {
4995
+ Calls: ["call", "calls"],
4996
+ WebSearchCalls: ["web search", "web searches"],
4997
+ InputImages: ["input image", "input images"],
4998
+ OutputImages: ["output image", "output images"],
4999
+ InputVideoSeconds: ["second of input video", "seconds of input video"],
5000
+ OutputVideoSeconds: ["second of output video", "seconds of output video"],
5001
+ TotalVideoSeconds: ["second of video", "seconds of video"],
5002
+ BilledTokens: ["token", "tokens"],
5003
+ Token: ["token", "tokens"],
5004
+ BilledCharacters: ["character", "characters"],
5005
+ Characters: ["character", "characters"],
5006
+ Credits: ["credit", "credits"],
5007
+ ProviderUnits: ["Unit", "Units"],
5008
+ PROVIDER_UNIT: ["Unit", "Units"],
5009
+ Unit: ["Unit", "Units"]
5010
+ };
5011
+ function meterText(meter, size = 1) {
5012
+ const [one, many] = METER_UNITS[meter] ?? [meter, meter];
5013
+ return size === 1 ? `per ${one}` : `per ${countText(size)} ${many}`;
5014
+ }
5015
+ function rateAmount(rate) {
5016
+ const micros = Math.ceil(Number((rate.unitPriceUsd * 1e6).toPrecision(12)));
5017
+ return formatAmount({ value: (micros / 1e6).toFixed(6), currency: "USD" });
5018
+ }
5019
+ function describeRate(rate) {
5020
+ return `${rateAmount(rate)} ${meterText(rate.meter, rate.unitSize)}`;
5021
+ }
5022
+ function ratesByMeter(rates) {
5023
+ const groups = new Map;
5024
+ for (const rate of rates)
5025
+ groups.set(rate.meter, [...groups.get(rate.meter) ?? [], rate]);
5026
+ return [...groups.values()];
5027
+ }
5028
+ function perUnit(rate) {
5029
+ return rate.unitPriceUsd / (rate.unitSize || 1);
5030
+ }
5031
+ function hasConditions(rate) {
5032
+ return Boolean(rate.conditions && Object.keys(rate.conditions).length > 0);
5033
+ }
5034
+ function summarizeRates(price, rates) {
5035
+ const only = rates.length === 1 ? rates[0] : undefined;
5036
+ if (only) {
5037
+ const amount = price.amount ? formatAmount(price.amount) : rateAmount(only);
5038
+ const text = `${amount} ${meterText(only.meter, only.unitSize)}`;
5039
+ return hasConditions(only) ? `${text} (${conditionsText(only)})` : text;
5040
+ }
5041
+ return ratesByMeter(rates).map((group) => {
5042
+ const cheapest = group.reduce((a, b) => perUnit(b) < perUnit(a) ? b : a);
5043
+ const varies = new Set(group.map(describeRate)).size > 1;
5044
+ return `${varies ? "from " : ""}${describeRate(cheapest)}`;
5045
+ }).join(" + ");
5046
+ }
5047
+ function rateNotes(rate) {
5048
+ const notes = [];
5049
+ if (rate.freeUnits)
5050
+ notes.push(`first ${rate.freeUnits} free`);
5051
+ if (typeof rate.maximumUnits === "number")
5052
+ notes.push(`billed up to ${rate.maximumUnits}`);
5053
+ if (rate.roundUpTo)
5054
+ notes.push(`rounded up to ${rate.roundUpTo}`);
5055
+ return notes.join("; ");
5056
+ }
5057
+ function conditionsText(rate) {
5058
+ const conditions = rate.conditions && typeof rate.conditions === "object" ? rate.conditions : {};
5059
+ return whenText(conditions) || "-";
5060
+ }
5061
+ function summarizePrice(price, fallback) {
5062
+ if (!price)
5063
+ return fallback || "-";
5064
+ if (price.rates?.length)
5065
+ return summarizeRates(price, price.rates);
5066
+ if (!price.amount)
5067
+ return fallback || "-";
3903
5068
  let text;
3904
5069
  switch (price.type) {
3905
5070
  case "PER_UNIT_MATRIX": {
@@ -3925,9 +5090,41 @@ function summarizePrice(price) {
3925
5090
  function whenText(when) {
3926
5091
  return Object.entries(when).map(([key, value]) => `${key}=${String(value)}`).join(", ");
3927
5092
  }
3928
- function priceDetail(price) {
5093
+ function priceDetail(price, fallback) {
3929
5094
  const rows = [];
3930
5095
  const lines = [];
5096
+ const summary = summarizePrice(price, fallback);
5097
+ if (!price)
5098
+ return { summary, rows, lines };
5099
+ const rates = price.rates ?? [];
5100
+ if (rates.length > 1 || rates.some(hasConditions)) {
5101
+ const notes = rates.map(rateNotes);
5102
+ const withNotes = notes.some(Boolean);
5103
+ rows.push(["CHARGE", "CONDITIONS", "PRICE", ...withNotes ? ["NOTES"] : []]);
5104
+ rates.forEach((rate, index) => {
5105
+ const [one] = METER_UNITS[rate.meter] ?? [rate.meter];
5106
+ rows.push([
5107
+ one,
5108
+ conditionsText(rate),
5109
+ describeRate(rate),
5110
+ ...withNotes ? [notes[index] || "-"] : []
5111
+ ]);
5112
+ });
5113
+ if (ratesByMeter(rates).length > 1) {
5114
+ lines.push("Different charges add up; for each charge, the row matching the request applies.");
5115
+ } else if (rates.length > 1) {
5116
+ lines.push("The row matching the request applies.");
5117
+ } else {
5118
+ lines.push("This price applies when the request matches its conditions.");
5119
+ }
5120
+ } else if (rates[0] && rateNotes(rates[0])) {
5121
+ lines.push(rateNotes(rates[0]));
5122
+ }
5123
+ if (price.description)
5124
+ lines.push(price.description);
5125
+ if (rates.length > 0) {
5126
+ lines.push("The final cost depends on actual usage: see `cost` in `solgrid runs get`.");
5127
+ }
3931
5128
  if (price.type === "PER_UNIT_MATRIX" && price.variants?.length) {
3932
5129
  const keys = price.selectors?.map((s) => s.key) ?? Object.keys(price.variants[0]?.when ?? {});
3933
5130
  const labels = keys.map((key) => price.selectors?.find((s) => s.key === key)?.label ?? key);
@@ -3944,11 +5141,11 @@ function priceDetail(price) {
3944
5141
  }
3945
5142
  for (const note of price.notes ?? [])
3946
5143
  lines.push(note);
3947
- return { summary: summarizePrice(price), rows, lines };
5144
+ return { summary, rows, lines };
3948
5145
  }
3949
5146
 
3950
5147
  // src/output/schema.ts
3951
- var MAX_DEPTH = 2;
5148
+ var MAX_DEPTH2 = 2;
3952
5149
  var MAX_ENUM_VALUES = 6;
3953
5150
  function isSchema(value) {
3954
5151
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -3993,7 +5190,7 @@ function schemaFields(schema, prefix = "", depth = 0) {
3993
5190
  required: required.has(name),
3994
5191
  description: describe(property)
3995
5192
  });
3996
- if (property.type === "object" && depth + 1 < MAX_DEPTH) {
5193
+ if (property.type === "object" && depth + 1 < MAX_DEPTH2) {
3997
5194
  fields.push(...schemaFields(property, `${path}.`, depth + 1));
3998
5195
  }
3999
5196
  }
@@ -4021,7 +5218,7 @@ function registerToolCommands(program, ctx) {
4021
5218
  const table = renderTable(["PROVIDER", "ENDPOINT", "PRICE", "HEALTH", "DESCRIPTION"], data.results.map((r) => [
4022
5219
  r.provider,
4023
5220
  r.endpoint,
4024
- summarizePrice(r.price),
5221
+ truncate(summarizePrice(r.price, r.externalPrice), 60),
4025
5222
  formatHealth(r.metrics, out.style),
4026
5223
  truncate(r.description, 60)
4027
5224
  ]), { formatHeader: (text) => out.style("dim", text) });
@@ -4045,7 +5242,7 @@ function registerToolCommands(program, ctx) {
4045
5242
  }));
4046
5243
  }
4047
5244
  function renderInspect(tool, style) {
4048
- const price = priceDetail(tool.price);
5245
+ const price = priceDetail(tool.price, tool.externalPrice);
4049
5246
  const runtime = tool.metrics?.runTimeMs;
4050
5247
  const lines = [
4051
5248
  style("bold", `${tool.providerName || tool.provider} · ${tool.endpoint}`),
@@ -4119,6 +5316,7 @@ function createProgram(ctx, options = {}) {
4119
5316
  registerRunCommand(program, ctx);
4120
5317
  registerRunsCommand(program, ctx);
4121
5318
  registerFilesCommand(program, ctx);
5319
+ registerSkillsCommand(program, ctx);
4122
5320
  registerAccountCommands(program, ctx);
4123
5321
  registerKeysCommand(program, ctx);
4124
5322
  registerSessionIdCommand(program, ctx);