@lumerahq/cli 0.30.20 → 0.31.0-dev.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.
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  createApiClient,
6
6
  isApiErrorStatus
7
- } from "./chunk-3JDRRFNT.js";
7
+ } from "./chunk-ZJ7M7JD3.js";
8
8
  import {
9
9
  findProjectRoot,
10
10
  getAppName
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  userContextHeaders
3
- } from "./chunk-3JDRRFNT.js";
3
+ } from "./chunk-ZJ7M7JD3.js";
4
4
  import {
5
5
  fetchWithRetry
6
6
  } from "./chunk-FJFIWC7G.js";
@@ -327,6 +327,12 @@ var ApiClient = class {
327
327
  }
328
328
  return all;
329
329
  }
330
+ async getAgentSkill(id, opts) {
331
+ const params = new URLSearchParams();
332
+ if (opts?.resource_urls) params.set("resource_urls", opts.resource_urls);
333
+ const query = params.toString();
334
+ return this.request(`/api/lm_agent_skills/${encodeURIComponent(id)}${query ? `?${query}` : ""}`);
335
+ }
330
336
  async createAgentSkill(def) {
331
337
  return this.request("/api/lm_agent_skills", {
332
338
  method: "POST",
@@ -342,6 +348,24 @@ var ApiClient = class {
342
348
  async deleteAgentSkill(id) {
343
349
  await this.request(`/api/lm_agent_skills/${id}`, { method: "DELETE" });
344
350
  }
351
+ async startAgentSkillResourceUpload(id, resource) {
352
+ return this.request(`/api/lm_agent_skills/${encodeURIComponent(id)}/resource-uploads`, {
353
+ method: "POST",
354
+ body: JSON.stringify(resource)
355
+ });
356
+ }
357
+ async completeAgentSkillResourceUpload(id, uploadId, resource) {
358
+ return this.request(`/api/lm_agent_skills/${encodeURIComponent(id)}/resource-uploads/${encodeURIComponent(uploadId)}/complete`, {
359
+ method: "POST",
360
+ body: JSON.stringify(resource)
361
+ });
362
+ }
363
+ async deleteAgentSkillResource(id, path) {
364
+ return this.request(`/api/lm_agent_skills/${encodeURIComponent(id)}/resources`, {
365
+ method: "PATCH",
366
+ body: JSON.stringify({ operation: "delete", path })
367
+ });
368
+ }
345
369
  // Resolve a list of refs (slugs or IDs) to skill records. Chunks
346
370
  // requests so callers don't have to think about the server-side
347
371
  // per-request cap.
@@ -2,9 +2,9 @@ import {
2
2
  deps,
3
3
  syncDeps,
4
4
  syncResourceShareDeps
5
- } from "./chunk-BRUU7JY3.js";
5
+ } from "./chunk-HS63O4MH.js";
6
6
  import "./chunk-2CR762KB.js";
7
- import "./chunk-3JDRRFNT.js";
7
+ import "./chunk-ZJ7M7JD3.js";
8
8
  import "./chunk-JLVVHTBY.js";
9
9
  import "./chunk-FJFIWC7G.js";
10
10
  import "./chunk-PNKVD2UK.js";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  dev
3
- } from "./chunk-HFNPBTT3.js";
3
+ } from "./chunk-IVII7OZZ.js";
4
4
  import {
5
5
  syncDeps
6
- } from "./chunk-BRUU7JY3.js";
6
+ } from "./chunk-HS63O4MH.js";
7
7
  import {
8
8
  loadEnv
9
9
  } from "./chunk-2CR762KB.js";
