@exulu/backend 2.1.0 → 2.2.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
@@ -54,6 +54,7 @@ import {
54
54
  mapStreamErrorMessage,
55
55
  oauthRegistry,
56
56
  oauthTokenStore,
57
+ parseResetAt,
57
58
  postgresClient,
58
59
  provisionDefaultUserBudget,
59
60
  reportSystemDependencies,
@@ -73,7 +74,7 @@ import {
73
74
  upsertBudget,
74
75
  waitForLiteLLMReady,
75
76
  withRetry
76
- } from "./chunk-RVZWZNWG.js";
77
+ } from "./chunk-Y7JPNBFM.js";
77
78
  import {
78
79
  findLiteLLMModel
79
80
  } from "./chunk-7CCMW3IW.js";
@@ -11134,21 +11135,7 @@ function isOsJunkPath(path2) {
11134
11135
  const basename = path2.split("/").pop() ?? "";
11135
11136
  return basename === ".DS_Store" || basename === "Thumbs.db" || basename === "desktop.ini";
11136
11137
  }
11137
- async function extractBundleToS3(opts) {
11138
- const { bytes, skillId, isZip, config } = opts;
11139
- if (!isZip) {
11140
- await uploadFile(
11141
- bytes,
11142
- `skills/${skillId}/v1/SKILL.md`,
11143
- config,
11144
- { contentType: "text/markdown" },
11145
- void 0,
11146
- void 0,
11147
- true
11148
- // global=true so the key isn't user-prefixed (skill files are shared)
11149
- );
11150
- return { filesCount: 1 };
11151
- }
11138
+ async function extractZipToPrefix(bytes, prefix, config) {
11152
11139
  let zip;
11153
11140
  try {
11154
11141
  zip = await JSZip.loadAsync(bytes);
@@ -11212,7 +11199,7 @@ async function extractBundleToS3(opts) {
11212
11199
  }
11213
11200
  let filesCount = 0;
11214
11201
  for (const { relPath, content } of prepared) {
11215
- const s3Key = `skills/${skillId}/v1/${relPath}`;
11202
+ const s3Key = `${prefix}${relPath}`;
11216
11203
  await uploadFile(
11217
11204
  content,
11218
11205
  s3Key,
@@ -11221,12 +11208,76 @@ async function extractBundleToS3(opts) {
11221
11208
  void 0,
11222
11209
  void 0,
11223
11210
  true
11224
- // global=true — see SKILL.md case above
11211
+ // global=true — skill files are shared across users
11225
11212
  );
11226
11213
  filesCount += 1;
11227
11214
  }
11228
11215
  return { filesCount };
11229
11216
  }
11217
+ async function extractBundleToS3(opts) {
11218
+ const { bytes, skillId, isZip, config } = opts;
11219
+ if (!isZip) {
11220
+ await uploadFile(
11221
+ bytes,
11222
+ `skills/${skillId}/v1/SKILL.md`,
11223
+ config,
11224
+ { contentType: "text/markdown" },
11225
+ void 0,
11226
+ void 0,
11227
+ true
11228
+ // global=true so the key isn't user-prefixed (skill files are shared)
11229
+ );
11230
+ return { filesCount: 1 };
11231
+ }
11232
+ return extractZipToPrefix(bytes, `skills/${skillId}/v1/`, config);
11233
+ }
11234
+ async function extractBundleToVersion(opts) {
11235
+ const { bytes, skillId, version, config } = opts;
11236
+ return extractZipToPrefix(bytes, `skills/${skillId}/v${version}/`, config);
11237
+ }
11238
+
11239
+ // src/skills/frontmatter.ts
11240
+ import JSZip2 from "jszip";
11241
+ function parseFrontmatter(md) {
11242
+ const match = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(md);
11243
+ if (!match) return {};
11244
+ const block = match[1];
11245
+ const out = {};
11246
+ for (const line of block.split(/\r?\n/)) {
11247
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
11248
+ if (!m) continue;
11249
+ const key = m[1];
11250
+ let v = m[2].trim();
11251
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
11252
+ v = v.slice(1, -1);
11253
+ }
11254
+ out[key] = v;
11255
+ }
11256
+ return out;
11257
+ }
11258
+ async function parseSkillFrontmatter(zipBytes) {
11259
+ let zip;
11260
+ try {
11261
+ zip = await JSZip2.loadAsync(zipBytes);
11262
+ } catch {
11263
+ return {};
11264
+ }
11265
+ const paths = [];
11266
+ zip.forEach((p, entry) => {
11267
+ if (!entry.dir) paths.push(p);
11268
+ });
11269
+ const heads = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean));
11270
+ let strip = (p) => p;
11271
+ if (heads.size === 1) {
11272
+ const head = [...heads][0] + "/";
11273
+ if (paths.every((p) => p.startsWith(head))) strip = (p) => p.slice(head.length);
11274
+ }
11275
+ const skillPath = paths.find((p) => strip(p) === "SKILL.md");
11276
+ if (!skillPath) return {};
11277
+ const md = await zip.file(skillPath).async("string");
11278
+ const fm = parseFrontmatter(md);
11279
+ return { name: fm.name, description: fm.description };
11280
+ }
11230
11281
 
11231
11282
  // src/sessions/pdf-preview-cache.ts
11232
11283
  import { exec } from "child_process";
@@ -11306,7 +11357,7 @@ import OpenAI from "openai";
11306
11357
  import fs2 from "fs";
11307
11358
  import { randomUUID as randomUUID3 } from "crypto";
11308
11359
  import "@opentelemetry/api";
11309
- import JSZip2 from "jszip";
11360
+ import JSZip3 from "jszip";
11310
11361
  import { createIdGenerator } from "ai";
11311
11362
  import cookieParser from "cookie-parser";
11312
11363
 
@@ -13763,6 +13814,148 @@ var contentHeadersFor = (key, contentType, filename) => {
13763
13814
  };
13764
13815
  var getSharedArtifactByName = (db, name) => db("shared_artifacts").where({ name }).first();
13765
13816
 
