@lumerahq/cli 0.31.0-dev.0 → 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.
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  deploy
3
- } from "./chunk-2K4ZYGCE.js";
3
+ } from "./chunk-IVII7OZZ.js";
4
4
  import {
5
5
  syncDeps
6
- } from "./chunk-ODANFLL7.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-E7YZ6QS6.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";
@@ -2496,6 +2497,7 @@ async function applyApp(args) {
2496
2497
  const accessLevel = getFlagValue(args, "access-level");
2497
2498
  const validAccessLevels = [
2498
2499
  "org_members_with_permission",
2500
+ "specific_people",
2499
2501
  "org_members",
2500
2502
  "invited",
2501
2503
  "signed_in",
@@ -2642,6 +2644,130 @@ var SKILL_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2642
2644
  var SKILL_NAME_MAX_LENGTH = 100;
2643
2645
  var SKILL_SLUG_MAX_LENGTH = 100;
2644
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
+ }
2645
2771
  function loadLocalSkills(platformDir, filterName) {
2646
2772
  const skillsDir = join2(platformDir, "skills");
2647
2773
  if (!existsSync2(skillsDir)) return [];
@@ -2654,11 +2780,19 @@ function loadLocalSkills(platformDir, filterName) {
2654
2780
  errors.push(`${entry.name}: directory name must be a lowercase slug`);
2655
2781
  continue;
2656
2782
  }
2657
- const path = join2(skillsDir, entry.name, "SKILL.md");
2783
+ const skillDir = join2(skillsDir, entry.name);
2784
+ const path = join2(skillDir, "SKILL.md");
2658
2785
  if (!existsSync2(path)) {
2659
2786
  errors.push(`${entry.name}: missing SKILL.md`);
2660
2787
  continue;
2661
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}`));
2662
2796
  const content = readFileSync2(path, "utf-8");
2663
2797
  const separator = /\r?\n---\r?\n/.exec(content);
2664
2798
  const parts = separator ? [content.slice(0, separator.index), content.slice(separator.index + separator[0].length)] : [content];
@@ -2684,7 +2818,7 @@ function loadLocalSkills(platformDir, filterName) {
2684
2818
  if (summary.length > SKILL_SUMMARY_MAX_LENGTH) errors.push(`${entry.name}: summary must be at most ${SKILL_SUMMARY_MAX_LENGTH} characters`);
2685
2819
  if (!instructions) errors.push(`${entry.name}: SKILL.md must include instructions after ---`);
2686
2820
  if (name && name.length <= SKILL_NAME_MAX_LENGTH && summary && summary.length <= SKILL_SUMMARY_MAX_LENGTH && instructions) {
2687
- skills.push({ slug: entry.name, name, summary, instructions });
2821
+ skills.push({ slug: entry.name, name, summary, instructions, resources });
2688
2822
  }
2689
2823
  }
2690
2824
  if (errors.length > 0) {
@@ -2728,6 +2862,13 @@ function remoteSkillsByLocalSlug(remoteSkills, appName) {
2728
2862
  }
2729
2863
  return byLocalSlug;
2730
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
+ }
2731
2872
  function projectSkillRefCandidates(refs, appName) {
2732
2873
  return [...new Set(refs.flatMap((ref) => appName && SKILL_SLUG_RE.test(ref) ? [scopedProjectSkillSlug(appName, ref), ref] : [ref]))];
2733
2874
  }
@@ -2794,13 +2935,24 @@ async function planSkills(api, localSkills, projectId, appName) {
2794
2935
  for (const skill of localSkills) {
2795
2936
  const remote = remoteBySlug.get(skill.slug);
2796
2937
  if (!remote) {
2797
- 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
+ });
2798
2946
  continue;
2799
2947
  }
2800
2948
  const changed = [];
2801
2949
  if (remote.name !== skill.name) changed.push("name");
2802
2950
  if ((remote.summary || "").trim() !== skill.summary.trim()) changed.push("summary");
2803
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
+ }
2804
2956
  if (changed.length > 0) {
2805
2957
  changes.push({
2806
2958
  type: "update",
@@ -2808,12 +2960,64 @@ async function planSkills(api, localSkills, projectId, appName) {
2808
2960
  id: skill.slug,
2809
2961
  name: skill.name,
2810
2962
  details: `changed: ${changed.join(", ")}`,
2811
- 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
2812
2964
  });
2813
2965
  }
2814
2966
  }
2815
2967
  return changes;
2816
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
+ }
2817
3021
  async function applySkills(api, localSkills, projectId, appName) {
2818
3022
  if (localSkills.length === 0) return 0;
2819
3023
  validateProjectSkillStorageSlugs(localSkills, appName);
@@ -2824,21 +3028,19 @@ async function applySkills(api, localSkills, projectId, appName) {
2824
3028
  for (const skill of localSkills) {
2825
3029
  const remote = remoteBySlug.get(skill.slug);
2826
3030
  try {
2827
- if (remote) {
2828
- await api.updateAgentSkill(remote.id, {
2829
- name: skill.name,
2830
- summary: skill.summary,
2831
- instructions: skill.instructions
2832
- });
2833
- console.log(pc2.green(" \u2713"), `${skill.name} (updated)`);
2834
- } else {
2835
- await api.createAgentSkill({
2836
- ...skill,
2837
- slug: scopedProjectSkillSlug(appName, skill.slug),
2838
- project_id: scopedProjectID
2839
- });
2840
- console.log(pc2.green(" \u2713"), `${skill.name} (created)`);
2841
- }
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"})`);
2842
3044
  } catch (error3) {
2843
3045
  console.log(pc2.red(" \u2717"), `${skill.name}: ${error3}`);
2844
3046
  errors++;
@@ -2846,6 +3048,54 @@ async function applySkills(api, localSkills, projectId, appName) {
2846
3048
  }
2847
3049
  return errors;
2848
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
+ }
2849
3099
  async function pullSkills(api, platformDir, filterName, projectId, appName) {
2850
3100
  if (!projectId) {
2851
3101
  console.log(pc2.dim(" Skipped \u2014 project not registered (run `lumera register`)"));
@@ -2857,24 +3107,34 @@ async function pullSkills(api, platformDir, filterName, projectId, appName) {
2857
3107
  await api.listAgentSkills({ project_id: scopedProjectID }),
2858
3108
  appName
2859
3109
  );
2860
- for (const [localSlug, skill] of remoteSkills) {
3110
+ const resolvedFilter = resolveProjectSkillLocalSlug(remoteSkills, filterName);
3111
+ for (const [localSlug, listedSkill] of remoteSkills) {
2861
3112
  if (!SKILL_SLUG_RE.test(localSlug)) {
2862
- 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`));
2863
3114
  continue;
2864
3115
  }
2865
- 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" });
2866
3118
  if (!(skill.summary || "").trim() || !(skill.instructions || "").trim()) {
2867
3119
  console.log(pc2.yellow(` \u26A0 Skipping ${skill.name}: source-managed skills require both summary and instructions`));
2868
3120
  continue;
2869
3121
  }
3122
+ const resources = [...skill.resources ?? []].sort((a, b) => a.path.localeCompare(b.path));
3123
+ validateRemoteSkillResources(resources);
2870
3124
  const skillDir = join2(skillsDir, localSlug);
2871
- mkdirSync(skillDir, { recursive: true });
2872
- writeFileSync(join2(skillDir, "SKILL.md"), skillMarkdown({
2873
- slug: localSlug,
2874
- name: skill.name,
2875
- summary: skill.summary || "",
2876
- instructions: skill.instructions || ""
2877
- }));
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
+ }
2878
3138
  console.log(pc2.green(" \u2713"), `${skill.name} \u2192 skills/${localSlug}/`);
2879
3139
  }
2880
3140
  }
@@ -2928,14 +3188,6 @@ function loadLocalAgents(platformDir, filterName, appName) {
2928
3188
  continue;
2929
3189
  }
2930
3190
  }
2931
- if (config.allowed_user_tags !== void 0) {
2932
- const tags = config.allowed_user_tags;
2933
- if (!Array.isArray(tags) || tags.length > 100 || tags.some((tag) => typeof tag !== "string" || !tag || Buffer.byteLength(tag) > 64 || tag !== tag.trim() || tag !== tag.toLowerCase())) {
2934
- errors.push(`${entry.name}: allowed_user_tags must be an array of at most 100 canonical tag keys (1\u201364 bytes each)`);
2935
- continue;
2936
- }
2937
- config.allowed_user_tags = [...new Set(tags)].sort();
2938
- }
2939
3191
  let systemPrompt = readFileSync2(promptPath, "utf-8");
2940
3192
  let policyScript = existsSync2(policyPath) ? readFileSync2(policyPath, "utf-8") : "";
2941
3193
  if (appName) {
@@ -2956,9 +3208,6 @@ function loadLocalAgents(platformDir, filterName, appName) {
2956
3208
  }
2957
3209
  return agents;
2958
3210
  }
2959
- function allowedUserTagsChanged(local, remote) {
2960
- return local.allowed_user_tags !== void 0 && JSON.stringify([...new Set(local.allowed_user_tags)].sort()) !== JSON.stringify([...new Set(remote.allowed_user_tags ?? [])].sort());
2961
- }
2962
3211
  async function planAgents(api, localAgents, projectId, localSourceSkillSlugs = /* @__PURE__ */ new Set(), pendingLocalSkillSlugs = /* @__PURE__ */ new Set(), appName) {
2963
3212
  const changes = [];
2964
3213
  let validationErrors = 0;
@@ -3011,7 +3260,6 @@ async function planAgents(api, localAgents, projectId, localSourceSkillSlugs = /
3011
3260
  if ((remote.system_prompt || "").trim() !== systemPrompt.trim()) diffs.push("system_prompt");
3012
3261
  if ((remote.model || "") !== (agent.model || "")) diffs.push("model");
3013
3262
  if (agent.thinking_level !== void 0 && (remote.thinking_level || "") !== agent.thinking_level) diffs.push("thinking_level");
3014
- if (allowedUserTagsChanged(agent, remote)) diffs.push("allowed_user_tags");
3015
3263
  if ((remote.idle_reap_timeout_seconds ?? 0) !== (agent.idle_reap_timeout_seconds ?? 0)) diffs.push("idle_reap_timeout_seconds");
3016
3264
  if ((remote.policy_script || "").trim() !== (policyScript || "").trim()) diffs.push("policy_script");
3017
3265
  if ((remote.policy_enabled || false) !== (agent.policy_enabled || false)) diffs.push("policy_enabled");
@@ -3115,7 +3363,6 @@ async function applyAgents(api, localAgents, projectId, localSourceSkillSlugs =
3115
3363
  system_prompt: systemPrompt,
3116
3364
  model: agent.model || "",
3117
3365
  ...agent.thinking_level !== void 0 ? { thinking_level: agent.thinking_level } : {},
3118
- ...agent.allowed_user_tags !== void 0 ? { allowed_user_tags: agent.allowed_user_tags } : {},
3119
3366
  idle_reap_timeout_seconds: agent.idle_reap_timeout_seconds ?? 0,
3120
3367
  skill_ids: skillIds,
3121
3368
  policy_script: policyScript || "",
@@ -3177,7 +3424,6 @@ async function pullAgents(api, platformDir, filterName, projectId, appName) {
3177
3424
  if (agent.description) config.description = agent.description;
3178
3425
  if (agent.model) config.model = agent.model;
3179
3426
  if (agent.thinking_level) config.thinking_level = agent.thinking_level;
3180
- if (agent.allowed_user_tags != null) config.allowed_user_tags = agent.allowed_user_tags;
3181
3427
  if (agent.idle_reap_timeout_seconds) config.idle_reap_timeout_seconds = agent.idle_reap_timeout_seconds;
3182
3428
  if (skillSlugs.length > 0) config.skills = skillSlugs;
3183
3429
  if (agent.policy_enabled) config.policy_enabled = true;
@@ -3314,16 +3560,21 @@ async function listResources(api, platformDir, filterType, appName, projectId) {
3314
3560
  for (const skill of localSkills) {
3315
3561
  const remote = remoteBySlug.get(skill.slug);
3316
3562
  if (!remote) {
3317
- results.push({ name: skill.name, type: "skills", status: "local-only" });
3318
- } else if (remote.name !== skill.name || (remote.summary || "").trim() !== skill.summary.trim() || (remote.instructions || "").trim() !== skill.instructions.trim()) {
3319
- 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" });
3320
3565
  } else {
3321
- 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" });
3322
3572
  }
3323
3573
  }
3324
3574
  for (const remote of remoteSkills) {
3325
3575
  if (!localSlugs.has(localProjectSkillSlug(appName, remote.slug))) {
3326
- 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" });
3327
3578
  }
3328
3579
  }
3329
3580
  }
@@ -3825,8 +4076,12 @@ async function showResource(api, platformDir, resourceType, resourceName, appNam
3825
4076
  console.log(pc2.bold(` Skill: ${local?.name || remote?.name}`));
3826
4077
  console.log();
3827
4078
  if (local && remote) {
3828
- 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);
3829
4081
  console.log(` Status: ${changed ? pc2.yellow("changed") : pc2.green("synced")}`);
4082
+ if (!skillResourceChangesEmpty(resourceChanges)) {
4083
+ console.log(` Resources: ${skillResourceChangesSummary(resourceChanges)}`);
4084
+ }
3830
4085
  } else if (local) {
3831
4086
  console.log(` Status: ${pc2.yellow("local only")}`);
3832
4087
  } else {
@@ -3835,6 +4090,8 @@ async function showResource(api, platformDir, resourceType, resourceName, appNam
3835
4090
  console.log(` Slug: ${local?.slug || (remote ? localProjectSkillSlug(appName, remote.slug) : "")}`);
3836
4091
  const summary = local?.summary || remote?.summary;
3837
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(", ")}`);
3838
4095
  console.log();
3839
4096
  } else if (resourceType === "mailboxes") {
3840
4097
  const localMailboxes = loadLocalMailboxes(platformDir, resourceName);
@@ -4278,11 +4535,22 @@ async function pull(args) {
4278
4535
  collections = /* @__PURE__ */ new Map();
4279
4536
  }
4280
4537
  if (projectId && (!type || type === "skills")) {
4281
- 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
+ );
4282
4546
  if (localSkills.length > 0) {
4283
- const changes = await planSkills(api, localSkills, projectId, appName);
4284
- for (const change of changes) {
4285
- 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
+ }
4286
4554
  }
4287
4555
  }
4288
4556
  }
@@ -4517,7 +4785,7 @@ ${pc2.dim("Usage:")}
4517
4785
 
4518
4786
  ${pc2.dim("Resources:")}
4519
4787
  agents/<name> Diff agent (system_prompt, policy_script)
4520
- skills/<slug> Diff project skill instructions
4788
+ skills/<slug> Diff project SKILL.md and resource paths
4521
4789
  automations/<name> Diff automation code
4522
4790
  hooks/<name> Diff hook script
4523
4791
 
@@ -4597,11 +4865,7 @@ async function diff(args) {
4597
4865
  const promptChanged = (remote.system_prompt || "").trim() !== local.systemPrompt.trim();
4598
4866
  const policyChanged = (remote.policy_script || "").trim() !== (local.policyScript || "").trim();
4599
4867
  const thinkingLevelChanged = local.agent.thinking_level !== void 0 && (remote.thinking_level || "") !== local.agent.thinking_level;
4600
- const userTagsChanged = allowedUserTagsChanged(local.agent, remote);
4601
- if (userTagsChanged) {
4602
- console.log(` allowed_user_tags: ${pc2.red(JSON.stringify(remote.allowed_user_tags ?? []))} \u2192 ${pc2.green(JSON.stringify(local.agent.allowed_user_tags))}`);
4603
- }
4604
- if (!promptChanged && !policyChanged && !thinkingLevelChanged && !userTagsChanged && remote.name === local.agent.name && (remote.description || "") === (local.agent.description || "") && (remote.model || "") === (local.agent.model || "") && (remote.idle_reap_timeout_seconds ?? 0) === (local.agent.idle_reap_timeout_seconds ?? 0) && (remote.policy_enabled || false) === (local.agent.policy_enabled || false)) {
4868
+ if (!promptChanged && !policyChanged && !thinkingLevelChanged && remote.name === local.agent.name && (remote.description || "") === (local.agent.description || "") && (remote.model || "") === (local.agent.model || "") && (remote.idle_reap_timeout_seconds ?? 0) === (local.agent.idle_reap_timeout_seconds ?? 0) && (remote.policy_enabled || false) === (local.agent.policy_enabled || false)) {
4605
4869
  console.log(pc2.green(` \u2713 No changes`));
4606
4870
  } else {
4607
4871
  if (promptChanged) renderFullDiff("system_prompt.md", remote.system_prompt || "", local.systemPrompt);
@@ -4697,16 +4961,22 @@ async function diff(args) {
4697
4961
  console.log(` summary: ${pc2.red(remote.summary || "(empty)")} \u2192 ${pc2.green(local.summary)}`);
4698
4962
  }
4699
4963
  const remoteMarkdown = skillMarkdown({
4700
- slug: localProjectSkillSlug(appName, remote.slug),
4701
4964
  name: remote.name,
4702
4965
  summary: remote.summary || "",
4703
4966
  instructions: remote.instructions || ""
4704
4967
  });
4705
4968
  const localMarkdown = skillMarkdown(local);
4706
- if (remoteMarkdown === localMarkdown) {
4969
+ const resourceChanges = compareSkillResources(local.resources, remote.resources ?? []);
4970
+ if (remoteMarkdown === localMarkdown && skillResourceChangesEmpty(resourceChanges)) {
4707
4971
  console.log(pc2.green(" \u2713 No changes"));
4708
4972
  } else {
4709
- 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
+ }
4710
4980
  }
4711
4981
  } else {
4712
4982
  console.log(pc2.red(` Diff not supported for "${type}" \u2014 use agents, skills, automations, or hooks`));
@@ -4720,11 +4990,11 @@ export {
4720
4990
  applyAutomations,
4721
4991
  applyCollections,
4722
4992
  applySkills,
4993
+ compareSkillResources,
4723
4994
  convertCollectionToApiFormat,
4724
4995
  destroy,
4725
4996
  diff,
4726
4997
  list,
4727
- loadLocalAgents,
4728
4998
  loadLocalCollections,
4729
4999
  loadLocalSkills,
4730
5000
  localProjectSkillSlug,
@@ -4734,10 +5004,10 @@ export {
4734
5004
  planSkills,
4735
5005
  projectSkillRefCandidates,
4736
5006
  pull,
4737
- pullAgents,
4738
5007
  pullCollections,
4739
5008
  pullSkills,
4740
5009
  resolveAgentSkillRefs,
5010
+ resolveProjectSkillLocalSlug,
4741
5011
  scopedProjectSkillSlug,
4742
5012
  show
4743
5013
  };
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-2CR762KB.js";
4
4
  import {
5
5
  createApiClient
6
- } from "./chunk-E7YZ6QS6.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.31.0-dev.0",
3
+ "version": "0.31.0-dev.1",
4
4
  "description": "CLI for building and deploying Lumera apps",
5
5
  "type": "module",
6
6
  "engines": {