10
10
  import {
11
11
  createApiClient
12
- } from "./chunk-3JDRRFNT.js";
12
+ } from "./chunk-ZJ7M7JD3.js";
13
13
  import {
14
14
  findProjectRoot,
15
15
  getApiUrl,
package/dist/index.js CHANGED
@@ -240,32 +240,32 @@ async function main() {
240
240
  switch (command) {
241
241
  // Resource commands
242
242
  case "plan":
243
- await import("./resources-3MNMQI3N.js").then((m) => m.plan(args.slice(1)));
243
+ await import("./resources-GM2OWDKP.js").then((m) => m.plan(args.slice(1)));
244
244
  break;
245
245
  case "apply":
246
- await import("./resources-3MNMQI3N.js").then((m) => m.apply(args.slice(1)));
246
+ await import("./resources-GM2OWDKP.js").then((m) => m.apply(args.slice(1)));
247
247
  break;
248
248
  case "pull":
249
- await import("./resources-3MNMQI3N.js").then((m) => m.pull(args.slice(1)));
249
+ await import("./resources-GM2OWDKP.js").then((m) => m.pull(args.slice(1)));
250
250
  break;
251
251
  case "destroy":
252
- await import("./resources-3MNMQI3N.js").then((m) => m.destroy(args.slice(1)));
252
+ await import("./resources-GM2OWDKP.js").then((m) => m.destroy(args.slice(1)));
253
253
  break;
254
254
  case "list":
255
- await import("./resources-3MNMQI3N.js").then((m) => m.list(args.slice(1)));
255
+ await import("./resources-GM2OWDKP.js").then((m) => m.list(args.slice(1)));
256
256
  break;
257
257
  case "show":
258
- await import("./resources-3MNMQI3N.js").then((m) => m.show(args.slice(1)));
258
+ await import("./resources-GM2OWDKP.js").then((m) => m.show(args.slice(1)));
259
259
  break;
260
260
  case "diff":
261
- await import("./resources-3MNMQI3N.js").then((m) => m.diff(args.slice(1)));
261
+ await import("./resources-GM2OWDKP.js").then((m) => m.diff(args.slice(1)));
262
262
  break;
263
263
  // Development
264
264
  case "dev":
265
- await import("./dev-H6NSDTAA.js").then((m) => m.dev(args.slice(1)));
265
+ await import("./dev-7ZQRZJXX.js").then((m) => m.dev(args.slice(1)));
266
266
  break;
267
267
  case "run":
268
- await import("./run-J3XKXSV7.js").then((m) => m.run(args.slice(1)));
268
+ await import("./run-7KJPBOIY.js").then((m) => m.run(args.slice(1)));
269
269
  break;
270
270
  case "functions":
271
271
  await import("./functions-WPHGKE54.js").then(
@@ -279,10 +279,10 @@ async function main() {
279
279
  break;
280
280
  // Project
281
281
  case "init":
282
- await import("./init-54CLJUYA.js").then((m) => m.init(args.slice(1)));
282
+ await import("./init-WE722M4X.js").then((m) => m.init(args.slice(1)));
283
283
  break;
284
284
  case "register":
285
- await import("./register-FNG4ZVDW.js").then((m) => m.register(args.slice(1)));
285
+ await import("./register-ZXNNU3NJ.js").then((m) => m.register(args.slice(1)));
286
286
  break;
287
287
  case "templates":
288
288
  await import("./templates-LNUOTNLN.js").then((m) => m.templates(subcommand, args.slice(2)));
@@ -299,7 +299,7 @@ async function main() {
299
299
  break;
300
300
  // Dependencies
301
301
  case "deps":
302
- await import("./deps-GL2XDDPO.js").then((m) => m.deps(args.slice(1)));
302
+ await import("./deps-SIZ77NYE.js").then((m) => m.deps(args.slice(1)));
303
303
  break;
304
304
  // Feature flags (read the in-sandbox snapshot)
305
305
  case "flags":
@@ -12,7 +12,7 @@ import {
12
12
  import {
13
13
  createApiClient,
14
14
  validateProjectNameLength
15
- } from "./chunk-3JDRRFNT.js";
15
+ } from "./chunk-ZJ7M7JD3.js";
16
16
  import {
17
17
  getToken,
18
18
  init_auth,
@@ -7,7 +7,7 @@ import {
7
7
  import {
8
8
  createApiClient,
9
9
  validateProjectExternalId
10
- } from "./chunk-3JDRRFNT.js";
10
+ } from "./chunk-ZJ7M7JD3.js";
11
11
  import {
12
12
  findProjectRoot,
13
13
  getAppName,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  deploy
3
- } from "./chunk-HFNPBTT3.js";
3
+ } from "./chunk-IVII7OZZ.js";
4
4
  import {
5
5
  syncDeps
6
- } from "./chunk-BRUU7JY3.js";
6
+ } from "./chunk-HS63O4MH.js";
7
7
  import {
8
8
  loadEnv
9
9
  } from "./chunk-2CR762KB.js";
@@ -12,7 +12,7 @@ import {
12
12
  createApiClient,
13
13
  isApiErrorStatus,
14
14
  validateCollectionNameLength
15
- } from "./chunk-3JDRRFNT.js";
15
+ } from "./chunk-ZJ7M7JD3.js";
16
16
  import {
17
17
  findProjectRoot,
18
18
  getApiUrl,
@@ -31,8 +31,9 @@ import "./chunk-PNKVD2UK.js";
31
31
  import pc2 from "picocolors";
32
32
  import prompts from "prompts";
33
33
  import { execFileSync, execSync } from "child_process";
34
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync, mkdirSync } from "fs";
35
- import { join as join2, resolve } from "path";
34
+ import { createHash } from "crypto";
35
+ import { existsSync as existsSync2, lstatSync, mkdirSync, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "fs";
36
+ import { dirname as dirname2, join as join2, resolve } from "path";
36
37
 
37
38
  // src/lib/lint/index.ts
38
39
  import { existsSync, readFileSync, readdirSync } from "fs";
@@ -2643,6 +2644,130 @@ var SKILL_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2643
2644
  var SKILL_NAME_MAX_LENGTH = 100;
2644
2645
  var SKILL_SLUG_MAX_LENGTH = 100;
2645
2646
  var SKILL_SUMMARY_MAX_LENGTH = 500;
2647
+ var SKILL_RESOURCE_MAX_COUNT = 20;
2648
+ var SKILL_RESOURCE_MAX_BYTES = 10 * 1024 * 1024;
2649
+ var SKILL_RESOURCES_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
2650
+ var SKILL_RESOURCE_MAX_PATH_BYTES = 1024;
2651
+ var SKILL_RESOURCE_MAX_PATH_PART_BYTES = 255;
2652
+ var SKILL_RESOURCE_CONTENT_TYPES = {
2653
+ bmp: "image/bmp",
2654
+ gif: "image/gif",
2655
+ jpeg: "image/jpeg",
2656
+ jpg: "image/jpeg",
2657
+ pdf: "application/pdf",
2658
+ png: "image/png",
2659
+ webp: "image/webp"
2660
+ };
2661
+ function skillResourceContentType(path) {
2662
+ return SKILL_RESOURCE_CONTENT_TYPES[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
2663
+ }
2664
+ function skillResourcePathError(value) {
2665
+ if (!value || Buffer.byteLength(value) > SKILL_RESOURCE_MAX_PATH_BYTES) {
2666
+ return `path must be at most ${SKILL_RESOURCE_MAX_PATH_BYTES} bytes`;
2667
+ }
2668
+ if (value.startsWith("/") || value.endsWith("/") || value.includes("\\")) {
2669
+ return "path must be relative and slash-separated";
2670
+ }
2671
+ if (/\p{Cc}/u.test(value)) return "path cannot contain control characters";
2672
+ for (const part of value.split("/")) {
2673
+ if (!part || part === "." || part === "..") return "path cannot contain empty, dot, or parent segments";
2674
+ if (part !== part.trim()) return "path segments cannot start or end with whitespace";
2675
+ if (Buffer.byteLength(part) > SKILL_RESOURCE_MAX_PATH_PART_BYTES) {
2676
+ return `path segments must be at most ${SKILL_RESOURCE_MAX_PATH_PART_BYTES} bytes`;
2677
+ }
2678
+ if (part.toLowerCase() === "skill.md") return "SKILL.md is reserved";
2679
+ }
2680
+ return null;
2681
+ }
2682
+ function skillResourcePathsCollide(left, right) {
2683
+ const leftParts = left.split("/");
2684
+ const rightParts = right.split("/");
2685
+ const limit = Math.min(leftParts.length, rightParts.length);
2686
+ for (let i = 0; i < limit; i++) {
2687
+ if (leftParts[i] === rightParts[i]) continue;
2688
+ return leftParts[i]?.toLowerCase() === rightParts[i]?.toLowerCase();
2689
+ }
2690
+ return true;
2691
+ }
2692
+ function localSkillResources(skillDir) {
2693
+ const resources = [];
2694
+ const errors = [];
2695
+ let totalBytes = 0;
2696
+ let fileCount = 0;
2697
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2698
+ const scan = (directory, parentParts) => {
2699
+ const names = readdirSync2(directory, { encoding: "buffer" }).map((rawName) => {
2700
+ try {
2701
+ return decoder.decode(rawName);
2702
+ } catch {
2703
+ errors.push(`${parentParts.join("/") || "."}: file names must be valid UTF-8`);
2704
+ return null;
2705
+ }
2706
+ }).filter((name) => name !== null).sort();
2707
+ for (const name of names) {
2708
+ if (parentParts.length === 0 && name === "SKILL.md") continue;
2709
+ const parts = [...parentParts, name];
2710
+ const resourcePath = parts.join("/");
2711
+ const filePath = join2(directory, name);
2712
+ const stat = lstatSync(filePath);
2713
+ if (stat.isSymbolicLink()) {
2714
+ errors.push(`${resourcePath}: symlinks are not supported`);
2715
+ continue;
2716
+ }
2717
+ if (stat.isDirectory()) {
2718
+ scan(filePath, parts);
2719
+ continue;
2720
+ }
2721
+ if (!stat.isFile()) {
2722
+ errors.push(`${resourcePath}: only regular files are supported`);
2723
+ continue;
2724
+ }
2725
+ fileCount++;
2726
+ totalBytes += stat.size;
2727
+ const pathError = skillResourcePathError(resourcePath);
2728
+ if (pathError) {
2729
+ errors.push(`${resourcePath}: ${pathError}`);
2730
+ continue;
2731
+ }
2732
+ if (stat.size > SKILL_RESOURCE_MAX_BYTES) {
2733
+ errors.push(`${resourcePath}: file exceeds the 10 MiB limit`);
2734
+ continue;
2735
+ }
2736
+ const conflict = resources.find((resource) => skillResourcePathsCollide(resource.path, resourcePath));
2737
+ if (conflict) {
2738
+ errors.push(`${resourcePath}: path conflicts ignoring letter case with ${conflict.path}`);
2739
+ continue;
2740
+ }
2741
+ const sha256 = createHash("sha256").update(readFileSync2(filePath)).digest("hex");
2742
+ resources.push({ path: resourcePath, filePath, size: stat.size, sha256 });
2743
+ }
2744
+ };
2745
+ scan(skillDir, []);
2746
+ if (fileCount > SKILL_RESOURCE_MAX_COUNT) errors.push(`resources: a skill can have at most ${SKILL_RESOURCE_MAX_COUNT} files`);
2747
+ if (totalBytes > SKILL_RESOURCES_MAX_TOTAL_BYTES) errors.push("resources: files can total at most 100 MiB");
2748
+ return { resources: resources.sort((a, b) => a.path.localeCompare(b.path)), errors };
2749
+ }
2750
+ function compareSkillResources(local, remote) {
2751
+ const localByPath = new Map(local.map((resource) => [resource.path, resource]));
2752
+ const remoteByPath = new Map(remote.map((resource) => [resource.path, resource]));
2753
+ const added = [...localByPath.keys()].filter((path) => !remoteByPath.has(path)).sort();
2754
+ const replaced = [...localByPath].filter(([path, resource]) => {
2755
+ const current = remoteByPath.get(path);
2756
+ return current && (current.size !== resource.size || current.sha256 !== resource.sha256);
2757
+ }).map(([path]) => path).sort();
2758
+ const deleted = [...remoteByPath.keys()].filter((path) => !localByPath.has(path)).sort();
2759
+ return { added, replaced, deleted };
2760
+ }
2761
+ function skillResourceChangesEmpty(changes) {
2762
+ return changes.added.length === 0 && changes.replaced.length === 0 && changes.deleted.length === 0;
2763
+ }
2764
+ function skillResourceChangesSummary(changes) {
2765
+ return [
2766
+ ...changes.added.map((path) => `+${path}`),
2767
+ ...changes.replaced.map((path) => `~${path}`),
2768
+ ...changes.deleted.map((path) => `-${path}`)
2769
+ ].join(", ");
2770
+ }
2646
2771
  function loadLocalSkills(platformDir, filterName) {
2647
2772
  const skillsDir = join2(platformDir, "skills");
2648
2773
  if (!existsSync2(skillsDir)) return [];
@@ -2655,11 +2780,19 @@ function loadLocalSkills(platformDir, filterName) {
2655
2780
  errors.push(`${entry.name}: directory name must be a lowercase slug`);
2656
2781
  continue;
2657
2782
  }
2658
- const path = join2(skillsDir, entry.name, "SKILL.md");
2783
+ const skillDir = join2(skillsDir, entry.name);
2784
+ const path = join2(skillDir, "SKILL.md");
2659
2785
  if (!existsSync2(path)) {
2660
2786
  errors.push(`${entry.name}: missing SKILL.md`);
2661
2787
  continue;
2662
2788
  }
2789
+ const skillMarkdownStat = lstatSync(path);
2790
+ if (skillMarkdownStat.isSymbolicLink() || !skillMarkdownStat.isFile()) {
2791
+ errors.push(`${entry.name}: SKILL.md must be a regular file`);
2792
+ continue;
2793
+ }
2794
+ const { resources, errors: resourceErrors } = localSkillResources(skillDir);
2795
+ errors.push(...resourceErrors.map((error3) => `${entry.name}: ${error3}`));
2663
2796
  const content = readFileSync2(path, "utf-8");
2664
2797
  const separator = /\r?\n---\r?\n/.exec(content);
2665
2798
  const parts = separator ? [content.slice(0, separator.index), content.slice(separator.index + separator[0].length)] : [content];
@@ -2685,7 +2818,7 @@ function loadLocalSkills(platformDir, filterName) {
2685
2818
  if (summary.length > SKILL_SUMMARY_MAX_LENGTH) errors.push(`${entry.name}: summary must be at most ${SKILL_SUMMARY_MAX_LENGTH} characters`);
2686
2819
  if (!instructions) errors.push(`${entry.name}: SKILL.md must include instructions after ---`);
2687
2820
  if (name && name.length <= SKILL_NAME_MAX_LENGTH && summary && summary.length <= SKILL_SUMMARY_MAX_LENGTH && instructions) {
2688
- skills.push({ slug: entry.name, name, summary, instructions });
2821
+ skills.push({ slug: entry.name, name, summary, instructions, resources });
2689
2822
  }
2690
2823
  }
2691
2824
  if (errors.length > 0) {
@@ -2729,6 +2862,13 @@ function remoteSkillsByLocalSlug(remoteSkills, appName) {
2729
2862
  }
2730
2863
  return byLocalSlug;
2731
2864
  }
2865
+ function resolveProjectSkillLocalSlug(remoteSkills, selector) {
2866
+ if (!selector) return void 0;
2867
+ for (const [localSlug, skill] of remoteSkills) {
2868
+ if (selector === localSlug || selector === skill.slug || selector === skill.name) return localSlug;
2869
+ }
2870
+ return selector;
2871
+ }
2732
2872
  function projectSkillRefCandidates(refs, appName) {
2733
2873
  return [...new Set(refs.flatMap((ref) => appName && SKILL_SLUG_RE.test(ref) ? [scopedProjectSkillSlug(appName, ref), ref] : [ref]))];
2734
2874
  }
@@ -2795,13 +2935,24 @@ async function planSkills(api, localSkills, projectId, appName) {
2795
2935
  for (const skill of localSkills) {
2796
2936
  const remote = remoteBySlug.get(skill.slug);
2797
2937
  if (!remote) {
2798
- changes.push({ type: "create", resource: "skill", id: skill.slug, name: skill.name });
2938
+ const resourceSummary = skillResourceChangesSummary(compareSkillResources(skill.resources, []));
2939
+ changes.push({
2940
+ type: "create",
2941
+ resource: "skill",
2942
+ id: skill.slug,
2943
+ name: skill.name,
2944
+ details: resourceSummary ? `resources (${resourceSummary})` : void 0
2945
+ });
2799
2946
  continue;
2800
2947
  }
2801
2948
  const changed = [];
2802
2949
  if (remote.name !== skill.name) changed.push("name");
2803
2950
  if ((remote.summary || "").trim() !== skill.summary.trim()) changed.push("summary");
2804
2951
  if ((remote.instructions || "").trim() !== skill.instructions.trim()) changed.push("instructions");
2952
+ const resourceChanges = compareSkillResources(skill.resources, remote.resources ?? []);
2953
+ if (!skillResourceChangesEmpty(resourceChanges)) {
2954
+ changed.push(`resources (${skillResourceChangesSummary(resourceChanges)})`);
2955
+ }
2805
2956
  if (changed.length > 0) {
2806
2957
  changes.push({
2807
2958
  type: "update",
@@ -2809,12 +2960,64 @@ async function planSkills(api, localSkills, projectId, appName) {
2809
2960
  id: skill.slug,
2810
2961
  name: skill.name,
2811
2962
  details: `changed: ${changed.join(", ")}`,
2812
- textDiffs: changed.includes("instructions") || changed.includes("summary") ? [{ field: "SKILL.md", oldText: skillMarkdown({ slug: skill.slug, name: remote.name, summary: remote.summary || "", instructions: remote.instructions || "" }), newText: skillMarkdown(skill) }] : void 0
2963
+ textDiffs: changed.includes("instructions") || changed.includes("summary") ? [{ field: "SKILL.md", oldText: skillMarkdown({ name: remote.name, summary: remote.summary || "", instructions: remote.instructions || "" }), newText: skillMarkdown(skill) }] : void 0
2813
2964
  });
2814
2965
  }
2815
2966
  }
2816
2967
  return changes;
2817
2968
  }
2969
+ async function uploadSkillResource(api, skillID, resource) {
2970
+ const bytes = readFileSync2(resource.filePath);
2971
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
2972
+ if (bytes.byteLength !== resource.size || sha256 !== resource.sha256) {
2973
+ throw new Error(`${resource.path} changed while apply was running`);
2974
+ }
2975
+ const metadata = {
2976
+ path: resource.path,
2977
+ size: resource.size,
2978
+ content_type: skillResourceContentType(resource.path),
2979
+ sha256: resource.sha256
2980
+ };
2981
+ const upload = await api.startAgentSkillResourceUpload(skillID, metadata);
2982
+ const headers = { ...upload.headers ?? {} };
2983
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) {
2984
+ headers["Content-Type"] = metadata.content_type;
2985
+ }
2986
+ const response = await fetchWithRetry(upload.upload_url, {
2987
+ method: "PUT",
2988
+ headers,
2989
+ body: bytes
2990
+ }, { attempts: 3, retryMethods: ["PUT"] });
2991
+ if (!response.ok) throw new Error(`${resource.path}: upload failed with HTTP ${response.status}`);
2992
+ await api.completeAgentSkillResourceUpload(skillID, upload.upload_id, metadata);
2993
+ }
2994
+ async function reconcileSkillResources(api, skill, localResources) {
2995
+ let remoteResources = skill.resources ?? [];
2996
+ let changes = compareSkillResources(localResources, remoteResources);
2997
+ if (changes.deleted.length > 0) {
2998
+ const deletions = changes.deleted;
2999
+ const expectedByPath = new Map(remoteResources.map((resource) => [resource.path, resource]));
3000
+ remoteResources = (await api.getAgentSkill(skill.id)).resources ?? [];
3001
+ for (const resourcePath of deletions) {
3002
+ const expected = expectedByPath.get(resourcePath);
3003
+ const current = remoteResources.find((resource) => resource.path === resourcePath);
3004
+ if (!current) continue;
3005
+ if (!expected || current.size !== expected.size || current.sha256 !== expected.sha256) {
3006
+ throw new Error(`${resourcePath} changed while apply was running`);
3007
+ }
3008
+ const result = await api.deleteAgentSkillResource(skill.id, resourcePath);
3009
+ remoteResources = result.resources;
3010
+ }
3011
+ }
3012
+ changes = compareSkillResources(localResources, remoteResources);
3013
+ if (changes.deleted.length > 0) throw new Error("remote resources changed while apply was running");
3014
+ const uploadPaths = /* @__PURE__ */ new Set([...changes.added, ...changes.replaced]);
3015
+ const remoteByPath = new Map(remoteResources.map((resource) => [resource.path, resource]));
3016
+ const uploads = localResources.filter((resource) => uploadPaths.has(resource.path)).sort(
3017
+ (a, b) => a.size - (remoteByPath.get(a.path)?.size ?? 0) - (b.size - (remoteByPath.get(b.path)?.size ?? 0))
3018
+ );
3019
+ for (const resource of uploads) await uploadSkillResource(api, skill.id, resource);
3020
+ }
2818
3021
  async function applySkills(api, localSkills, projectId, appName) {
2819
3022
  if (localSkills.length === 0) return 0;
2820
3023
  validateProjectSkillStorageSlugs(localSkills, appName);
@@ -2825,21 +3028,19 @@ async function applySkills(api, localSkills, projectId, appName) {
2825
3028
  for (const skill of localSkills) {
2826
3029
  const remote = remoteBySlug.get(skill.slug);
2827
3030
  try {
2828
- if (remote) {
2829
- await api.updateAgentSkill(remote.id, {
2830
- name: skill.name,
2831
- summary: skill.summary,
2832
- instructions: skill.instructions
2833
- });
2834
- console.log(pc2.green(" \u2713"), `${skill.name} (updated)`);
2835
- } else {
2836
- await api.createAgentSkill({
2837
- ...skill,
2838
- slug: scopedProjectSkillSlug(appName, skill.slug),
2839
- project_id: scopedProjectID
2840
- });
2841
- console.log(pc2.green(" \u2713"), `${skill.name} (created)`);
2842
- }
3031
+ const saved = remote ? await api.updateAgentSkill(remote.id, {
3032
+ name: skill.name,
3033
+ summary: skill.summary,
3034
+ instructions: skill.instructions
3035
+ }) : await api.createAgentSkill({
3036
+ name: skill.name,
3037
+ summary: skill.summary,
3038
+ instructions: skill.instructions,
3039
+ slug: scopedProjectSkillSlug(appName, skill.slug),
3040
+ project_id: scopedProjectID
3041
+ });
3042
+ await reconcileSkillResources(api, saved, skill.resources);
3043
+ console.log(pc2.green(" \u2713"), `${skill.name} (${remote ? "updated" : "created"})`);
2843
3044
  } catch (error3) {
2844
3045
  console.log(pc2.red(" \u2717"), `${skill.name}: ${error3}`);
2845
3046
  errors++;
@@ -2847,6 +3048,54 @@ async function applySkills(api, localSkills, projectId, appName) {
2847
3048
  }
2848
3049
  return errors;
2849
3050
  }
3051
+ function validateRemoteSkillResources(resources) {
3052
+ if (resources.length > SKILL_RESOURCE_MAX_COUNT) throw new Error(`remote skill has more than ${SKILL_RESOURCE_MAX_COUNT} resources`);
3053
+ const paths = [];
3054
+ let totalBytes = 0;
3055
+ for (const resource of resources) {
3056
+ const pathError = skillResourcePathError(resource.path);
3057
+ if (pathError) throw new Error(`remote resource ${resource.path}: ${pathError}`);
3058
+ const conflict = paths.find((path) => skillResourcePathsCollide(path, resource.path));
3059
+ if (conflict) throw new Error(`remote resource paths conflict: ${conflict} and ${resource.path}`);
3060
+ if (!Number.isSafeInteger(resource.size) || resource.size < 0 || resource.size > SKILL_RESOURCE_MAX_BYTES) {
3061
+ throw new Error(`remote resource ${resource.path} has an invalid size`);
3062
+ }
3063
+ if (!/^[a-f0-9]{64}$/.test(resource.sha256)) throw new Error(`remote resource ${resource.path} has an invalid checksum`);
3064
+ paths.push(resource.path);
3065
+ totalBytes += resource.size;
3066
+ }
3067
+ if (totalBytes > SKILL_RESOURCES_MAX_TOTAL_BYTES) throw new Error("remote skill resources exceed 100 MiB");
3068
+ }
3069
+ async function downloadSkillResource(resource, skillDir) {
3070
+ if (!resource.url) throw new Error(`${resource.path}: download URL is unavailable`);
3071
+ let response;
3072
+ try {
3073
+ response = await fetchWithRetry(resource.url);
3074
+ } catch {
3075
+ throw new Error(`${resource.path}: download failed`);
3076
+ }
3077
+ if (!response.ok) throw new Error(`${resource.path}: download failed with HTTP ${response.status}`);
3078
+ const bytes = Buffer.from(await response.arrayBuffer());
3079
+ if (bytes.byteLength !== resource.size) throw new Error(`${resource.path}: downloaded size does not match`);
3080
+ if (createHash("sha256").update(bytes).digest("hex") !== resource.sha256) {
3081
+ throw new Error(`${resource.path}: downloaded checksum does not match`);
3082
+ }
3083
+ const destination = join2(skillDir, ...resource.path.split("/"));
3084
+ mkdirSync(dirname2(destination), { recursive: true });
3085
+ writeFileSync(destination, bytes);
3086
+ }
3087
+ function replaceSkillDirectory(stagingDir, skillDir) {
3088
+ const backupDir = `${stagingDir}.previous`;
3089
+ const hadExisting = existsSync2(skillDir);
3090
+ if (hadExisting) renameSync(skillDir, backupDir);
3091
+ try {
3092
+ renameSync(stagingDir, skillDir);
3093
+ } catch (error3) {
3094
+ if (hadExisting && !existsSync2(skillDir)) renameSync(backupDir, skillDir);
3095
+ throw error3;
3096
+ }
3097
+ if (hadExisting) rmSync(backupDir, { recursive: true, force: true });
3098
+ }
2850
3099
  async function pullSkills(api, platformDir, filterName, projectId, appName) {
2851
3100
  if (!projectId) {
2852
3101
  console.log(pc2.dim(" Skipped \u2014 project not registered (run `lumera register`)"));
@@ -2858,24 +3107,34 @@ async function pullSkills(api, platformDir, filterName, projectId, appName) {
2858
3107
  await api.listAgentSkills({ project_id: scopedProjectID }),
2859
3108
  appName
2860
3109
  );
2861
- for (const [localSlug, skill] of remoteSkills) {
3110
+ const resolvedFilter = resolveProjectSkillLocalSlug(remoteSkills, filterName);
3111
+ for (const [localSlug, listedSkill] of remoteSkills) {
2862
3112
  if (!SKILL_SLUG_RE.test(localSlug)) {
2863
- console.log(pc2.yellow(` \u26A0 Skipping ${skill.name}: skill slug ${skill.slug} cannot be represented in platform/skills`));
3113
+ console.log(pc2.yellow(` \u26A0 Skipping ${listedSkill.name}: skill slug ${listedSkill.slug} cannot be represented in platform/skills`));
2864
3114
  continue;
2865
3115
  }
2866
- if (filterName && localSlug !== filterName && skill.slug !== filterName && skill.name !== filterName) continue;
3116
+ if (resolvedFilter && localSlug !== resolvedFilter) continue;
3117
+ const skill = await api.getAgentSkill(listedSkill.id, { resource_urls: "attachment" });
2867
3118
  if (!(skill.summary || "").trim() || !(skill.instructions || "").trim()) {
2868
3119
  console.log(pc2.yellow(` \u26A0 Skipping ${skill.name}: source-managed skills require both summary and instructions`));
2869
3120
  continue;
2870
3121
  }
3122
+ const resources = [...skill.resources ?? []].sort((a, b) => a.path.localeCompare(b.path));
3123
+ validateRemoteSkillResources(resources);
2871
3124
  const skillDir = join2(skillsDir, localSlug);
2872
- mkdirSync(skillDir, { recursive: true });
2873
- writeFileSync(join2(skillDir, "SKILL.md"), skillMarkdown({
2874
- slug: localSlug,
2875
- name: skill.name,
2876
- summary: skill.summary || "",
2877
- instructions: skill.instructions || ""
2878
- }));
3125
+ mkdirSync(skillsDir, { recursive: true });
3126
+ const stagingDir = mkdtempSync(join2(platformDir, `.skill-${localSlug}-pull-`));
3127
+ try {
3128
+ writeFileSync(join2(stagingDir, "SKILL.md"), skillMarkdown({
3129
+ name: skill.name,
3130
+ summary: skill.summary || "",
3131
+ instructions: skill.instructions || ""
3132
+ }));
3133
+ for (const resource of resources) await downloadSkillResource(resource, stagingDir);
3134
+ replaceSkillDirectory(stagingDir, skillDir);
3135
+ } finally {
3136
+ rmSync(stagingDir, { recursive: true, force: true });
3137
+ }
2879
3138
  console.log(pc2.green(" \u2713"), `${skill.name} \u2192 skills/${localSlug}/`);
2880
3139
  }
2881
3140
  }
@@ -3301,16 +3560,21 @@ async function listResources(api, platformDir, filterType, appName, projectId) {
3301
3560
  for (const skill of localSkills) {
3302
3561
  const remote = remoteBySlug.get(skill.slug);
3303
3562
  if (!remote) {
3304
- results.push({ name: skill.name, type: "skills", status: "local-only" });
3305
- } else if (remote.name !== skill.name || (remote.summary || "").trim() !== skill.summary.trim() || (remote.instructions || "").trim() !== skill.instructions.trim()) {
3306
- results.push({ name: skill.name, type: "skills", status: "changed", details: "SKILL.md" });
3563
+ const resourceChanges = skillResourceChangesSummary(compareSkillResources(skill.resources, []));
3564
+ results.push(resourceChanges ? { name: skill.name, type: "skills", status: "local-only", details: `resources (${resourceChanges})` } : { name: skill.name, type: "skills", status: "local-only" });
3307
3565
  } else {
3308
- results.push({ name: skill.name, type: "skills", status: "synced" });
3566
+ const resourceChanges = compareSkillResources(skill.resources, remote.resources ?? []);
3567
+ const details = [
3568
+ remote.name !== skill.name || (remote.summary || "").trim() !== skill.summary.trim() || (remote.instructions || "").trim() !== skill.instructions.trim() ? "SKILL.md" : "",
3569
+ skillResourceChangesEmpty(resourceChanges) ? "" : `resources (${skillResourceChangesSummary(resourceChanges)})`
3570
+ ].filter(Boolean).join(", ");
3571
+ results.push(details ? { name: skill.name, type: "skills", status: "changed", details } : { name: skill.name, type: "skills", status: "synced" });
3309
3572
  }
3310
3573
  }
3311
3574
  for (const remote of remoteSkills) {
3312
3575
  if (!localSlugs.has(localProjectSkillSlug(appName, remote.slug))) {
3313
- results.push({ name: remote.name, type: "skills", status: "remote-only" });
3576
+ const resourceChanges = skillResourceChangesSummary(compareSkillResources([], remote.resources ?? []));
3577
+ results.push(resourceChanges ? { name: remote.name, type: "skills", status: "remote-only", details: `resources (${resourceChanges})` } : { name: remote.name, type: "skills", status: "remote-only" });
3314
3578
  }
3315
3579
  }
3316
3580
  }
@@ -3812,8 +4076,12 @@ async function showResource(api, platformDir, resourceType, resourceName, appNam
3812
4076
  console.log(pc2.bold(` Skill: ${local?.name || remote?.name}`));
3813
4077
  console.log();
3814
4078
  if (local && remote) {
3815
- const changed = local.name !== remote.name || local.summary.trim() !== (remote.summary || "").trim() || local.instructions.trim() !== (remote.instructions || "").trim();
4079
+ const resourceChanges = compareSkillResources(local.resources, remote.resources ?? []);
4080
+ const changed = local.name !== remote.name || local.summary.trim() !== (remote.summary || "").trim() || local.instructions.trim() !== (remote.instructions || "").trim() || !skillResourceChangesEmpty(resourceChanges);
3816
4081
  console.log(` Status: ${changed ? pc2.yellow("changed") : pc2.green("synced")}`);
4082
+ if (!skillResourceChangesEmpty(resourceChanges)) {
4083
+ console.log(` Resources: ${skillResourceChangesSummary(resourceChanges)}`);
4084
+ }
3817
4085
  } else if (local) {
3818
4086
  console.log(` Status: ${pc2.yellow("local only")}`);
3819
4087
  } else {
@@ -3822,6 +4090,8 @@ async function showResource(api, platformDir, resourceType, resourceName, appNam
3822
4090
  console.log(` Slug: ${local?.slug || (remote ? localProjectSkillSlug(appName, remote.slug) : "")}`);
3823
4091
  const summary = local?.summary || remote?.summary;
3824
4092
  if (summary) console.log(` Summary: ${summary}`);
4093
+ const resourcePaths = (local?.resources ?? remote?.resources ?? []).map((resource) => resource.path).sort();
4094
+ if (resourcePaths.length > 0) console.log(` Resource files: ${resourcePaths.join(", ")}`);
3825
4095
  console.log();
3826
4096
  } else if (resourceType === "mailboxes") {
3827
4097
  const localMailboxes = loadLocalMailboxes(platformDir, resourceName);
@@ -4265,11 +4535,22 @@ async function pull(args) {
4265
4535
  collections = /* @__PURE__ */ new Map();
4266
4536
  }
4267
4537
  if (projectId && (!type || type === "skills")) {
4268
- const localSkills = loadLocalSkills(platformDir, name || void 0);
4538
+ const remoteBySlug = remoteSkillsByLocalSlug(
4539
+ await api.listAgentSkills({ project_id: projectId }),
4540
+ appName
4541
+ );
4542
+ const localSkills = loadLocalSkills(
4543
+ platformDir,
4544
+ resolveProjectSkillLocalSlug(remoteBySlug, name || void 0)
4545
+ );
4269
4546
  if (localSkills.length > 0) {
4270
- const changes = await planSkills(api, localSkills, projectId, appName);
4271
- for (const change of changes) {
4272
- if (change.type === "update") conflicts.push(`skills/${change.id}`);
4547
+ for (const skill of localSkills) {
4548
+ const remote = remoteBySlug.get(skill.slug);
4549
+ if (!remote) continue;
4550
+ const resourceChanges = compareSkillResources(skill.resources, remote.resources ?? []);
4551
+ if (remote.name !== skill.name || (remote.summary || "").trim() !== skill.summary.trim() || (remote.instructions || "").trim() !== skill.instructions.trim() || resourceChanges.added.length > 0 || resourceChanges.replaced.length > 0) {
4552
+ conflicts.push(`skills/${skill.slug}`);
4553
+ }
4273
4554
  }
4274
4555
  }
4275
4556
  }
@@ -4504,7 +4785,7 @@ ${pc2.dim("Usage:")}
4504
4785
 
4505
4786
  ${pc2.dim("Resources:")}
4506
4787
  agents/<name> Diff agent (system_prompt, policy_script)
4507
- skills/<slug> Diff project skill instructions
4788
+ skills/<slug> Diff project SKILL.md and resource paths
4508
4789
  automations/<name> Diff automation code
4509
4790
  hooks/<name> Diff hook script
4510
4791
 
@@ -4680,16 +4961,22 @@ async function diff(args) {
4680
4961
  console.log(` summary: ${pc2.red(remote.summary || "(empty)")} \u2192 ${pc2.green(local.summary)}`);
4681
4962
  }
4682
4963
  const remoteMarkdown = skillMarkdown({
4683
- slug: localProjectSkillSlug(appName, remote.slug),
4684
4964
  name: remote.name,
4685
4965
  summary: remote.summary || "",
4686
4966
  instructions: remote.instructions || ""
4687
4967
  });
4688
4968
  const localMarkdown = skillMarkdown(local);
4689
- if (remoteMarkdown === localMarkdown) {
4969
+ const resourceChanges = compareSkillResources(local.resources, remote.resources ?? []);
4970
+ if (remoteMarkdown === localMarkdown && skillResourceChangesEmpty(resourceChanges)) {
4690
4971
  console.log(pc2.green(" \u2713 No changes"));
4691
4972
  } else {
4692
- renderFullDiff("SKILL.md", remoteMarkdown, localMarkdown);
4973
+ if (remoteMarkdown !== localMarkdown) renderFullDiff("SKILL.md", remoteMarkdown, localMarkdown);
4974
+ if (!skillResourceChangesEmpty(resourceChanges)) {
4975
+ console.log(pc2.bold(" Resources:"));
4976
+ for (const path of resourceChanges.added) console.log(pc2.green(` + ${path}`));
4977
+ for (const path of resourceChanges.replaced) console.log(pc2.yellow(` ~ ${path}`));
4978
+ for (const path of resourceChanges.deleted) console.log(pc2.red(` - ${path}`));
4979
+ }
4693
4980
  }
4694
4981
  } else {
4695
4982
  console.log(pc2.red(` Diff not supported for "${type}" \u2014 use agents, skills, automations, or hooks`));
@@ -4703,6 +4990,7 @@ export {
4703
4990
  applyAutomations,
4704
4991
  applyCollections,
4705
4992
  applySkills,
4993
+ compareSkillResources,
4706
4994
  convertCollectionToApiFormat,
4707
4995
  destroy,
4708
4996
  diff,
@@ -4719,6 +5007,7 @@ export {
4719
5007
  pullCollections,
4720
5008
  pullSkills,
4721
5009
  resolveAgentSkillRefs,
5010
+ resolveProjectSkillLocalSlug,
4722
5011
  scopedProjectSkillSlug,
4723
5012
  show
4724
5013
  };
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-2CR762KB.js";
4
4
  import {
5
5
  createApiClient
6
- } from "./chunk-3JDRRFNT.js";
6
+ } from "./chunk-ZJ7M7JD3.js";
7
7
  import {
8
8
  findProjectRoot,
9
9
  getApiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumerahq/cli",
3
- "version": "0.30.20",
3
+ "version": "0.31.0-dev.1",
4
4
  "description": "CLI for building and deploying Lumera apps",
5
5
  "type": "module",
6
6
  "engines": {