13817
+ // src/skills/skill-access.ts
13818
+ async function resolveSkillByName(db, name) {
13819
+ const row = await db("skills").where({ name }).first();
13820
+ return row ?? null;
13821
+ }
13822
+ async function canAccessSkill(db, skill, action, user) {
13823
+ const rbac = await RBACResolver(db, "skill", skill.id, skill.rights_mode || "private");
13824
+ return checkRecordAccess({ ...skill, RBAC: rbac }, action, user);
13825
+ }
13826
+ async function filterReadableSkills(db, skills, user) {
13827
+ const out = [];
13828
+ for (const s of skills) {
13829
+ if (await canAccessSkill(db, s, "read", user)) out.push(s);
13830
+ }
13831
+ return out;
13832
+ }
13833
+
13834
+ // src/skills/bootstrap/clients.ts
13835
+ var CLIENT_MANIFEST = [
13836
+ { id: "agents", dir: ".agents/skills" },
13837
+ // cross-agent standard (symlink canonical store)
13838
+ { id: "claude", dir: ".claude/skills" },
13839
+ { id: "windsurf", dir: ".windsurf/skills" },
13840
+ { id: "continue", dir: ".continue/skills" },
13841
+ { id: "roo", dir: ".roo/skills" },
13842
+ { id: "kilocode", dir: ".kilocode/skills" },
13843
+ { id: "crush", dir: ".crush/skills" },
13844
+ { id: "goose", dir: ".goose/skills" },
13845
+ { id: "qwen", dir: ".qwen/skills" },
13846
+ { id: "iflow", dir: ".iflow/skills" },
13847
+ { id: "junie", dir: ".junie/skills" },
13848
+ { id: "kiro", dir: ".kiro/skills" },
13849
+ { id: "trae", dir: ".trae/skills" },
13850
+ { id: "augment", dir: ".augment/skills" },
13851
+ { id: "factory", dir: ".factory/skills" },
13852
+ { id: "devin", dir: ".devin/skills" },
13853
+ { id: "openhands", dir: ".openhands/skills" },
13854
+ { id: "pi", dir: ".pi/skills" },
13855
+ { id: "cortex", dir: ".cortex/skills" },
13856
+ { id: "zencoder", dir: ".zencoder/skills" },
13857
+ { id: "codebuddy", dir: ".codebuddy/skills" },
13858
+ { id: "codestudio", dir: ".codestudio/skills" },
13859
+ { id: "commandcode", dir: ".commandcode/skills" },
13860
+ { id: "codemaker", dir: ".codemaker/skills" },
13861
+ { id: "codeartsdoer", dir: ".codeartsdoer/skills" },
13862
+ { id: "lingma", dir: ".lingma/skills" },
13863
+ { id: "qoder", dir: ".qoder/skills" },
13864
+ { id: "rovodev", dir: ".rovodev/skills" },
13865
+ { id: "moxby", dir: ".moxby/skills" },
13866
+ { id: "mux", dir: ".mux/skills" },
13867
+ { id: "neovate", dir: ".neovate/skills" },
13868
+ { id: "ona", dir: ".ona/skills" },
13869
+ { id: "pochi", dir: ".pochi/skills" },
13870
+ { id: "reasonix", dir: ".reasonix/skills" },
13871
+ { id: "terramind", dir: ".terramind/skills" },
13872
+ { id: "tinycloud", dir: ".tinycloud/skills" },
13873
+ { id: "vibe", dir: ".vibe/skills" },
13874
+ { id: "adal", dir: ".adal/skills" },
13875
+ { id: "aider-desk", dir: ".aider-desk/skills" },
13876
+ { id: "autohand", dir: ".autohand/skills" },
13877
+ { id: "bob", dir: ".bob/skills" },
13878
+ { id: "hermes", dir: ".hermes/skills" },
13879
+ { id: "inferencesh", dir: ".inferencesh/skills" },
13880
+ { id: "jazz", dir: ".jazz/skills" },
13881
+ { id: "kode", dir: ".kode/skills" },
13882
+ { id: "mcpjam", dir: ".mcpjam/skills" },
13883
+ { id: "forge", dir: ".forge/skills" },
13884
+ { id: "tabnine", dir: ".tabnine/agent/skills" }
13885
+ // exception: nested under agent/
13886
+ ];
13887
+
13888
+ // src/skills/bootstrap/exulu-sh.generated.ts
13889
+ var EXULU_SH_B64 = "IyEvYmluL3NoCiMgZXh1bHUg4oCUIGhlbHBlciBmb3IgdGhlIEV4dWx1IGNlbnRyYWwgc2tpbGwgbGlicmFyeS4gVGhlIGFnZW50IGludm9rZXMgdGhpcwojIChuZXZlciByYXcgY3VybCk6IHRoZSB0b2tlbiBpcyByZWFkIGZyb20gdGhlIGNvbmZpZyBmaWxlIGhlcmUgYW5kIHNlbnQgdmlhCiMgYHgtYXBpLWtleTogQmVhcmVyYCwgc28gaXQgbmV2ZXIgZW50ZXJzIHRoZSBtb2RlbCBjb250ZXh0LiBDbGllbnQgZmFuLW91dAojIChjb3B5L3N5bWxpbmsgYWNyb3NzIGFnZW50IGNsaWVudHMpIGlzIGRldGVybWluaXN0aWMuCnNldCAtZXUKCkNPTkZJR19ESVI9IiRIT01FLy5jb25maWcvZXh1bHUiCkNPTkZJR19GSUxFPSIkQ09ORklHX0RJUi9za2lsbHMuanNvbiIKCmRpZSgpIHsgcHJpbnRmICdleHVsdTogJXNcbicgIiQqIiA+JjI7IGV4aXQgMTsgfQppbmZvKCkgeyBwcmludGYgJyVzXG4nICIkKiIgPiYyOyB9Cgpqc29uX3N0cigpIHsgIyBqc29uX3N0ciA8a2V5PiA8ZmlsZT4g4oCUIGZsYXQgImtleSI6InZhbHVlIgogIHNlZCAtbiAicy8uKlwiJDFcIltbOnNwYWNlOl1dKjpbWzpzcGFjZTpdXSpcIlxcKFteXCJdKlxcKVwiLiovXFwxL3AiICIkMiIgfCBoZWFkIC1uMQp9CgpbIC1mICIkQ09ORklHX0ZJTEUiIF0gfHwgZGllICJub3QgY29uZmlndXJlZCDigJQgcnVuOiBjdXJsIC1mc1NMIDxiYXNlX3VybD4vYXBpL3NraWxscy9pbnN0YWxsLnNoIHwgc2giCgpCQVNFX1VSTD0iJChqc29uX3N0ciBiYXNlX3VybCAiJENPTkZJR19GSUxFIikiCkJBQ0tFTkQ9IiQoanNvbl9zdHIgYmFja2VuZCAiJENPTkZJR19GSUxFIikiCkFQSV9LRVk9IiQoanNvbl9zdHIgYXBpX2tleSAiJENPTkZJR19GSUxFIikiCkxJTktfTU9ERT0iJChqc29uX3N0ciBsaW5rX21vZGUgIiRDT05GSUdfRklMRSIpIgpTQ09QRT0iJChqc29uX3N0ciBzY29wZSAiJENPTkZJR19GSUxFIikiCkNMSUVOVFM9IiQoc2VkIC1uICdzLy4qImNsaWVudHMiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKlxbXChbXl1dKlwpXF0uKi9cMS9wJyAiJENPTkZJR19GSUxFIiB8IHRyICcsJyAnICcgfCB0ciAtZCAnIicgfCB0ciAtcyAnICcpIgoKWyAtbiAiJENMSUVOVFMiIF0gfHwgQ0xJRU5UUz0iYWdlbnRzIgpbIC1uICIkTElOS19NT0RFIiBdIHx8IExJTktfTU9ERT0iY29weSIKWyAtbiAiJFNDT1BFIiBdIHx8IFNDT1BFPSJwcm9qZWN0IgoKaWYgWyAteiAiJEJBQ0tFTkQiIF07IHRoZW4KICBbIC1uICIkQkFTRV9VUkwiIF0gfHwgZGllICJjb25maWcgaGFzIG5vIGJhY2tlbmQgYW5kIG5vIGJhc2VfdXJsIgogIEJBQ0tFTkQ9IiQoY3VybCAtZnNTTCAiJEJBU0VfVVJML2FwaS9jb25maWciIHwgc2VkIC1uICdzLy4qImJhY2tlbmQiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKiJcKFteIl0qXCkiLiovXDEvcCcgfCBoZWFkIC1uMSkiCiAgWyAtbiAiJEJBQ0tFTkQiIF0gfHwgZGllICJjb3VsZCBub3QgcmVzb2x2ZSBiYWNrZW5kIGZyb20gJEJBU0VfVVJML2FwaS9jb25maWciCmZpCkJBQ0tFTkQ9IiQocHJpbnRmICclcycgIiRCQUNLRU5EIiB8IHNlZCAnczovKiQ6OicpIgoKUk9PVD0iJFBXRCIKWyAiJFNDT1BFIiA9ICJob21lIiBdICYmIFJPT1Q9IiRIT01FIgoKIyBkaXJfZm9yIENMSUVOVF9JRCAtPiByZWxhdGl2ZSBza2lsbCBkaXIgKGdlbmVyYXRlZCBmcm9tIHRoZSBtYW5pZmVzdCBpbiB0aGUKIyByZWFsIGJ1aWxkOyBhIHJlcHJlc2VudGF0aXZlIHN1YnNldCBoZXJlIGZvciBsb2NhbCB0ZXN0aW5nKS4KZGlyX2ZvcigpIHsKICBjYXNlICIkMSIgaW4KX19ESVJfRk9SX0NBU0VTX18KICAgICopIHJldHVybiAxIDs7CiAgZXNhYwp9CgphcGkoKSB7ICMgYXBpIDxNRVRIT0Q+IDxwYXRoPiBbZXh0cmEgY3VybCBhcmdzLi4uXSAtPiBib2R5IG9uIHN0ZG91dAogIG09IiQxIjsgcD0iJDIiOyBzaGlmdCAyCiAgY3VybCAtZnNTIC1YICIkbSIgIiRCQUNLRU5EJHAiIC1IICJ4LWFwaS1rZXk6IEJlYXJlciAkQVBJX0tFWSIgIiRAIgp9CgptZXRhX3ZlcnNpb24oKSB7ICMgbWV0YV92ZXJzaW9uIDxuYW1lPiAtPiBjdXJyZW50X3ZlcnNpb24gZnJvbSByZWdpc3RyeQogIGFwaSBHRVQgIi9za2lsbHMvcmVnaXN0cnkvJDEiIDI+L2Rldi9udWxsIFwKICAgIHwgc2VkIC1uICdzLy4qImN1cnJlbnRfdmVyc2lvbiJbWzpzcGFjZTpdXSo6W1s6c3BhY2U6XV0qXChbMC05XVswLTldKlwpLiovXDEvcCcgfCBoZWFkIC1uMQp9CgpfbWFuYWdlZCgpIHsgIyBfbWFuYWdlZCA8ZGlyPiAtPiAwIGlmIHNhZmUgdG8gb3ZlcndyaXRlIChvdXJzIG9yIHN5bWxpbmsgb3IgYWJzZW50KQogIGQ9IiQxIgogIGlmIFsgLWUgIiRkIiBdICYmIFsgISAtTCAiJGQiIF0gJiYgWyAhIC1mICIkZC8uZXh1bHUtc2tpbGwuanNvbiIgXTsgdGhlbgogICAgaW5mbyAic2tpcCAkZCAoZXhpc3RzLCBub3QgbWFuYWdlZCBieSBleHVsdSkiOyByZXR1cm4gMQogIGZpCiAgcmV0dXJuIDAKfQoKX3B1dF9yZWFsKCkgeyAjIF9wdXRfcmVhbCA8ZGVzdD4gPHNyY2Rpcj4gPG1hcmtlci1qc29uPgogIF9tYW5hZ2VkICIkMSIgfHwgcmV0dXJuIDAKICBybSAtcmYgIiQxIjsgbWtkaXIgLXAgIiQxIjsgY3AgLVIgIiQyLy4iICIkMS8iCiAgcHJpbnRmICclc1xuJyAiJDMiID4gIiQxLy5leHVsdS1za2lsbC5qc29uIgp9CgpwbGFjZV9za2lsbCgpIHsgIyBwbGFjZV9za2lsbCA8bmFtZT4gPHNyY2Rpcj4gPHZlcnNpb24+CiAgcG5hbWU9IiQxIjsgcHNyYz0iJDIiOyBwdmVyPSIkezM6LTF9IgogIG1hcmtlcj0neyAibmFtZSI6ICInIiRwbmFtZSInIiwgInZlcnNpb24iOiAnIiRwdmVyIicsICJzb3VyY2UiOiAiJyIkQkFDS0VORCInIiB9JwogIGNhbm9uPSIkUk9PVC8uYWdlbnRzL3NraWxscy8kcG5hbWUiCiAgWyAiJExJTktfTU9ERSIgPSAic3ltbGluayIgXSAmJiBfcHV0X3JlYWwgIiRjYW5vbiIgIiRwc3JjIiAiJG1hcmtlciIKICBmb3IgaWQgaW4gJENMSUVOVFM7IGRvCiAgICBkPSIkKGRpcl9mb3IgIiRpZCIpIiB8fCB7IGluZm8gInVua25vd24gY2xpZW50OiAkaWQiOyBjb250aW51ZTsgfQogICAgcGFyZW50PSIkUk9PVC8kZCI7IGRlc3Q9IiRwYXJlbnQvJHBuYW1lIgogICAgaWYgWyAiJExJTktfTU9ERSIgPSAic3ltbGluayIgXSAmJiBbICIkaWQiICE9ICJhZ2VudHMiIF07IHRoZW4KICAgICAgX21hbmFnZWQgIiRkZXN0IiB8fCBjb250aW51ZQogICAgICBta2RpciAtcCAiJHBhcmVudCI7IHJtIC1yZiAiJGRlc3QiCiAgICAgIGlmIGxuIC1zICIkY2Fub24iICIkZGVzdCIgMj4vZGV2L251bGw7IHRoZW4KICAgICAgICBpbmZvICJsaW5rZWQgJGRlc3QgLT4gJGNhbm9uIgogICAgICBlbHNlCiAgICAgICAgaW5mbyAic3ltbGluayB1bnN1cHBvcnRlZCBhdCAkZGVzdDsgY29weWluZyIKICAgICAgICBfcHV0X3JlYWwgIiRkZXN0IiAiJHBzcmMiICIkbWFya2VyIgogICAgICBmaQogICAgZWxpZiBbICIkTElOS19NT0RFIiA9ICJjb3B5IiBdOyB0aGVuCiAgICAgIF9wdXRfcmVhbCAiJGRlc3QiICIkcHNyYyIgIiRtYXJrZXIiCiAgICBmaQogICAgIyBzeW1saW5rICsgYWdlbnRzOiBhbHJlYWR5IHBsYWNlZCBhcyB0aGUgY2Fub25pY2FsIHN0b3JlIGFib3ZlLgogIGRvbmUKfQoKaW5zdGFsbGVkX25hbWVzKCkgeyAjIHVuaXF1ZSBza2lsbCBuYW1lcyB0aGF0IGNhcnJ5IG91ciBtYXJrZXIgdW5kZXIgUk9PVAogIHsgZmluZCAiJFJPT1QvLmFnZW50cy9za2lsbHMiIC1tYXhkZXB0aCAyIC1uYW1lIC5leHVsdS1za2lsbC5qc29uIDI+L2Rldi9udWxsCiAgICBmb3IgaWQgaW4gJENMSUVOVFM7IGRvCiAgICAgIGQ9IiQoZGlyX2ZvciAiJGlkIikiIHx8IGNvbnRpbnVlCiAgICAgIGZpbmQgIiRST09ULyRkIiAtbWF4ZGVwdGggMiAtbmFtZSAuZXh1bHUtc2tpbGwuanNvbiAyPi9kZXYvbnVsbAogICAgZG9uZQogIH0gfCB3aGlsZSByZWFkIC1yIG07IGRvIGpzb25fc3RyIG5hbWUgIiRtIjsgZG9uZSB8IHNvcnQgLXUKfQoKbWFya2VyX3ZlcnNpb24oKSB7ICMgbWFya2VyX3ZlcnNpb24gPG5hbWU+CiAgZm9yIGJhc2UgaW4gIiRST09ULy5hZ2VudHMvc2tpbGxzIjsgZG8KICAgIFsgLWYgIiRiYXNlLyQxLy5leHVsdS1za2lsbC5qc29uIiBdICYmIHsganNvbl9zdHIgdmVyc2lvbiAiJGJhc2UvJDEvLmV4dWx1LXNraWxsLmpzb24iOyByZXR1cm47IH0KICBkb25lCiAgZm9yIGlkIGluICRDTElFTlRTOyBkbwogICAgZD0iJChkaXJfZm9yICIkaWQiKSIgfHwgY29udGludWUKICAgIGY9IiRST09ULyRkLyQxLy5leHVsdS1za2lsbC5qc29uIgogICAgWyAtZiAiJGYiIF0gJiYgeyBqc29uX3N0ciB2ZXJzaW9uICIkZiI7IHJldHVybjsgfQogIGRvbmUKfQoKZG9faW5zdGFsbCgpIHsgIyBkb19pbnN0YWxsIDxuYW1lPgogIG5hbWU9IiQxIgogIFRNUD0iJChta3RlbXAgLWQpIgogIGFwaSBHRVQgIi9za2lsbHMvcmVnaXN0cnkvJG5hbWUvZG93bmxvYWQiIC0tb3V0cHV0ICIkVE1QL3NraWxsLnppcCIgXAogICAgfHwgeyBybSAtcmYgIiRUTVAiOyBkaWUgImRvd25sb2FkIGZhaWxlZCBmb3IgJyRuYW1lJyAoNDAzID0gbm8gYWNjZXNzLCA0MDQgPSB1bmtub3duKSI7IH0KICBta2RpciAtcCAiJFRNUC94IgogIHVuemlwIC1xICIkVE1QL3NraWxsLnppcCIgLWQgIiRUTVAveCIgfHwgeyBybSAtcmYgIiRUTVAiOyBkaWUgImJhZCBhcmNoaXZlIGZvciAnJG5hbWUnIjsgfQogIHNyYz0iJChmaW5kICIkVE1QL3giIC1taW5kZXB0aCAxIC1tYXhkZXB0aCAxIC10eXBlIGQgfCBoZWFkIC1uMSkiCiAgWyAtbiAiJHNyYyIgXSB8fCB7IHJtIC1yZiAiJFRNUCI7IGRpZSAidW5leHBlY3RlZCBhcmNoaXZlIGxheW91dCBmb3IgJyRuYW1lJyI7IH0KICB2ZXI9IiQobWV0YV92ZXJzaW9uICIkbmFtZSIpIgogIHBsYWNlX3NraWxsICIkbmFtZSIgIiRzcmMiICIke3ZlcjotMX0iCiAgcm0gLXJmICIkVE1QIgogIGluZm8gImluc3RhbGxlZCAkbmFtZSAodiR7dmVyOi0xfSkgWyRMSU5LX01PREVdIGludG86ICRDTElFTlRTIgp9Cgp1c2FnZSgpIHsKICBjYXQgPiYyIDw8RU9GCmV4dWx1IOKAlCBFeHVsdSBza2lsbCBsaWJyYXJ5IGhlbHBlcgogIGV4dWx1IGxpc3QgICAgICAgICAgICAgICAgIGxpc3Qgc2tpbGxzIHlvdSBjYW4gYWNjZXNzIChKU09OKQogIGV4dWx1IGdldCA8bmFtZT4gICAgICAgICAgIHNob3cgb25lIHNraWxsJ3MgbWV0YWRhdGEgKEpTT04pCiAgZXh1bHUgaW5zdGFsbCA8bmFtZT4gICAgICAgaW5zdGFsbC9yZWZyZXNoIGEgc2tpbGwgaW50byB5b3VyIGFnZW50IGNsaWVudHMKICBleHVsdSB1cGRhdGUgWzxuYW1lPl0gICAgICB1cGRhdGUgaW5zdGFsbGVkIHNraWxscyAoYWxsLCBvciBvbmUpIHRvIGxhdGVzdAogIGV4dWx1IHB1Ymxpc2ggPG5hbWU+IDxkaXI+IHB1Ymxpc2ggYSBsb2NhbCBza2lsbCBmb2xkZXIgYXMgPG5hbWU+CiAgZXh1bHUgY29uZmlnICAgICAgICAgICAgICAgc2hvdyByZXNvbHZlZCBiYWNrZW5kIC8gc2NvcGUgLyBjbGllbnRzIChubyBzZWNyZXRzKQpFT0YKfQoKY21kPSIkezE6LWhlbHB9IgpbICQjIC1ndCAwIF0gJiYgc2hpZnQgfHwgdHJ1ZQoKY2FzZSAiJGNtZCIgaW4KICBsaXN0KSBhcGkgR0VUICIvc2tpbGxzL3JlZ2lzdHJ5IiA7OwogIGdldCkgWyAkIyAtZ2UgMSBdIHx8IGRpZSAidXNhZ2U6IGV4dWx1IGdldCA8bmFtZT4iOyBhcGkgR0VUICIvc2tpbGxzL3JlZ2lzdHJ5LyQxIiA7OwogIGluc3RhbGwpIFsgJCMgLWdlIDEgXSB8fCBkaWUgInVzYWdlOiBleHVsdSBpbnN0YWxsIDxuYW1lPiI7IGRvX2luc3RhbGwgIiQxIiA7OwogIHVwZGF0ZSkKICAgIGlmIFsgJCMgLWdlIDEgXTsgdGhlbiBuYW1lcz0iJDEiOyBlbHNlIG5hbWVzPSIkKGluc3RhbGxlZF9uYW1lcykiOyBmaQogICAgWyAtbiAiJG5hbWVzIiBdIHx8IHsgaW5mbyAibm8gZXh1bHUtbWFuYWdlZCBza2lsbHMgZm91bmQgdW5kZXIgJFJPT1QiOyBleGl0IDA7IH0KICAgIGZvciBuIGluICRuYW1lczsgZG8KICAgICAgY3VyPSIkKG1hcmtlcl92ZXJzaW9uICIkbiIpIgogICAgICBsYXRlc3Q9IiQobWV0YV92ZXJzaW9uICIkbiIpIgogICAgICBbIC1uICIkbGF0ZXN0IiBdIHx8IHsgaW5mbyAic2tpcCAkbiAobm90IGluIHJlZ2lzdHJ5KSI7IGNvbnRpbnVlOyB9CiAgICAgIGlmIFsgLXogIiRjdXIiIF0gfHwgWyAiJGxhdGVzdCIgLWd0ICIkY3VyIiBdIDI+L2Rldi9udWxsOyB0aGVuCiAgICAgICAgaW5mbyAidXBkYXRpbmcgJG46IHYke2N1cjotP30gLT4gdiRsYXRlc3QiOyBkb19pbnN0YWxsICIkbiIKICAgICAgZWxzZQogICAgICAgIGluZm8gIiRuIHVwIHRvIGRhdGUgKHYkY3VyKSIKICAgICAgZmkKICAgIGRvbmUKICAgIDs7CiAgcHVibGlzaCkKICAgIFsgJCMgLWdlIDIgXSB8fCBkaWUgInVzYWdlOiBleHVsdSBwdWJsaXNoIDxuYW1lPiA8ZGlyPiIKICAgIG5hbWU9IiQxIjsgZm9sZGVyPSIkMiIKICAgIFsgLWQgIiRmb2xkZXIiIF0gfHwgZGllICJubyBzdWNoIGZvbGRlcjogJGZvbGRlciIKICAgIFsgLWYgIiRmb2xkZXIvU0tJTEwubWQiIF0gfHwgZGllICIkZm9sZGVyIGhhcyBubyBTS0lMTC5tZCBhdCBpdHMgcm9vdCIKICAgIGNvbW1hbmQgLXYgemlwID4vZGV2L251bGwgMj4mMSB8fCBkaWUgInRoZSAnemlwJyBjb21tYW5kIGlzIHJlcXVpcmVkIHRvIHB1Ymxpc2giCiAgICBUTVA9IiQobWt0ZW1wIC1kKSIKICAgICggY2QgIiQoZGlybmFtZSAiJGZvbGRlciIpIiBcCiAgICAgICYmIHppcCAtcSAtciAtWCAiJFRNUC9za2lsbC56aXAiICIkKGJhc2VuYW1lICIkZm9sZGVyIikiIFwKICAgICAgICAgICAteCAnKi8uZXh1bHUtc2tpbGwuanNvbicgJyovLmdpdC8qJyAnKi5EU19TdG9yZScgJyovX19NQUNPU1gvKicgKSBcCiAgICAgIHx8IHsgcm0gLXJmICIkVE1QIjsgZGllICJjb3VsZCBub3QgemlwICRmb2xkZXIiOyB9CiAgICBhcGkgUE9TVCAiL3NraWxscy9yZWdpc3RyeS8kbmFtZSIgLUggIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vemlwIiBcCiAgICAgIC0tZGF0YS1iaW5hcnkgQCIkVE1QL3NraWxsLnppcCIgXAogICAgICB8fCB7IHJtIC1yZiAiJFRNUCI7IGRpZSAicHVibGlzaCBmYWlsZWQgKDQwMyA9IG5vIHdyaXRlIGFjY2VzcywgNDA5ID0gbmFtZSB0YWtlbikiOyB9CiAgICBybSAtcmYgIiRUTVAiCiAgICBpbmZvICJwdWJsaXNoZWQgJG5hbWUiCiAgICA7OwogIGNvbmZpZykKICAgIHByaW50ZiAnYmFja2VuZD0lc1xuc2NvcGU9JXNcbmxpbmtfbW9kZT0lc1xuY2xpZW50cz0lc1xuYXBpX2tleT0lc1xuJyBcCiAgICAgICIkQkFDS0VORCIgIiRTQ09QRSIgIiRMSU5LX01PREUiICIkQ0xJRU5UUyIgXAogICAgICAiJChbIC1uICIkQVBJX0tFWSIgXSAmJiBlY2hvIHNldCB8fCBlY2hvIE1JU1NJTkcpIgogICAgOzsKICBoZWxwfC0taGVscHwtaCkgdXNhZ2UgOzsKICAqKSBpbmZvICJ1bmtub3duIGNvbW1hbmQ6ICRjbWQiOyB1c2FnZTsgZXhpdCAyIDs7CmVzYWMK";
13890
+
13891
+ // src/skills/bootstrap/exulu-skills.ts
13892
+ var BOOTSTRAP_CLIENTS_JSON = JSON.stringify(CLIENT_MANIFEST, null, 2);
13893
+ var DIR_FOR_CASES = CLIENT_MANIFEST.map(
13894
+ (c) => ` ${c.id}) printf '%s' '${c.dir}' ;;`
13895
+ ).join("\n");
13896
+ var BOOTSTRAP_EXULU_SH = Buffer.from(EXULU_SH_B64, "base64").toString("utf8").replace("__DIR_FOR_CASES__", DIR_FOR_CASES);
13897
+ var BOOTSTRAP_SKILL_MD = `---
13898
+ name: exulu-skills
13899
+ description: Install, update, and publish skills from this Exulu instance's central skill library. Use when the user asks to install a skill, get the latest version of a skill, list available skills, or publish a skill to Exulu.
13900
+ ---
13901
+
13902
+ # Exulu Skills
13903
+
13904
+ Bridge to the Exulu central skill library. **All operations go through the
13905
+ bundled helper script \u2014 do not hand-write curl or copy files yourself.** The
13906
+ script reads the API token from config (keeping it out of this conversation) and
13907
+ handles the multi-client copy/symlink fan-out deterministically.
13908
+
13909
+ ## The helper
13910
+
13911
+ Run the script next to this file, \`scripts/exulu\`, with \`sh\` and the absolute
13912
+ path of this skill's directory:
13913
+
13914
+ \`\`\`
13915
+ sh "<this-skill-dir>/scripts/exulu" <command>
13916
+ \`\`\`
13917
+
13918
+ Commands:
13919
+ - \`list\` \u2014 skills you can access (JSON on stdout)
13920
+ - \`get <name>\` \u2014 one skill's metadata (JSON)
13921
+ - \`install <name>\` \u2014 install/refresh a skill into the user's agent clients
13922
+ - \`update [<name>]\` \u2014 update every installed skill, or just \`<name>\`, to latest
13923
+ - \`publish <name> <folder>\` \u2014 publish a local skill folder as \`<name>\`
13924
+ - \`config\` \u2014 show resolved backend / scope / clients (prints no secrets)
13925
+
13926
+ The token, backend URL, target clients, copy-vs-symlink mode, and scope all come
13927
+ from \`~/.config/exulu/skills.json\` (written by the installer). Never print the
13928
+ \`api_key\` or read it into your reply \u2014 the script uses it internally.
13929
+
13930
+ ## Requests \u2192 commands
13931
+
13932
+ - "list / search skills" \u2192 \`exulu list\`, then filter the JSON for the user.
13933
+ - "install skill X" / "add the X skill" \u2192 \`exulu install X\`.
13934
+ - "update / get the latest version [of X]" \u2192 \`exulu update [X]\`.
13935
+ - "publish / upload this skill as X" \u2192 confirm the target name with the user; for
13936
+ an existing skill run \`exulu get X\` first and confirm a new version is intended;
13937
+ then \`exulu publish X <folder>\`.
13938
+
13939
+ ## Not configured yet?
13940
+
13941
+ If \`exulu config\` reports it's not configured (or \`~/.config/exulu/skills.json\`
13942
+ is missing), tell the user to run the installer \u2014 it sets everything up
13943
+ interactively (base URL, API key, target clients, copy/symlink):
13944
+
13945
+ \`\`\`
13946
+ curl -fsSL <base_url>/api/skills/install.sh | sh
13947
+ \`\`\`
13948
+
13949
+ \`<base_url>\` is their Exulu frontend URL (e.g. https://ai.open.de). They can
13950
+ create an API key at \`<base_url>/token\`.
13951
+
13952
+ ## Errors
13953
+
13954
+ - install: \`403\` = no access to that skill; \`404\` = unknown name.
13955
+ - publish: \`403\` = you can see it but lack write access; \`409\` = the name is
13956
+ taken by a skill you can't access.
13957
+ `;
13958
+
13766
13959
  // src/exulu/routes.ts
13767
13960
  var REQUEST_SIZE_LIMIT = "50mb";
13768
13961
  var getExuluVersionNumber = async () => {
@@ -13836,6 +14029,7 @@ var createExpressRoutes = async (app, providers, tools, contexts, config, evals,
13836
14029
  }
13837
14030
  next();
13838
14031
  });
14032
+ const rawZip = express2.raw({ type: ["application/zip", "application/octet-stream", "application/x-zip-compressed", "application/x-zip"], limit: "50mb" });
13839
14033
  console.log(`
13840
14034
  \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557
13841
14035
  \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u255A\u2588\u2588\u2557\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551
@@ -15488,7 +15682,9 @@ ${style.markdown}` : params.prompt;
15488
15682
  const budget_duration = String(body?.budget_duration ?? "");
15489
15683
  if (!Number.isFinite(max_budget) || max_budget <= 0) return null;
15490
15684
  if (!BUDGET_ALLOWED_DURATIONS.has(budget_duration)) return null;
15491
- return { max_budget, budget_duration };
15685
+ const reset = parseResetAt(body?.budget_reset_at);
15686
+ if (!reset.valid) return null;
15687
+ return { max_budget, budget_duration, budget_reset_at: reset.value };
15492
15688
  };
15493
15689
  const parseBudgetSettingsBody = (body) => {
15494
15690
  if (!body || typeof body !== "object") return null;
@@ -15558,7 +15754,7 @@ ${style.markdown}` : params.prompt;
15558
15754
  }
15559
15755
  const body = parseBudgetBody(req.body);
15560
15756
  if (!body) {
15561
- res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration)." });
15757
+ res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration, budget_reset_at)." });
15562
15758
  return;
15563
15759
  }
15564
15760
  const entityIds = Array.isArray(req.body?.entityIds) ? req.body.entityIds : [];
@@ -15570,7 +15766,7 @@ ${style.markdown}` : params.prompt;
15570
15766
  for (const id of entityIds) {
15571
15767
  const tag = budgetTagFor(entityType, id);
15572
15768
  try {
15573
- await upsertBudget(tag, body.max_budget, body.budget_duration);
15769
+ await upsertBudget(tag, body.max_budget, body.budget_duration, body.budget_reset_at);
15574
15770
  results.push({ entityId: String(id), ok: true });
15575
15771
  } catch (err) {
15576
15772
  results.push({
@@ -15595,12 +15791,12 @@ ${style.markdown}` : params.prompt;
15595
15791
  }
15596
15792
  const body = parseBudgetBody(req.body);
15597
15793
  if (!body) {
15598
- res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration)." });
15794
+ res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration, budget_reset_at)." });
15599
15795
  return;
15600
15796
  }
15601
15797
  const tag = budgetTagFor(entityType, req.params.entityId ?? "");
15602
15798
  try {
15603
- await upsertBudget(tag, body.max_budget, body.budget_duration);
15799
+ await upsertBudget(tag, body.max_budget, body.budget_duration, body.budget_reset_at);
15604
15800
  const info = await tagInfo([tag]);
15605
15801
  res.status(200).json({ budget: info[tag] ?? null });
15606
15802
  } catch (err) {
@@ -16063,6 +16259,199 @@ ${style.markdown}` : params.prompt;
16063
16259
  }
16064
16260
  return root;
16065
16261
  }
16262
+ app.get("/skills/agent/bootstrap", async (_req, res) => {
16263
+ try {
16264
+ const zip = new JSZip3();
16265
+ zip.file("exulu-skills/SKILL.md", BOOTSTRAP_SKILL_MD);
16266
+ zip.file("exulu-skills/references/clients.json", BOOTSTRAP_CLIENTS_JSON);
16267
+ zip.file("exulu-skills/scripts/exulu", BOOTSTRAP_EXULU_SH, {
16268
+ unixPermissions: 493
16269
+ });
16270
+ const buffer = await zip.generateAsync({
16271
+ type: "nodebuffer",
16272
+ platform: "UNIX"
16273
+ });
16274
+ res.setHeader("Content-Type", "application/zip");
16275
+ res.setHeader("Content-Disposition", 'attachment; filename="exulu-skills.zip"');
16276
+ res.send(buffer);
16277
+ } catch (err) {
16278
+ console.error("[SKILLS] Failed to build bootstrap zip", err);
16279
+ res.status(500).json({ detail: "Failed to build bootstrap skill." });
16280
+ }
16281
+ });
16282
+ app.get("/skills/registry", async (req, res) => {
16283
+ const authResult = await requestValidators.authenticate(req);
16284
+ if (!authResult.user?.id) {
16285
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
16286
+ return;
16287
+ }
16288
+ const { db } = await postgresClient();
16289
+ const tag = typeof req.query.tag === "string" ? req.query.tag : void 0;
16290
+ const all = await db("skills").select("*");
16291
+ const readable = await filterReadableSkills(db, all, authResult.user);
16292
+ const skills = readable.filter((s) => {
16293
+ if (!tag) return true;
16294
+ const tags = Array.isArray(s.tags) ? s.tags : [];
16295
+ return tags.includes(tag);
16296
+ }).map((s) => ({
16297
+ name: s.name,
16298
+ description: s.description ?? "",
16299
+ tags: Array.isArray(s.tags) ? s.tags : [],
16300
+ current_version: s.current_version ?? 1,
16301
+ updated_at: s.updatedAt ?? s.updated_at ?? null
16302
+ }));
16303
+ res.json({ skills });
16304
+ });
16305
+ app.get("/skills/registry/:name/download", async (req, res) => {
16306
+ const authResult = await requestValidators.authenticate(req);
16307
+ if (!authResult.user?.id) {
16308
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
16309
+ return;
16310
+ }
16311
+ const { db } = await postgresClient();
16312
+ const skill = await resolveSkillByName(db, req.params.name);
16313
+ if (!skill) {
16314
+ res.status(404).json({ detail: "Skill not found." });
16315
+ return;
16316
+ }
16317
+ if (!await canAccessSkill(db, skill, "read", authResult.user)) {
16318
+ res.status(403).json({ detail: "You don't have access to this skill." });
16319
+ return;
16320
+ }
16321
+ const vQuery = req.query.version;
16322
+ const version = !vQuery || vQuery === "latest" ? skill.current_version ?? 1 : Number(vQuery);
16323
+ if (!Number.isFinite(version) || version < 1) {
16324
+ res.status(400).json({ detail: "Invalid version." });
16325
+ return;
16326
+ }
16327
+ const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
16328
+ const versionPrefix = `skills/${skill.id}/v${version}/`;
16329
+ const files = await listS3ObjectsByPrefix(versionPrefix, config);
16330
+ if (files.length === 0) {
16331
+ res.status(404).json({ detail: `Version v${version} has no files.` });
16332
+ return;
16333
+ }
16334
+ const zip = new JSZip3();
16335
+ for (const file of files) {
16336
+ const idx = file.key.indexOf(versionPrefix);
16337
+ const rel = idx >= 0 ? file.key.slice(idx + versionPrefix.length) : file.key;
16338
+ if (!rel) continue;
16339
+ const bytes = await getS3ObjectBytes(file.key, config);
16340
+ zip.file(`${safeName}/${rel}`, bytes);
16341
+ }
16342
+ const buffer = await zip.generateAsync({ type: "nodebuffer" });
16343
+ res.setHeader("Content-Type", "application/zip");
16344
+ res.setHeader("Content-Disposition", `attachment; filename="${safeName}.skill"`);
16345
+ res.send(buffer);
16346
+ });
16347
+ app.post("/skills/registry/:name", rawZip, async (req, res) => {
16348
+ const authResult = await requestValidators.authenticate(req);
16349
+ if (!authResult.user?.id) {
16350
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
16351
+ return;
16352
+ }
16353
+ const name = req.params.name;
16354
+ const bytes = req.body;
16355
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
16356
+ res.status(400).json({ detail: "Empty body. Send the skill as a zip/.skill payload." });
16357
+ return;
16358
+ }
16359
+ const { db } = await postgresClient();
16360
+ const existing = await resolveSkillByName(db, name);
16361
+ if (existing) {
16362
+ const canWrite = await canAccessSkill(db, existing, "write", authResult.user);
16363
+ if (canWrite) {
16364
+ const nextVersion = (existing.current_version ?? 1) + 1;
16365
+ try {
16366
+ await extractBundleToVersion({ bytes, skillId: existing.id, version: nextVersion, config });
16367
+ } catch (err) {
16368
+ if (err instanceof BundleValidationError) {
16369
+ res.status(400).json({ detail: err.message });
16370
+ return;
16371
+ }
16372
+ console.error("[SKILLS] publish (new version) failed", err);
16373
+ res.status(500).json({ detail: "Failed to publish new version." });
16374
+ return;
16375
+ }
16376
+ const history = Array.isArray(existing.history) ? existing.history : [];
16377
+ await db("skills").where({ id: existing.id }).update({
16378
+ current_version: nextVersion,
16379
+ history: JSON.stringify([
16380
+ ...history,
16381
+ { version: nextVersion, created_at: (/* @__PURE__ */ new Date()).toISOString(), label: "Published from agent" }
16382
+ ])
16383
+ });
16384
+ res.json({ name, version: nextVersion, created: false });
16385
+ return;
16386
+ } else {
16387
+ const canRead = await canAccessSkill(db, existing, "read", authResult.user);
16388
+ if (!canRead) {
16389
+ res.status(409).json({ detail: "That name is unavailable." });
16390
+ return;
16391
+ }
16392
+ res.status(403).json({ detail: "You don't have write access to this skill." });
16393
+ return;
16394
+ }
16395
+ }
16396
+ const meta = await parseSkillFrontmatter(bytes);
16397
+ const skillId = randomUUID3();
16398
+ try {
16399
+ await extractBundleToVersion({ bytes, skillId, version: 1, config });
16400
+ } catch (err) {
16401
+ if (err instanceof BundleValidationError) {
16402
+ res.status(400).json({ detail: err.message });
16403
+ return;
16404
+ }
16405
+ console.error("[SKILLS] publish (create) failed", err);
16406
+ res.status(500).json({ detail: "Failed to publish skill." });
16407
+ return;
16408
+ }
16409
+ try {
16410
+ await db("skills").insert({
16411
+ id: skillId,
16412
+ name,
16413
+ description: meta.description ?? "",
16414
+ s3folder: `skills/${skillId}`,
16415
+ tags: JSON.stringify([]),
16416
+ usage_count: 0,
16417
+ favorite_count: 0,
16418
+ current_version: 1,
16419
+ history: JSON.stringify([
16420
+ { version: 1, created_at: (/* @__PURE__ */ new Date()).toISOString(), label: "Published from agent" }
16421
+ ]),
16422
+ rights_mode: "private",
16423
+ created_by: authResult.user.id
16424
+ });
16425
+ } catch (err) {
16426
+ res.status(409).json({ detail: "That name is unavailable." });
16427
+ return;
16428
+ }
16429
+ res.json({ name, version: 1, created: true });
16430
+ });
16431
+ app.get("/skills/registry/:name", async (req, res) => {
16432
+ const authResult = await requestValidators.authenticate(req);
16433
+ if (!authResult.user?.id) {
16434
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
16435
+ return;
16436
+ }
16437
+ const { db } = await postgresClient();
16438
+ const skill = await resolveSkillByName(db, req.params.name);
16439
+ if (!skill) {
16440
+ res.status(404).json({ detail: "Skill not found." });
16441
+ return;
16442
+ }
16443
+ if (!await canAccessSkill(db, skill, "read", authResult.user)) {
16444
+ res.status(403).json({ detail: "You don't have access to this skill." });
16445
+ return;
16446
+ }
16447
+ res.json({
16448
+ name: skill.name,
16449
+ description: skill.description ?? "",
16450
+ tags: Array.isArray(skill.tags) ? skill.tags : [],
16451
+ current_version: skill.current_version ?? 1,
16452
+ history: Array.isArray(skill.history) ? skill.history : []
16453
+ });
16454
+ });
16066
16455
  app.post("/skills/:skillId/init", async (req, res) => {
16067
16456
  const authResult = await requestValidators.authenticate(req);
16068
16457
  if (!authResult.user?.id) {
@@ -16110,8 +16499,8 @@ ${style.markdown}` : params.prompt;
16110
16499
  }
16111
16500
  const { skillId } = req.params;
16112
16501
  const { extension, contentType } = req.body ?? {};
16113
- if (extension !== ".zip" && extension !== ".md") {
16114
- res.status(400).json({ detail: 'extension must be ".zip" or ".md".' });
16502
+ if (extension !== ".zip" && extension !== ".md" && extension !== ".skill") {
16503
+ res.status(400).json({ detail: 'extension must be ".zip", ".md", or ".skill".' });
16115
16504
  return;
16116
16505
  }
16117
16506
  if (!contentType || typeof contentType !== "string") {
@@ -16250,19 +16639,22 @@ ${style.markdown}` : params.prompt;
16250
16639
  }
16251
16640
  const versionPrefix = `skills/${skillId}/v${version}/`;
16252
16641
  const files = await listS3ObjectsByPrefix(versionPrefix, config);
16253
- const zip = new JSZip2();
16642
+ const asSkill = req.query.format === "skill";
16643
+ const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
16644
+ const zip = new JSZip3();
16254
16645
  let fileCount = 0;
16255
16646
  for (const file of files) {
16256
16647
  const prefixIndex = file.key.indexOf(versionPrefix);
16257
16648
  const relativePath = prefixIndex >= 0 ? file.key.slice(prefixIndex + versionPrefix.length) : file.key;
16258
16649
  if (!relativePath) continue;
16259
16650
  const bytes = await getS3ObjectBytes(file.key, config);
16260
- zip.file(relativePath, bytes);
16651
+ const archivePath = asSkill ? `${safeName}/${relativePath}` : relativePath;
16652
+ zip.file(archivePath, bytes);
16261
16653
  fileCount += 1;
16262
16654
  }
16263
16655
  const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
16264
16656
  zip.file(
16265
- "version.txt",
16657
+ asSkill ? `${safeName}/version.txt` : "version.txt",
16266
16658
  [
16267
16659
  `Skill: ${skill.name ?? skillId}`,
16268
16660
  `Skill id: ${skillId}`,
@@ -16273,13 +16665,9 @@ ${style.markdown}` : params.prompt;
16273
16665
  ].join("\n")
16274
16666
  );
16275
16667
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
16276
- const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
16277
- const filename = `${safeName}-v${version}.zip`;
16668
+ const filename = asSkill ? `${safeName}.skill` : `${safeName}-v${version}.zip`;
16278
16669
  res.setHeader("Content-Type", "application/zip");
16279
- res.setHeader(
16280
- "Content-Disposition",
16281
- `attachment; filename="${filename}"`
16282
- );
16670
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
16283
16671
  res.send(buffer);
16284
16672
  });
16285
16673
  app.post("/skills/:skillId/sign", async (req, res) => {
@@ -16579,6 +16967,12 @@ ${style.markdown}` : params.prompt;
16579
16967
  const fullSessionPrefix = `${generalPrefix}${userSessionPrefix}`;
16580
16968
  return { userSessionPrefix, fullSessionPrefix };
16581
16969
  }
16970
+ const loadSessionFilesAuth = async (req, res, sessionId, rights) => {
16971
+ const authed = await loadAuthedSession(req, res, sessionId, rights);
16972
+ if (!authed) return null;
16973
+ const ownerId = authed.session.user ?? authed.user.id;
16974
+ return { ...authed, ownerId, ...buildSessionPrefixes(ownerId, sessionId) };
16975
+ };
16582
16976
  function sanitizeFilename(name) {
16583
16977
  const trimmed = name.trim();
16584
16978
  if (!trimmed) return "";
@@ -16587,11 +16981,6 @@ ${style.markdown}` : params.prompt;
16587
16981
  return trimmed.replace(/[\\/]/g, "_");
16588
16982
  }
16589
16983
  app.get("/sessions/:sessionId/files", async (req, res) => {
16590
- const authResult = await requestValidators.authenticate(req);
16591
- if (!authResult.user?.id) {
16592
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
16593
- return;
16594
- }
16595
16984
  const sessionId = req.params.sessionId;
16596
16985
  if (!sessionId) {
16597
16986
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -16601,10 +16990,9 @@ ${style.markdown}` : params.prompt;
16601
16990
  res.status(500).json({ detail: "File uploads are not configured." });
16602
16991
  return;
16603
16992
  }
16604
- const { userSessionPrefix } = buildSessionPrefixes(
16605
- authResult.user.id,
16606
- sessionId
16607
- );
16993
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "read");
16994
+ if (!authed) return;
16995
+ const { userSessionPrefix } = authed;
16608
16996
  let objects;
16609
16997
  try {
16610
16998
  objects = await listS3ObjectsByPrefix(userSessionPrefix, config);
@@ -16634,11 +17022,6 @@ ${style.markdown}` : params.prompt;
16634
17022
  app.post(
16635
17023
  "/sessions/:sessionId/files/upload-sign",
16636
17024
  async (req, res) => {
16637
- const authResult = await requestValidators.authenticate(req);
16638
- if (!authResult.user?.id) {
16639
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
16640
- return;
16641
- }
16642
17025
  const sessionId = req.params.sessionId;
16643
17026
  if (!sessionId) {
16644
17027
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -16648,6 +17031,8 @@ ${style.markdown}` : params.prompt;
16648
17031
  res.status(500).json({ detail: "File uploads are not configured." });
16649
17032
  return;
16650
17033
  }
17034
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
17035
+ if (!authed) return;
16651
17036
  const { filename, contentType } = req.body ?? {};
16652
17037
  if (!filename || typeof filename !== "string") {
16653
17038
  res.status(400).json({ detail: "Missing filename in request body." });
@@ -16662,11 +17047,7 @@ ${style.markdown}` : params.prompt;
16662
17047
  res.status(400).json({ detail: "Missing contentType in request body." });
16663
17048
  return;
16664
17049
  }
16665
- const { userSessionPrefix, fullSessionPrefix } = buildSessionPrefixes(
16666
- authResult.user.id,
16667
- sessionId
16668
- );
16669
- const fullKey = `${fullSessionPrefix}${safeName}`;
17050
+ const fullKey = `${authed.fullSessionPrefix}${safeName}`;
16670
17051
  const uploadUrl = await getS3SignedUploadUrl(fullKey, contentType, config);
16671
17052
  res.json({ uploadUrl, key: fullKey });
16672
17053
  }
@@ -16674,11 +17055,6 @@ ${style.markdown}` : params.prompt;
16674
17055
  app.post(
16675
17056
  "/sessions/:sessionId/files/sync-to-sandbox",
16676
17057
  async (req, res) => {
16677
- const authResult = await requestValidators.authenticate(req);
16678
- if (!authResult.user?.id) {
16679
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
16680
- return;
16681
- }
16682
17058
  const sessionId = req.params.sessionId;
16683
17059
  if (!sessionId) {
16684
17060
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -16689,18 +17065,16 @@ ${style.markdown}` : params.prompt;
16689
17065
  res.status(400).json({ detail: "Missing key in request body." });
16690
17066
  return;
16691
17067
  }
16692
- const { fullSessionPrefix } = buildSessionPrefixes(
16693
- authResult.user.id,
16694
- sessionId
16695
- );
16696
- if (!key.startsWith(fullSessionPrefix)) {
17068
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
17069
+ if (!authed) return;
17070
+ if (!key.startsWith(authed.fullSessionPrefix)) {
16697
17071
  res.status(403).json({ detail: "Key does not belong to this session." });
16698
17072
  return;
16699
17073
  }
16700
17074
  try {
16701
17075
  const result = await downloadKeyIntoSandbox({
16702
17076
  sessionId,
16703
- userId: authResult.user.id,
17077
+ userId: authed.ownerId,
16704
17078
  fullS3Key: key,
16705
17079
  config
16706
17080
  });
@@ -16714,11 +17088,6 @@ ${style.markdown}` : params.prompt;
16714
17088
  app.delete(
16715
17089
  "/sessions/:sessionId/files",
16716
17090
  async (req, res) => {
16717
- const authResult = await requestValidators.authenticate(req);
16718
- if (!authResult.user?.id) {
16719
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
16720
- return;
16721
- }
16722
17091
  const sessionId = req.params.sessionId;
16723
17092
  if (!sessionId) {
16724
17093
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -16729,11 +17098,9 @@ ${style.markdown}` : params.prompt;
16729
17098
  res.status(400).json({ detail: "Missing or invalid 'key' query parameter." });
16730
17099
  return;
16731
17100
  }
16732
- const { fullSessionPrefix } = buildSessionPrefixes(
16733
- authResult.user.id,
16734
- sessionId
16735
- );
16736
- if (!key.startsWith(fullSessionPrefix)) {
17101
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
17102
+ if (!authed) return;
17103
+ if (!key.startsWith(authed.fullSessionPrefix)) {
16737
17104
  res.status(403).json({ detail: "Key does not belong to this session." });
16738
17105
  return;
16739
17106
  }
@@ -16752,11 +17119,6 @@ ${style.markdown}` : params.prompt;
16752
17119
  if (!req.headers.authorization && typeof req.query.auth === "string") {
16753
17120
  req.headers.authorization = `Bearer ${req.query.auth}`;
16754
17121
  }
16755
- const authResult = await requestValidators.authenticate(req);
16756
- if (!authResult.user?.id) {
16757
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
16758
- return;
16759
- }
16760
17122
  const sessionId = req.params.sessionId;
16761
17123
  if (!sessionId) {
16762
17124
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -16767,11 +17129,9 @@ ${style.markdown}` : params.prompt;
16767
17129
  res.status(400).json({ detail: "Missing or invalid 'key' query parameter." });
16768
17130
  return;
16769
17131
  }
16770
- const { fullSessionPrefix } = buildSessionPrefixes(
16771
- authResult.user.id,
16772
- sessionId
16773
- );
16774
- if (!key.startsWith(fullSessionPrefix)) {
17132
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "read");
17133
+ if (!authed) return;
17134
+ if (!key.startsWith(authed.fullSessionPrefix)) {
16775
17135
  res.status(403).json({ detail: "Key does not belong to this session." });
16776
17136
  return;
16777
17137
  }
@@ -16875,7 +17235,7 @@ ${style.markdown}` : params.prompt;
16875
17235
  ` - tracking.json tracking events linked to the user`,
16876
17236
  ``
16877
17237
  ].join("\n");
16878
- const zip = new JSZip2();
17238
+ const zip = new JSZip3();
16879
17239
  zip.file("README.txt", readme);
16880
17240
  zip.file("user_data.json", JSON.stringify(userExport, null, 2));
16881
17241
  zip.file("sessions.json", JSON.stringify(sessionsWithMessages, null, 2));