@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.cjs CHANGED
@@ -415,7 +415,12 @@ function getS3Client(config) {
415
415
  credentials: {
416
416
  accessKeyId: config.fileUploads.s3key,
417
417
  secretAccessKey: config.fileUploads.s3secret
418
- }
418
+ },
419
+ // AWS SDK >= 3.729 injects x-amz-checksum-crc32 (of an empty body) into
420
+ // presigned PUT URLs, which S3-compatible stores like MinIO reject on
421
+ // upload with a checksum mismatch. WHEN_REQUIRED disables that default.
422
+ requestChecksumCalculation: "WHEN_REQUIRED",
423
+ responseChecksumValidation: "WHEN_REQUIRED"
419
424
  });
420
425
  return s3Client;
421
426
  }
@@ -1667,6 +1672,9 @@ async function tagUpdate(input) {
1667
1672
  budget_duration: input.budget_duration
1668
1673
  });
1669
1674
  }
1675
+ async function budgetUpdate(budget_id, patch) {
1676
+ await call("/budget/update", { budget_id, ...patch });
1677
+ }
1670
1678
  async function tagDelete(name) {
1671
1679
  await call("/tag/delete", { name });
1672
1680
  }
@@ -1675,8 +1683,9 @@ function extractBudget(raw) {
1675
1683
  const max_budget = bt.max_budget ?? raw?.max_budget ?? null;
1676
1684
  const budget_duration = bt.budget_duration ?? raw?.budget_duration ?? null;
1677
1685
  const budget_reset_at = bt.budget_reset_at ?? raw?.budget_reset_at ?? null;
1686
+ const budget_id = bt.budget_id ?? raw?.budget_id ?? null;
1678
1687
  const spend = typeof raw?.spend === "number" ? raw.spend : typeof bt.spend === "number" ? bt.spend : 0;
1679
- return { max_budget, budget_duration, budget_reset_at, spend };
1688
+ return { max_budget, budget_duration, budget_reset_at, budget_id, spend };
1680
1689
  }
1681
1690
  async function listTags() {
1682
1691
  const { url, masterKey } = litellmBase();
@@ -1709,7 +1718,8 @@ async function listTagBudgets() {
1709
1718
  spend: b.spend,
1710
1719
  max_budget: b.max_budget,
1711
1720
  budget_duration: b.budget_duration,
1712
- budget_reset_at: b.budget_reset_at
1721
+ budget_reset_at: b.budget_reset_at,
1722
+ budget_id: b.budget_id
1713
1723
  };
1714
1724
  }
1715
1725
  const names = Object.keys(map);
@@ -1755,7 +1765,8 @@ async function tagInfo(names) {
1755
1765
  spend: b.spend,
1756
1766
  max_budget: b.max_budget,
1757
1767
  budget_duration: b.budget_duration,
1758
- budget_reset_at: b.budget_reset_at
1768
+ budget_reset_at: b.budget_reset_at,
1769
+ budget_id: b.budget_id
1759
1770
  };
1760
1771
  }
1761
1772
  return out;
@@ -1986,7 +1997,16 @@ async function setBudgetSettings(settings) {
1986
1997
  }).onConflict("config_key").merge({ config_value: JSON.stringify(settings) });
1987
1998
  return settings;
1988
1999
  }
1989
- async function upsertBudget(tag, max_budget, budget_duration) {
2000
+ function parseResetAt(raw) {
2001
+ if (raw === void 0 || raw === null || raw === "") {
2002
+ return { valid: true, value: void 0 };
2003
+ }
2004
+ if (typeof raw !== "string") return { valid: false };
2005
+ const t = Date.parse(raw);
2006
+ if (Number.isNaN(t)) return { valid: false };
2007
+ return { valid: true, value: new Date(t).toISOString() };
2008
+ }
2009
+ async function upsertBudget(tag, max_budget, budget_duration, budget_reset_at) {
1990
2010
  const info = await tagInfo([tag]);
1991
2011
  try {
1992
2012
  if (info[tag]) {
@@ -2001,6 +2021,15 @@ async function upsertBudget(tag, max_budget, budget_duration) {
2001
2021
  await tagUpdate({ name: tag, max_budget, budget_duration });
2002
2022
  }
2003
2023
  }
2024
+ if (budget_reset_at) {
2025
+ const after = await tagInfo([tag]);
2026
+ const budgetId = after[tag]?.budget_id ?? null;
2027
+ if (budgetId) {
2028
+ await budgetUpdate(budgetId, { budget_reset_at });
2029
+ } else {
2030
+ console.warn(`[EXULU] upsertBudget: no budget_id for ${tag}; reset date not applied`);
2031
+ }
2032
+ }
2004
2033
  invalidateBudgetCaches(tag);
2005
2034
  }
2006
2035
  function invalidateBudgetCaches(tag) {
@@ -18422,21 +18451,7 @@ function isOsJunkPath(path2) {
18422
18451
  const basename = path2.split("/").pop() ?? "";
18423
18452
  return basename === ".DS_Store" || basename === "Thumbs.db" || basename === "desktop.ini";
18424
18453
  }
18425
- async function extractBundleToS3(opts) {
18426
- const { bytes, skillId, isZip, config } = opts;
18427
- if (!isZip) {
18428
- await uploadFile(
18429
- bytes,
18430
- `skills/${skillId}/v1/SKILL.md`,
18431
- config,
18432
- { contentType: "text/markdown" },
18433
- void 0,
18434
- void 0,
18435
- true
18436
- // global=true so the key isn't user-prefixed (skill files are shared)
18437
- );
18438
- return { filesCount: 1 };
18439
- }
18454
+ async function extractZipToPrefix(bytes, prefix, config) {
18440
18455
  let zip;
18441
18456
  try {
18442
18457
  zip = await import_jszip.default.loadAsync(bytes);
@@ -18500,7 +18515,7 @@ async function extractBundleToS3(opts) {
18500
18515
  }
18501
18516
  let filesCount = 0;
18502
18517
  for (const { relPath, content } of prepared) {
18503
- const s3Key = `skills/${skillId}/v1/${relPath}`;
18518
+ const s3Key = `${prefix}${relPath}`;
18504
18519
  await uploadFile(
18505
18520
  content,
18506
18521
  s3Key,
@@ -18509,12 +18524,77 @@ async function extractBundleToS3(opts) {
18509
18524
  void 0,
18510
18525
  void 0,
18511
18526
  true
18512
- // global=true — see SKILL.md case above
18527
+ // global=true — skill files are shared across users
18513
18528
  );
18514
18529
  filesCount += 1;
18515
18530
  }
18516
18531
  return { filesCount };
18517
18532
  }
18533
+ async function extractBundleToS3(opts) {
18534
+ const { bytes, skillId, isZip, config } = opts;
18535
+ if (!isZip) {
18536
+ await uploadFile(
18537
+ bytes,
18538
+ `skills/${skillId}/v1/SKILL.md`,
18539
+ config,
18540
+ { contentType: "text/markdown" },
18541
+ void 0,
18542
+ void 0,
18543
+ true
18544
+ // global=true so the key isn't user-prefixed (skill files are shared)
18545
+ );
18546
+ return { filesCount: 1 };
18547
+ }
18548
+ return extractZipToPrefix(bytes, `skills/${skillId}/v1/`, config);
18549
+ }
18550
+ async function extractBundleToVersion(opts) {
18551
+ const { bytes, skillId, version, config } = opts;
18552
+ return extractZipToPrefix(bytes, `skills/${skillId}/v${version}/`, config);
18553
+ }
18554
+
18555
+ // src/skills/frontmatter.ts
18556
+ init_cjs_shims();
18557
+ var import_jszip2 = __toESM(require("jszip"), 1);
18558
+ function parseFrontmatter(md) {
18559
+ const match = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(md);
18560
+ if (!match) return {};
18561
+ const block = match[1];
18562
+ const out = {};
18563
+ for (const line of block.split(/\r?\n/)) {
18564
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
18565
+ if (!m) continue;
18566
+ const key = m[1];
18567
+ let v = m[2].trim();
18568
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
18569
+ v = v.slice(1, -1);
18570
+ }
18571
+ out[key] = v;
18572
+ }
18573
+ return out;
18574
+ }
18575
+ async function parseSkillFrontmatter(zipBytes) {
18576
+ let zip;
18577
+ try {
18578
+ zip = await import_jszip2.default.loadAsync(zipBytes);
18579
+ } catch {
18580
+ return {};
18581
+ }
18582
+ const paths = [];
18583
+ zip.forEach((p, entry) => {
18584
+ if (!entry.dir) paths.push(p);
18585
+ });
18586
+ const heads = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean));
18587
+ let strip = (p) => p;
18588
+ if (heads.size === 1) {
18589
+ const head = [...heads][0] + "/";
18590
+ if (paths.every((p) => p.startsWith(head))) strip = (p) => p.slice(head.length);
18591
+ }
18592
+ const skillPath = paths.find((p) => strip(p) === "SKILL.md");
18593
+ if (!skillPath) return {};
18594
+ const md = await zip.file(skillPath).async("string");
18595
+ const fm = parseFrontmatter(md);
18596
+ return { name: fm.name, description: fm.description };
18597
+ }
18518
18598
 
18519
18599
  // src/sessions/pdf-preview-cache.ts
18520
18600
  init_cjs_shims();
@@ -18598,7 +18678,7 @@ var import_fs3 = __toESM(require("fs"), 1);
18598
18678
  var import_node_crypto9 = require("crypto");
18599
18679
  var import_api2 = require("@opentelemetry/api");
18600
18680
  init_check_record_access();
18601
- var import_jszip2 = __toESM(require("jszip"), 1);
18681
+ var import_jszip3 = __toESM(require("jszip"), 1);
18602
18682
  var import_ai15 = require("ai");
18603
18683
  var import_cookie_parser = __toESM(require("cookie-parser"), 1);
18604
18684
  init_statistics2();
@@ -21113,6 +21193,155 @@ var contentHeadersFor = (key, contentType, filename) => {
21113
21193
  };
21114
21194
  var getSharedArtifactByName = (db2, name) => db2("shared_artifacts").where({ name }).first();
21115
21195
 
21196
+ // src/skills/skill-access.ts
21197
+ init_cjs_shims();
21198
+ init_check_record_access();
21199
+ async function resolveSkillByName(db2, name) {
21200
+ const row = await db2("skills").where({ name }).first();
21201
+ return row ?? null;
21202
+ }
21203
+ async function canAccessSkill(db2, skill, action, user) {
21204
+ const rbac = await RBACResolver(db2, "skill", skill.id, skill.rights_mode || "private");
21205
+ return checkRecordAccess({ ...skill, RBAC: rbac }, action, user);
21206
+ }
21207
+ async function filterReadableSkills(db2, skills, user) {
21208
+ const out = [];
21209
+ for (const s of skills) {
21210
+ if (await canAccessSkill(db2, s, "read", user)) out.push(s);
21211
+ }
21212
+ return out;
21213
+ }
21214
+
21215
+ // src/skills/bootstrap/exulu-skills.ts
21216
+ init_cjs_shims();
21217
+
21218
+ // src/skills/bootstrap/clients.ts
21219
+ init_cjs_shims();
21220
+ var CLIENT_MANIFEST = [
21221
+ { id: "agents", dir: ".agents/skills" },
21222
+ // cross-agent standard (symlink canonical store)
21223
+ { id: "claude", dir: ".claude/skills" },
21224
+ { id: "windsurf", dir: ".windsurf/skills" },
21225
+ { id: "continue", dir: ".continue/skills" },
21226
+ { id: "roo", dir: ".roo/skills" },
21227
+ { id: "kilocode", dir: ".kilocode/skills" },
21228
+ { id: "crush", dir: ".crush/skills" },
21229
+ { id: "goose", dir: ".goose/skills" },
21230
+ { id: "qwen", dir: ".qwen/skills" },
21231
+ { id: "iflow", dir: ".iflow/skills" },
21232
+ { id: "junie", dir: ".junie/skills" },
21233
+ { id: "kiro", dir: ".kiro/skills" },
21234
+ { id: "trae", dir: ".trae/skills" },
21235
+ { id: "augment", dir: ".augment/skills" },
21236
+ { id: "factory", dir: ".factory/skills" },
21237
+ { id: "devin", dir: ".devin/skills" },
21238
+ { id: "openhands", dir: ".openhands/skills" },
21239
+ { id: "pi", dir: ".pi/skills" },
21240
+ { id: "cortex", dir: ".cortex/skills" },
21241
+ { id: "zencoder", dir: ".zencoder/skills" },
21242
+ { id: "codebuddy", dir: ".codebuddy/skills" },
21243
+ { id: "codestudio", dir: ".codestudio/skills" },
21244
+ { id: "commandcode", dir: ".commandcode/skills" },
21245
+ { id: "codemaker", dir: ".codemaker/skills" },
21246
+ { id: "codeartsdoer", dir: ".codeartsdoer/skills" },
21247
+ { id: "lingma", dir: ".lingma/skills" },
21248
+ { id: "qoder", dir: ".qoder/skills" },
21249
+ { id: "rovodev", dir: ".rovodev/skills" },
21250
+ { id: "moxby", dir: ".moxby/skills" },
21251
+ { id: "mux", dir: ".mux/skills" },
21252
+ { id: "neovate", dir: ".neovate/skills" },
21253
+ { id: "ona", dir: ".ona/skills" },
21254
+ { id: "pochi", dir: ".pochi/skills" },
21255
+ { id: "reasonix", dir: ".reasonix/skills" },
21256
+ { id: "terramind", dir: ".terramind/skills" },
21257
+ { id: "tinycloud", dir: ".tinycloud/skills" },
21258
+ { id: "vibe", dir: ".vibe/skills" },
21259
+ { id: "adal", dir: ".adal/skills" },
21260
+ { id: "aider-desk", dir: ".aider-desk/skills" },
21261
+ { id: "autohand", dir: ".autohand/skills" },
21262
+ { id: "bob", dir: ".bob/skills" },
21263
+ { id: "hermes", dir: ".hermes/skills" },
21264
+ { id: "inferencesh", dir: ".inferencesh/skills" },
21265
+ { id: "jazz", dir: ".jazz/skills" },
21266
+ { id: "kode", dir: ".kode/skills" },
21267
+ { id: "mcpjam", dir: ".mcpjam/skills" },
21268
+ { id: "forge", dir: ".forge/skills" },
21269
+ { id: "tabnine", dir: ".tabnine/agent/skills" }
21270
+ // exception: nested under agent/
21271
+ ];
21272
+
21273
+ // src/skills/bootstrap/exulu-sh.generated.ts
21274
+ init_cjs_shims();
21275
+ var EXULU_SH_B64 = "IyEvYmluL3NoCiMgZXh1bHUg4oCUIGhlbHBlciBmb3IgdGhlIEV4dWx1IGNlbnRyYWwgc2tpbGwgbGlicmFyeS4gVGhlIGFnZW50IGludm9rZXMgdGhpcwojIChuZXZlciByYXcgY3VybCk6IHRoZSB0b2tlbiBpcyByZWFkIGZyb20gdGhlIGNvbmZpZyBmaWxlIGhlcmUgYW5kIHNlbnQgdmlhCiMgYHgtYXBpLWtleTogQmVhcmVyYCwgc28gaXQgbmV2ZXIgZW50ZXJzIHRoZSBtb2RlbCBjb250ZXh0LiBDbGllbnQgZmFuLW91dAojIChjb3B5L3N5bWxpbmsgYWNyb3NzIGFnZW50IGNsaWVudHMpIGlzIGRldGVybWluaXN0aWMuCnNldCAtZXUKCkNPTkZJR19ESVI9IiRIT01FLy5jb25maWcvZXh1bHUiCkNPTkZJR19GSUxFPSIkQ09ORklHX0RJUi9za2lsbHMuanNvbiIKCmRpZSgpIHsgcHJpbnRmICdleHVsdTogJXNcbicgIiQqIiA+JjI7IGV4aXQgMTsgfQppbmZvKCkgeyBwcmludGYgJyVzXG4nICIkKiIgPiYyOyB9Cgpqc29uX3N0cigpIHsgIyBqc29uX3N0ciA8a2V5PiA8ZmlsZT4g4oCUIGZsYXQgImtleSI6InZhbHVlIgogIHNlZCAtbiAicy8uKlwiJDFcIltbOnNwYWNlOl1dKjpbWzpzcGFjZTpdXSpcIlxcKFteXCJdKlxcKVwiLiovXFwxL3AiICIkMiIgfCBoZWFkIC1uMQp9CgpbIC1mICIkQ09ORklHX0ZJTEUiIF0gfHwgZGllICJub3QgY29uZmlndXJlZCDigJQgcnVuOiBjdXJsIC1mc1NMIDxiYXNlX3VybD4vYXBpL3NraWxscy9pbnN0YWxsLnNoIHwgc2giCgpCQVNFX1VSTD0iJChqc29uX3N0ciBiYXNlX3VybCAiJENPTkZJR19GSUxFIikiCkJBQ0tFTkQ9IiQoanNvbl9zdHIgYmFja2VuZCAiJENPTkZJR19GSUxFIikiCkFQSV9LRVk9IiQoanNvbl9zdHIgYXBpX2tleSAiJENPTkZJR19GSUxFIikiCkxJTktfTU9ERT0iJChqc29uX3N0ciBsaW5rX21vZGUgIiRDT05GSUdfRklMRSIpIgpTQ09QRT0iJChqc29uX3N0ciBzY29wZSAiJENPTkZJR19GSUxFIikiCkNMSUVOVFM9IiQoc2VkIC1uICdzLy4qImNsaWVudHMiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKlxbXChbXl1dKlwpXF0uKi9cMS9wJyAiJENPTkZJR19GSUxFIiB8IHRyICcsJyAnICcgfCB0ciAtZCAnIicgfCB0ciAtcyAnICcpIgoKWyAtbiAiJENMSUVOVFMiIF0gfHwgQ0xJRU5UUz0iYWdlbnRzIgpbIC1uICIkTElOS19NT0RFIiBdIHx8IExJTktfTU9ERT0iY29weSIKWyAtbiAiJFNDT1BFIiBdIHx8IFNDT1BFPSJwcm9qZWN0IgoKaWYgWyAteiAiJEJBQ0tFTkQiIF07IHRoZW4KICBbIC1uICIkQkFTRV9VUkwiIF0gfHwgZGllICJjb25maWcgaGFzIG5vIGJhY2tlbmQgYW5kIG5vIGJhc2VfdXJsIgogIEJBQ0tFTkQ9IiQoY3VybCAtZnNTTCAiJEJBU0VfVVJML2FwaS9jb25maWciIHwgc2VkIC1uICdzLy4qImJhY2tlbmQiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKiJcKFteIl0qXCkiLiovXDEvcCcgfCBoZWFkIC1uMSkiCiAgWyAtbiAiJEJBQ0tFTkQiIF0gfHwgZGllICJjb3VsZCBub3QgcmVzb2x2ZSBiYWNrZW5kIGZyb20gJEJBU0VfVVJML2FwaS9jb25maWciCmZpCkJBQ0tFTkQ9IiQocHJpbnRmICclcycgIiRCQUNLRU5EIiB8IHNlZCAnczovKiQ6OicpIgoKUk9PVD0iJFBXRCIKWyAiJFNDT1BFIiA9ICJob21lIiBdICYmIFJPT1Q9IiRIT01FIgoKIyBkaXJfZm9yIENMSUVOVF9JRCAtPiByZWxhdGl2ZSBza2lsbCBkaXIgKGdlbmVyYXRlZCBmcm9tIHRoZSBtYW5pZmVzdCBpbiB0aGUKIyByZWFsIGJ1aWxkOyBhIHJlcHJlc2VudGF0aXZlIHN1YnNldCBoZXJlIGZvciBsb2NhbCB0ZXN0aW5nKS4KZGlyX2ZvcigpIHsKICBjYXNlICIkMSIgaW4KX19ESVJfRk9SX0NBU0VTX18KICAgICopIHJldHVybiAxIDs7CiAgZXNhYwp9CgphcGkoKSB7ICMgYXBpIDxNRVRIT0Q+IDxwYXRoPiBbZXh0cmEgY3VybCBhcmdzLi4uXSAtPiBib2R5IG9uIHN0ZG91dAogIG09IiQxIjsgcD0iJDIiOyBzaGlmdCAyCiAgY3VybCAtZnNTIC1YICIkbSIgIiRCQUNLRU5EJHAiIC1IICJ4LWFwaS1rZXk6IEJlYXJlciAkQVBJX0tFWSIgIiRAIgp9CgptZXRhX3ZlcnNpb24oKSB7ICMgbWV0YV92ZXJzaW9uIDxuYW1lPiAtPiBjdXJyZW50X3ZlcnNpb24gZnJvbSByZWdpc3RyeQogIGFwaSBHRVQgIi9za2lsbHMvcmVnaXN0cnkvJDEiIDI+L2Rldi9udWxsIFwKICAgIHwgc2VkIC1uICdzLy4qImN1cnJlbnRfdmVyc2lvbiJbWzpzcGFjZTpdXSo6W1s6c3BhY2U6XV0qXChbMC05XVswLTldKlwpLiovXDEvcCcgfCBoZWFkIC1uMQp9CgpfbWFuYWdlZCgpIHsgIyBfbWFuYWdlZCA8ZGlyPiAtPiAwIGlmIHNhZmUgdG8gb3ZlcndyaXRlIChvdXJzIG9yIHN5bWxpbmsgb3IgYWJzZW50KQogIGQ9IiQxIgogIGlmIFsgLWUgIiRkIiBdICYmIFsgISAtTCAiJGQiIF0gJiYgWyAhIC1mICIkZC8uZXh1bHUtc2tpbGwuanNvbiIgXTsgdGhlbgogICAgaW5mbyAic2tpcCAkZCAoZXhpc3RzLCBub3QgbWFuYWdlZCBieSBleHVsdSkiOyByZXR1cm4gMQogIGZpCiAgcmV0dXJuIDAKfQoKX3B1dF9yZWFsKCkgeyAjIF9wdXRfcmVhbCA8ZGVzdD4gPHNyY2Rpcj4gPG1hcmtlci1qc29uPgogIF9tYW5hZ2VkICIkMSIgfHwgcmV0dXJuIDAKICBybSAtcmYgIiQxIjsgbWtkaXIgLXAgIiQxIjsgY3AgLVIgIiQyLy4iICIkMS8iCiAgcHJpbnRmICclc1xuJyAiJDMiID4gIiQxLy5leHVsdS1za2lsbC5qc29uIgp9CgpwbGFjZV9za2lsbCgpIHsgIyBwbGFjZV9za2lsbCA8bmFtZT4gPHNyY2Rpcj4gPHZlcnNpb24+CiAgcG5hbWU9IiQxIjsgcHNyYz0iJDIiOyBwdmVyPSIkezM6LTF9IgogIG1hcmtlcj0neyAibmFtZSI6ICInIiRwbmFtZSInIiwgInZlcnNpb24iOiAnIiRwdmVyIicsICJzb3VyY2UiOiAiJyIkQkFDS0VORCInIiB9JwogIGNhbm9uPSIkUk9PVC8uYWdlbnRzL3NraWxscy8kcG5hbWUiCiAgWyAiJExJTktfTU9ERSIgPSAic3ltbGluayIgXSAmJiBfcHV0X3JlYWwgIiRjYW5vbiIgIiRwc3JjIiAiJG1hcmtlciIKICBmb3IgaWQgaW4gJENMSUVOVFM7IGRvCiAgICBkPSIkKGRpcl9mb3IgIiRpZCIpIiB8fCB7IGluZm8gInVua25vd24gY2xpZW50OiAkaWQiOyBjb250aW51ZTsgfQogICAgcGFyZW50PSIkUk9PVC8kZCI7IGRlc3Q9IiRwYXJlbnQvJHBuYW1lIgogICAgaWYgWyAiJExJTktfTU9ERSIgPSAic3ltbGluayIgXSAmJiBbICIkaWQiICE9ICJhZ2VudHMiIF07IHRoZW4KICAgICAgX21hbmFnZWQgIiRkZXN0IiB8fCBjb250aW51ZQogICAgICBta2RpciAtcCAiJHBhcmVudCI7IHJtIC1yZiAiJGRlc3QiCiAgICAgIGlmIGxuIC1zICIkY2Fub24iICIkZGVzdCIgMj4vZGV2L251bGw7IHRoZW4KICAgICAgICBpbmZvICJsaW5rZWQgJGRlc3QgLT4gJGNhbm9uIgogICAgICBlbHNlCiAgICAgICAgaW5mbyAic3ltbGluayB1bnN1cHBvcnRlZCBhdCAkZGVzdDsgY29weWluZyIKICAgICAgICBfcHV0X3JlYWwgIiRkZXN0IiAiJHBzcmMiICIkbWFya2VyIgogICAgICBmaQogICAgZWxpZiBbICIkTElOS19NT0RFIiA9ICJjb3B5IiBdOyB0aGVuCiAgICAgIF9wdXRfcmVhbCAiJGRlc3QiICIkcHNyYyIgIiRtYXJrZXIiCiAgICBmaQogICAgIyBzeW1saW5rICsgYWdlbnRzOiBhbHJlYWR5IHBsYWNlZCBhcyB0aGUgY2Fub25pY2FsIHN0b3JlIGFib3ZlLgogIGRvbmUKfQoKaW5zdGFsbGVkX25hbWVzKCkgeyAjIHVuaXF1ZSBza2lsbCBuYW1lcyB0aGF0IGNhcnJ5IG91ciBtYXJrZXIgdW5kZXIgUk9PVAogIHsgZmluZCAiJFJPT1QvLmFnZW50cy9za2lsbHMiIC1tYXhkZXB0aCAyIC1uYW1lIC5leHVsdS1za2lsbC5qc29uIDI+L2Rldi9udWxsCiAgICBmb3IgaWQgaW4gJENMSUVOVFM7IGRvCiAgICAgIGQ9IiQoZGlyX2ZvciAiJGlkIikiIHx8IGNvbnRpbnVlCiAgICAgIGZpbmQgIiRST09ULyRkIiAtbWF4ZGVwdGggMiAtbmFtZSAuZXh1bHUtc2tpbGwuanNvbiAyPi9kZXYvbnVsbAogICAgZG9uZQogIH0gfCB3aGlsZSByZWFkIC1yIG07IGRvIGpzb25fc3RyIG5hbWUgIiRtIjsgZG9uZSB8IHNvcnQgLXUKfQoKbWFya2VyX3ZlcnNpb24oKSB7ICMgbWFya2VyX3ZlcnNpb24gPG5hbWU+CiAgZm9yIGJhc2UgaW4gIiRST09ULy5hZ2VudHMvc2tpbGxzIjsgZG8KICAgIFsgLWYgIiRiYXNlLyQxLy5leHVsdS1za2lsbC5qc29uIiBdICYmIHsganNvbl9zdHIgdmVyc2lvbiAiJGJhc2UvJDEvLmV4dWx1LXNraWxsLmpzb24iOyByZXR1cm47IH0KICBkb25lCiAgZm9yIGlkIGluICRDTElFTlRTOyBkbwogICAgZD0iJChkaXJfZm9yICIkaWQiKSIgfHwgY29udGludWUKICAgIGY9IiRST09ULyRkLyQxLy5leHVsdS1za2lsbC5qc29uIgogICAgWyAtZiAiJGYiIF0gJiYgeyBqc29uX3N0ciB2ZXJzaW9uICIkZiI7IHJldHVybjsgfQogIGRvbmUKfQoKZG9faW5zdGFsbCgpIHsgIyBkb19pbnN0YWxsIDxuYW1lPgogIG5hbWU9IiQxIgogIFRNUD0iJChta3RlbXAgLWQpIgogIGFwaSBHRVQgIi9za2lsbHMvcmVnaXN0cnkvJG5hbWUvZG93bmxvYWQiIC0tb3V0cHV0ICIkVE1QL3NraWxsLnppcCIgXAogICAgfHwgeyBybSAtcmYgIiRUTVAiOyBkaWUgImRvd25sb2FkIGZhaWxlZCBmb3IgJyRuYW1lJyAoNDAzID0gbm8gYWNjZXNzLCA0MDQgPSB1bmtub3duKSI7IH0KICBta2RpciAtcCAiJFRNUC94IgogIHVuemlwIC1xICIkVE1QL3NraWxsLnppcCIgLWQgIiRUTVAveCIgfHwgeyBybSAtcmYgIiRUTVAiOyBkaWUgImJhZCBhcmNoaXZlIGZvciAnJG5hbWUnIjsgfQogIHNyYz0iJChmaW5kICIkVE1QL3giIC1taW5kZXB0aCAxIC1tYXhkZXB0aCAxIC10eXBlIGQgfCBoZWFkIC1uMSkiCiAgWyAtbiAiJHNyYyIgXSB8fCB7IHJtIC1yZiAiJFRNUCI7IGRpZSAidW5leHBlY3RlZCBhcmNoaXZlIGxheW91dCBmb3IgJyRuYW1lJyI7IH0KICB2ZXI9IiQobWV0YV92ZXJzaW9uICIkbmFtZSIpIgogIHBsYWNlX3NraWxsICIkbmFtZSIgIiRzcmMiICIke3ZlcjotMX0iCiAgcm0gLXJmICIkVE1QIgogIGluZm8gImluc3RhbGxlZCAkbmFtZSAodiR7dmVyOi0xfSkgWyRMSU5LX01PREVdIGludG86ICRDTElFTlRTIgp9Cgp1c2FnZSgpIHsKICBjYXQgPiYyIDw8RU9GCmV4dWx1IOKAlCBFeHVsdSBza2lsbCBsaWJyYXJ5IGhlbHBlcgogIGV4dWx1IGxpc3QgICAgICAgICAgICAgICAgIGxpc3Qgc2tpbGxzIHlvdSBjYW4gYWNjZXNzIChKU09OKQogIGV4dWx1IGdldCA8bmFtZT4gICAgICAgICAgIHNob3cgb25lIHNraWxsJ3MgbWV0YWRhdGEgKEpTT04pCiAgZXh1bHUgaW5zdGFsbCA8bmFtZT4gICAgICAgaW5zdGFsbC9yZWZyZXNoIGEgc2tpbGwgaW50byB5b3VyIGFnZW50IGNsaWVudHMKICBleHVsdSB1cGRhdGUgWzxuYW1lPl0gICAgICB1cGRhdGUgaW5zdGFsbGVkIHNraWxscyAoYWxsLCBvciBvbmUpIHRvIGxhdGVzdAogIGV4dWx1IHB1Ymxpc2ggPG5hbWU+IDxkaXI+IHB1Ymxpc2ggYSBsb2NhbCBza2lsbCBmb2xkZXIgYXMgPG5hbWU+CiAgZXh1bHUgY29uZmlnICAgICAgICAgICAgICAgc2hvdyByZXNvbHZlZCBiYWNrZW5kIC8gc2NvcGUgLyBjbGllbnRzIChubyBzZWNyZXRzKQpFT0YKfQoKY21kPSIkezE6LWhlbHB9IgpbICQjIC1ndCAwIF0gJiYgc2hpZnQgfHwgdHJ1ZQoKY2FzZSAiJGNtZCIgaW4KICBsaXN0KSBhcGkgR0VUICIvc2tpbGxzL3JlZ2lzdHJ5IiA7OwogIGdldCkgWyAkIyAtZ2UgMSBdIHx8IGRpZSAidXNhZ2U6IGV4dWx1IGdldCA8bmFtZT4iOyBhcGkgR0VUICIvc2tpbGxzL3JlZ2lzdHJ5LyQxIiA7OwogIGluc3RhbGwpIFsgJCMgLWdlIDEgXSB8fCBkaWUgInVzYWdlOiBleHVsdSBpbnN0YWxsIDxuYW1lPiI7IGRvX2luc3RhbGwgIiQxIiA7OwogIHVwZGF0ZSkKICAgIGlmIFsgJCMgLWdlIDEgXTsgdGhlbiBuYW1lcz0iJDEiOyBlbHNlIG5hbWVzPSIkKGluc3RhbGxlZF9uYW1lcykiOyBmaQogICAgWyAtbiAiJG5hbWVzIiBdIHx8IHsgaW5mbyAibm8gZXh1bHUtbWFuYWdlZCBza2lsbHMgZm91bmQgdW5kZXIgJFJPT1QiOyBleGl0IDA7IH0KICAgIGZvciBuIGluICRuYW1lczsgZG8KICAgICAgY3VyPSIkKG1hcmtlcl92ZXJzaW9uICIkbiIpIgogICAgICBsYXRlc3Q9IiQobWV0YV92ZXJzaW9uICIkbiIpIgogICAgICBbIC1uICIkbGF0ZXN0IiBdIHx8IHsgaW5mbyAic2tpcCAkbiAobm90IGluIHJlZ2lzdHJ5KSI7IGNvbnRpbnVlOyB9CiAgICAgIGlmIFsgLXogIiRjdXIiIF0gfHwgWyAiJGxhdGVzdCIgLWd0ICIkY3VyIiBdIDI+L2Rldi9udWxsOyB0aGVuCiAgICAgICAgaW5mbyAidXBkYXRpbmcgJG46IHYke2N1cjotP30gLT4gdiRsYXRlc3QiOyBkb19pbnN0YWxsICIkbiIKICAgICAgZWxzZQogICAgICAgIGluZm8gIiRuIHVwIHRvIGRhdGUgKHYkY3VyKSIKICAgICAgZmkKICAgIGRvbmUKICAgIDs7CiAgcHVibGlzaCkKICAgIFsgJCMgLWdlIDIgXSB8fCBkaWUgInVzYWdlOiBleHVsdSBwdWJsaXNoIDxuYW1lPiA8ZGlyPiIKICAgIG5hbWU9IiQxIjsgZm9sZGVyPSIkMiIKICAgIFsgLWQgIiRmb2xkZXIiIF0gfHwgZGllICJubyBzdWNoIGZvbGRlcjogJGZvbGRlciIKICAgIFsgLWYgIiRmb2xkZXIvU0tJTEwubWQiIF0gfHwgZGllICIkZm9sZGVyIGhhcyBubyBTS0lMTC5tZCBhdCBpdHMgcm9vdCIKICAgIGNvbW1hbmQgLXYgemlwID4vZGV2L251bGwgMj4mMSB8fCBkaWUgInRoZSAnemlwJyBjb21tYW5kIGlzIHJlcXVpcmVkIHRvIHB1Ymxpc2giCiAgICBUTVA9IiQobWt0ZW1wIC1kKSIKICAgICggY2QgIiQoZGlybmFtZSAiJGZvbGRlciIpIiBcCiAgICAgICYmIHppcCAtcSAtciAtWCAiJFRNUC9za2lsbC56aXAiICIkKGJhc2VuYW1lICIkZm9sZGVyIikiIFwKICAgICAgICAgICAteCAnKi8uZXh1bHUtc2tpbGwuanNvbicgJyovLmdpdC8qJyAnKi5EU19TdG9yZScgJyovX19NQUNPU1gvKicgKSBcCiAgICAgIHx8IHsgcm0gLXJmICIkVE1QIjsgZGllICJjb3VsZCBub3QgemlwICRmb2xkZXIiOyB9CiAgICBhcGkgUE9TVCAiL3NraWxscy9yZWdpc3RyeS8kbmFtZSIgLUggIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vemlwIiBcCiAgICAgIC0tZGF0YS1iaW5hcnkgQCIkVE1QL3NraWxsLnppcCIgXAogICAgICB8fCB7IHJtIC1yZiAiJFRNUCI7IGRpZSAicHVibGlzaCBmYWlsZWQgKDQwMyA9IG5vIHdyaXRlIGFjY2VzcywgNDA5ID0gbmFtZSB0YWtlbikiOyB9CiAgICBybSAtcmYgIiRUTVAiCiAgICBpbmZvICJwdWJsaXNoZWQgJG5hbWUiCiAgICA7OwogIGNvbmZpZykKICAgIHByaW50ZiAnYmFja2VuZD0lc1xuc2NvcGU9JXNcbmxpbmtfbW9kZT0lc1xuY2xpZW50cz0lc1xuYXBpX2tleT0lc1xuJyBcCiAgICAgICIkQkFDS0VORCIgIiRTQ09QRSIgIiRMSU5LX01PREUiICIkQ0xJRU5UUyIgXAogICAgICAiJChbIC1uICIkQVBJX0tFWSIgXSAmJiBlY2hvIHNldCB8fCBlY2hvIE1JU1NJTkcpIgogICAgOzsKICBoZWxwfC0taGVscHwtaCkgdXNhZ2UgOzsKICAqKSBpbmZvICJ1bmtub3duIGNvbW1hbmQ6ICRjbWQiOyB1c2FnZTsgZXhpdCAyIDs7CmVzYWMK";
21276
+
21277
+ // src/skills/bootstrap/exulu-skills.ts
21278
+ var BOOTSTRAP_CLIENTS_JSON = JSON.stringify(CLIENT_MANIFEST, null, 2);
21279
+ var DIR_FOR_CASES = CLIENT_MANIFEST.map(
21280
+ (c) => ` ${c.id}) printf '%s' '${c.dir}' ;;`
21281
+ ).join("\n");
21282
+ var BOOTSTRAP_EXULU_SH = Buffer.from(EXULU_SH_B64, "base64").toString("utf8").replace("__DIR_FOR_CASES__", DIR_FOR_CASES);
21283
+ var BOOTSTRAP_SKILL_MD = `---
21284
+ name: exulu-skills
21285
+ 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.
21286
+ ---
21287
+
21288
+ # Exulu Skills
21289
+
21290
+ Bridge to the Exulu central skill library. **All operations go through the
21291
+ bundled helper script \u2014 do not hand-write curl or copy files yourself.** The
21292
+ script reads the API token from config (keeping it out of this conversation) and
21293
+ handles the multi-client copy/symlink fan-out deterministically.
21294
+
21295
+ ## The helper
21296
+
21297
+ Run the script next to this file, \`scripts/exulu\`, with \`sh\` and the absolute
21298
+ path of this skill's directory:
21299
+
21300
+ \`\`\`
21301
+ sh "<this-skill-dir>/scripts/exulu" <command>
21302
+ \`\`\`
21303
+
21304
+ Commands:
21305
+ - \`list\` \u2014 skills you can access (JSON on stdout)
21306
+ - \`get <name>\` \u2014 one skill's metadata (JSON)
21307
+ - \`install <name>\` \u2014 install/refresh a skill into the user's agent clients
21308
+ - \`update [<name>]\` \u2014 update every installed skill, or just \`<name>\`, to latest
21309
+ - \`publish <name> <folder>\` \u2014 publish a local skill folder as \`<name>\`
21310
+ - \`config\` \u2014 show resolved backend / scope / clients (prints no secrets)
21311
+
21312
+ The token, backend URL, target clients, copy-vs-symlink mode, and scope all come
21313
+ from \`~/.config/exulu/skills.json\` (written by the installer). Never print the
21314
+ \`api_key\` or read it into your reply \u2014 the script uses it internally.
21315
+
21316
+ ## Requests \u2192 commands
21317
+
21318
+ - "list / search skills" \u2192 \`exulu list\`, then filter the JSON for the user.
21319
+ - "install skill X" / "add the X skill" \u2192 \`exulu install X\`.
21320
+ - "update / get the latest version [of X]" \u2192 \`exulu update [X]\`.
21321
+ - "publish / upload this skill as X" \u2192 confirm the target name with the user; for
21322
+ an existing skill run \`exulu get X\` first and confirm a new version is intended;
21323
+ then \`exulu publish X <folder>\`.
21324
+
21325
+ ## Not configured yet?
21326
+
21327
+ If \`exulu config\` reports it's not configured (or \`~/.config/exulu/skills.json\`
21328
+ is missing), tell the user to run the installer \u2014 it sets everything up
21329
+ interactively (base URL, API key, target clients, copy/symlink):
21330
+
21331
+ \`\`\`
21332
+ curl -fsSL <base_url>/api/skills/install.sh | sh
21333
+ \`\`\`
21334
+
21335
+ \`<base_url>\` is their Exulu frontend URL (e.g. https://ai.open.de). They can
21336
+ create an API key at \`<base_url>/token\`.
21337
+
21338
+ ## Errors
21339
+
21340
+ - install: \`403\` = no access to that skill; \`404\` = unknown name.
21341
+ - publish: \`403\` = you can see it but lack write access; \`409\` = the name is
21342
+ taken by a skill you can't access.
21343
+ `;
21344
+
21116
21345
  // src/exulu/routes.ts
21117
21346
  var REQUEST_SIZE_LIMIT = "50mb";
21118
21347
  var getExuluVersionNumber = async () => {
@@ -21186,6 +21415,7 @@ var createExpressRoutes = async (app, providers, tools, contexts, config, evals,
21186
21415
  }
21187
21416
  next();
21188
21417
  });
21418
+ const rawZip = import_express5.default.raw({ type: ["application/zip", "application/octet-stream", "application/x-zip-compressed", "application/x-zip"], limit: "50mb" });
21189
21419
  console.log(`
21190
21420
  \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
21191
21421
  \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
@@ -22838,7 +23068,9 @@ ${style.markdown}` : params.prompt;
22838
23068
  const budget_duration = String(body?.budget_duration ?? "");
22839
23069
  if (!Number.isFinite(max_budget) || max_budget <= 0) return null;
22840
23070
  if (!BUDGET_ALLOWED_DURATIONS.has(budget_duration)) return null;
22841
- return { max_budget, budget_duration };
23071
+ const reset = parseResetAt(body?.budget_reset_at);
23072
+ if (!reset.valid) return null;
23073
+ return { max_budget, budget_duration, budget_reset_at: reset.value };
22842
23074
  };
22843
23075
  const parseBudgetSettingsBody = (body) => {
22844
23076
  if (!body || typeof body !== "object") return null;
@@ -22908,7 +23140,7 @@ ${style.markdown}` : params.prompt;
22908
23140
  }
22909
23141
  const body = parseBudgetBody(req.body);
22910
23142
  if (!body) {
22911
- res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration)." });
23143
+ res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration, budget_reset_at)." });
22912
23144
  return;
22913
23145
  }
22914
23146
  const entityIds = Array.isArray(req.body?.entityIds) ? req.body.entityIds : [];
@@ -22920,7 +23152,7 @@ ${style.markdown}` : params.prompt;
22920
23152
  for (const id of entityIds) {
22921
23153
  const tag = budgetTagFor(entityType, id);
22922
23154
  try {
22923
- await upsertBudget(tag, body.max_budget, body.budget_duration);
23155
+ await upsertBudget(tag, body.max_budget, body.budget_duration, body.budget_reset_at);
22924
23156
  results.push({ entityId: String(id), ok: true });
22925
23157
  } catch (err) {
22926
23158
  results.push({
@@ -22945,12 +23177,12 @@ ${style.markdown}` : params.prompt;
22945
23177
  }
22946
23178
  const body = parseBudgetBody(req.body);
22947
23179
  if (!body) {
22948
- res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration)." });
23180
+ res.status(400).json({ detail: "Invalid budget (max_budget, budget_duration, budget_reset_at)." });
22949
23181
  return;
22950
23182
  }
22951
23183
  const tag = budgetTagFor(entityType, req.params.entityId ?? "");
22952
23184
  try {
22953
- await upsertBudget(tag, body.max_budget, body.budget_duration);
23185
+ await upsertBudget(tag, body.max_budget, body.budget_duration, body.budget_reset_at);
22954
23186
  const info = await tagInfo([tag]);
22955
23187
  res.status(200).json({ budget: info[tag] ?? null });
22956
23188
  } catch (err) {
@@ -23413,6 +23645,199 @@ ${style.markdown}` : params.prompt;
23413
23645
  }
23414
23646
  return root;
23415
23647
  }
23648
+ app.get("/skills/agent/bootstrap", async (_req, res) => {
23649
+ try {
23650
+ const zip = new import_jszip3.default();
23651
+ zip.file("exulu-skills/SKILL.md", BOOTSTRAP_SKILL_MD);
23652
+ zip.file("exulu-skills/references/clients.json", BOOTSTRAP_CLIENTS_JSON);
23653
+ zip.file("exulu-skills/scripts/exulu", BOOTSTRAP_EXULU_SH, {
23654
+ unixPermissions: 493
23655
+ });
23656
+ const buffer = await zip.generateAsync({
23657
+ type: "nodebuffer",
23658
+ platform: "UNIX"
23659
+ });
23660
+ res.setHeader("Content-Type", "application/zip");
23661
+ res.setHeader("Content-Disposition", 'attachment; filename="exulu-skills.zip"');
23662
+ res.send(buffer);
23663
+ } catch (err) {
23664
+ console.error("[SKILLS] Failed to build bootstrap zip", err);
23665
+ res.status(500).json({ detail: "Failed to build bootstrap skill." });
23666
+ }
23667
+ });
23668
+ app.get("/skills/registry", async (req, res) => {
23669
+ const authResult = await requestValidators.authenticate(req);
23670
+ if (!authResult.user?.id) {
23671
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
23672
+ return;
23673
+ }
23674
+ const { db: db2 } = await postgresClient();
23675
+ const tag = typeof req.query.tag === "string" ? req.query.tag : void 0;
23676
+ const all = await db2("skills").select("*");
23677
+ const readable = await filterReadableSkills(db2, all, authResult.user);
23678
+ const skills = readable.filter((s) => {
23679
+ if (!tag) return true;
23680
+ const tags = Array.isArray(s.tags) ? s.tags : [];
23681
+ return tags.includes(tag);
23682
+ }).map((s) => ({
23683
+ name: s.name,
23684
+ description: s.description ?? "",
23685
+ tags: Array.isArray(s.tags) ? s.tags : [],
23686
+ current_version: s.current_version ?? 1,
23687
+ updated_at: s.updatedAt ?? s.updated_at ?? null
23688
+ }));
23689
+ res.json({ skills });
23690
+ });
23691
+ app.get("/skills/registry/:name/download", async (req, res) => {
23692
+ const authResult = await requestValidators.authenticate(req);
23693
+ if (!authResult.user?.id) {
23694
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
23695
+ return;
23696
+ }
23697
+ const { db: db2 } = await postgresClient();
23698
+ const skill = await resolveSkillByName(db2, req.params.name);
23699
+ if (!skill) {
23700
+ res.status(404).json({ detail: "Skill not found." });
23701
+ return;
23702
+ }
23703
+ if (!await canAccessSkill(db2, skill, "read", authResult.user)) {
23704
+ res.status(403).json({ detail: "You don't have access to this skill." });
23705
+ return;
23706
+ }
23707
+ const vQuery = req.query.version;
23708
+ const version = !vQuery || vQuery === "latest" ? skill.current_version ?? 1 : Number(vQuery);
23709
+ if (!Number.isFinite(version) || version < 1) {
23710
+ res.status(400).json({ detail: "Invalid version." });
23711
+ return;
23712
+ }
23713
+ const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
23714
+ const versionPrefix = `skills/${skill.id}/v${version}/`;
23715
+ const files = await listS3ObjectsByPrefix(versionPrefix, config);
23716
+ if (files.length === 0) {
23717
+ res.status(404).json({ detail: `Version v${version} has no files.` });
23718
+ return;
23719
+ }
23720
+ const zip = new import_jszip3.default();
23721
+ for (const file of files) {
23722
+ const idx = file.key.indexOf(versionPrefix);
23723
+ const rel = idx >= 0 ? file.key.slice(idx + versionPrefix.length) : file.key;
23724
+ if (!rel) continue;
23725
+ const bytes = await getS3ObjectBytes(file.key, config);
23726
+ zip.file(`${safeName}/${rel}`, bytes);
23727
+ }
23728
+ const buffer = await zip.generateAsync({ type: "nodebuffer" });
23729
+ res.setHeader("Content-Type", "application/zip");
23730
+ res.setHeader("Content-Disposition", `attachment; filename="${safeName}.skill"`);
23731
+ res.send(buffer);
23732
+ });
23733
+ app.post("/skills/registry/:name", rawZip, async (req, res) => {
23734
+ const authResult = await requestValidators.authenticate(req);
23735
+ if (!authResult.user?.id) {
23736
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
23737
+ return;
23738
+ }
23739
+ const name = req.params.name;
23740
+ const bytes = req.body;
23741
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
23742
+ res.status(400).json({ detail: "Empty body. Send the skill as a zip/.skill payload." });
23743
+ return;
23744
+ }
23745
+ const { db: db2 } = await postgresClient();
23746
+ const existing = await resolveSkillByName(db2, name);
23747
+ if (existing) {
23748
+ const canWrite = await canAccessSkill(db2, existing, "write", authResult.user);
23749
+ if (canWrite) {
23750
+ const nextVersion = (existing.current_version ?? 1) + 1;
23751
+ try {
23752
+ await extractBundleToVersion({ bytes, skillId: existing.id, version: nextVersion, config });
23753
+ } catch (err) {
23754
+ if (err instanceof BundleValidationError) {
23755
+ res.status(400).json({ detail: err.message });
23756
+ return;
23757
+ }
23758
+ console.error("[SKILLS] publish (new version) failed", err);
23759
+ res.status(500).json({ detail: "Failed to publish new version." });
23760
+ return;
23761
+ }
23762
+ const history = Array.isArray(existing.history) ? existing.history : [];
23763
+ await db2("skills").where({ id: existing.id }).update({
23764
+ current_version: nextVersion,
23765
+ history: JSON.stringify([
23766
+ ...history,
23767
+ { version: nextVersion, created_at: (/* @__PURE__ */ new Date()).toISOString(), label: "Published from agent" }
23768
+ ])
23769
+ });
23770
+ res.json({ name, version: nextVersion, created: false });
23771
+ return;
23772
+ } else {
23773
+ const canRead = await canAccessSkill(db2, existing, "read", authResult.user);
23774
+ if (!canRead) {
23775
+ res.status(409).json({ detail: "That name is unavailable." });
23776
+ return;
23777
+ }
23778
+ res.status(403).json({ detail: "You don't have write access to this skill." });
23779
+ return;
23780
+ }
23781
+ }
23782
+ const meta = await parseSkillFrontmatter(bytes);
23783
+ const skillId = (0, import_node_crypto9.randomUUID)();
23784
+ try {
23785
+ await extractBundleToVersion({ bytes, skillId, version: 1, config });
23786
+ } catch (err) {
23787
+ if (err instanceof BundleValidationError) {
23788
+ res.status(400).json({ detail: err.message });
23789
+ return;
23790
+ }
23791
+ console.error("[SKILLS] publish (create) failed", err);
23792
+ res.status(500).json({ detail: "Failed to publish skill." });
23793
+ return;
23794
+ }
23795
+ try {
23796
+ await db2("skills").insert({
23797
+ id: skillId,
23798
+ name,
23799
+ description: meta.description ?? "",
23800
+ s3folder: `skills/${skillId}`,
23801
+ tags: JSON.stringify([]),
23802
+ usage_count: 0,
23803
+ favorite_count: 0,
23804
+ current_version: 1,
23805
+ history: JSON.stringify([
23806
+ { version: 1, created_at: (/* @__PURE__ */ new Date()).toISOString(), label: "Published from agent" }
23807
+ ]),
23808
+ rights_mode: "private",
23809
+ created_by: authResult.user.id
23810
+ });
23811
+ } catch (err) {
23812
+ res.status(409).json({ detail: "That name is unavailable." });
23813
+ return;
23814
+ }
23815
+ res.json({ name, version: 1, created: true });
23816
+ });
23817
+ app.get("/skills/registry/:name", async (req, res) => {
23818
+ const authResult = await requestValidators.authenticate(req);
23819
+ if (!authResult.user?.id) {
23820
+ res.status(authResult.code ?? 401).json({ detail: authResult.message });
23821
+ return;
23822
+ }
23823
+ const { db: db2 } = await postgresClient();
23824
+ const skill = await resolveSkillByName(db2, req.params.name);
23825
+ if (!skill) {
23826
+ res.status(404).json({ detail: "Skill not found." });
23827
+ return;
23828
+ }
23829
+ if (!await canAccessSkill(db2, skill, "read", authResult.user)) {
23830
+ res.status(403).json({ detail: "You don't have access to this skill." });
23831
+ return;
23832
+ }
23833
+ res.json({
23834
+ name: skill.name,
23835
+ description: skill.description ?? "",
23836
+ tags: Array.isArray(skill.tags) ? skill.tags : [],
23837
+ current_version: skill.current_version ?? 1,
23838
+ history: Array.isArray(skill.history) ? skill.history : []
23839
+ });
23840
+ });
23416
23841
  app.post("/skills/:skillId/init", async (req, res) => {
23417
23842
  const authResult = await requestValidators.authenticate(req);
23418
23843
  if (!authResult.user?.id) {
@@ -23460,8 +23885,8 @@ ${style.markdown}` : params.prompt;
23460
23885
  }
23461
23886
  const { skillId } = req.params;
23462
23887
  const { extension, contentType } = req.body ?? {};
23463
- if (extension !== ".zip" && extension !== ".md") {
23464
- res.status(400).json({ detail: 'extension must be ".zip" or ".md".' });
23888
+ if (extension !== ".zip" && extension !== ".md" && extension !== ".skill") {
23889
+ res.status(400).json({ detail: 'extension must be ".zip", ".md", or ".skill".' });
23465
23890
  return;
23466
23891
  }
23467
23892
  if (!contentType || typeof contentType !== "string") {
@@ -23600,19 +24025,22 @@ ${style.markdown}` : params.prompt;
23600
24025
  }
23601
24026
  const versionPrefix = `skills/${skillId}/v${version}/`;
23602
24027
  const files = await listS3ObjectsByPrefix(versionPrefix, config);
23603
- const zip = new import_jszip2.default();
24028
+ const asSkill = req.query.format === "skill";
24029
+ const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
24030
+ const zip = new import_jszip3.default();
23604
24031
  let fileCount = 0;
23605
24032
  for (const file of files) {
23606
24033
  const prefixIndex = file.key.indexOf(versionPrefix);
23607
24034
  const relativePath = prefixIndex >= 0 ? file.key.slice(prefixIndex + versionPrefix.length) : file.key;
23608
24035
  if (!relativePath) continue;
23609
24036
  const bytes = await getS3ObjectBytes(file.key, config);
23610
- zip.file(relativePath, bytes);
24037
+ const archivePath = asSkill ? `${safeName}/${relativePath}` : relativePath;
24038
+ zip.file(archivePath, bytes);
23611
24039
  fileCount += 1;
23612
24040
  }
23613
24041
  const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
23614
24042
  zip.file(
23615
- "version.txt",
24043
+ asSkill ? `${safeName}/version.txt` : "version.txt",
23616
24044
  [
23617
24045
  `Skill: ${skill.name ?? skillId}`,
23618
24046
  `Skill id: ${skillId}`,
@@ -23623,13 +24051,9 @@ ${style.markdown}` : params.prompt;
23623
24051
  ].join("\n")
23624
24052
  );
23625
24053
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
23626
- const safeName = String(skill.name ?? "skill").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
23627
- const filename = `${safeName}-v${version}.zip`;
24054
+ const filename = asSkill ? `${safeName}.skill` : `${safeName}-v${version}.zip`;
23628
24055
  res.setHeader("Content-Type", "application/zip");
23629
- res.setHeader(
23630
- "Content-Disposition",
23631
- `attachment; filename="${filename}"`
23632
- );
24056
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
23633
24057
  res.send(buffer);
23634
24058
  });
23635
24059
  app.post("/skills/:skillId/sign", async (req, res) => {
@@ -23929,6 +24353,12 @@ ${style.markdown}` : params.prompt;
23929
24353
  const fullSessionPrefix = `${generalPrefix}${userSessionPrefix}`;
23930
24354
  return { userSessionPrefix, fullSessionPrefix };
23931
24355
  }
24356
+ const loadSessionFilesAuth = async (req, res, sessionId, rights) => {
24357
+ const authed = await loadAuthedSession(req, res, sessionId, rights);
24358
+ if (!authed) return null;
24359
+ const ownerId = authed.session.user ?? authed.user.id;
24360
+ return { ...authed, ownerId, ...buildSessionPrefixes(ownerId, sessionId) };
24361
+ };
23932
24362
  function sanitizeFilename(name) {
23933
24363
  const trimmed = name.trim();
23934
24364
  if (!trimmed) return "";
@@ -23937,11 +24367,6 @@ ${style.markdown}` : params.prompt;
23937
24367
  return trimmed.replace(/[\\/]/g, "_");
23938
24368
  }
23939
24369
  app.get("/sessions/:sessionId/files", async (req, res) => {
23940
- const authResult = await requestValidators.authenticate(req);
23941
- if (!authResult.user?.id) {
23942
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
23943
- return;
23944
- }
23945
24370
  const sessionId = req.params.sessionId;
23946
24371
  if (!sessionId) {
23947
24372
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -23951,10 +24376,9 @@ ${style.markdown}` : params.prompt;
23951
24376
  res.status(500).json({ detail: "File uploads are not configured." });
23952
24377
  return;
23953
24378
  }
23954
- const { userSessionPrefix } = buildSessionPrefixes(
23955
- authResult.user.id,
23956
- sessionId
23957
- );
24379
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "read");
24380
+ if (!authed) return;
24381
+ const { userSessionPrefix } = authed;
23958
24382
  let objects;
23959
24383
  try {
23960
24384
  objects = await listS3ObjectsByPrefix(userSessionPrefix, config);
@@ -23984,11 +24408,6 @@ ${style.markdown}` : params.prompt;
23984
24408
  app.post(
23985
24409
  "/sessions/:sessionId/files/upload-sign",
23986
24410
  async (req, res) => {
23987
- const authResult = await requestValidators.authenticate(req);
23988
- if (!authResult.user?.id) {
23989
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
23990
- return;
23991
- }
23992
24411
  const sessionId = req.params.sessionId;
23993
24412
  if (!sessionId) {
23994
24413
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -23998,6 +24417,8 @@ ${style.markdown}` : params.prompt;
23998
24417
  res.status(500).json({ detail: "File uploads are not configured." });
23999
24418
  return;
24000
24419
  }
24420
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
24421
+ if (!authed) return;
24001
24422
  const { filename, contentType } = req.body ?? {};
24002
24423
  if (!filename || typeof filename !== "string") {
24003
24424
  res.status(400).json({ detail: "Missing filename in request body." });
@@ -24012,11 +24433,7 @@ ${style.markdown}` : params.prompt;
24012
24433
  res.status(400).json({ detail: "Missing contentType in request body." });
24013
24434
  return;
24014
24435
  }
24015
- const { userSessionPrefix, fullSessionPrefix } = buildSessionPrefixes(
24016
- authResult.user.id,
24017
- sessionId
24018
- );
24019
- const fullKey = `${fullSessionPrefix}${safeName}`;
24436
+ const fullKey = `${authed.fullSessionPrefix}${safeName}`;
24020
24437
  const uploadUrl = await getS3SignedUploadUrl(fullKey, contentType, config);
24021
24438
  res.json({ uploadUrl, key: fullKey });
24022
24439
  }
@@ -24024,11 +24441,6 @@ ${style.markdown}` : params.prompt;
24024
24441
  app.post(
24025
24442
  "/sessions/:sessionId/files/sync-to-sandbox",
24026
24443
  async (req, res) => {
24027
- const authResult = await requestValidators.authenticate(req);
24028
- if (!authResult.user?.id) {
24029
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
24030
- return;
24031
- }
24032
24444
  const sessionId = req.params.sessionId;
24033
24445
  if (!sessionId) {
24034
24446
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -24039,18 +24451,16 @@ ${style.markdown}` : params.prompt;
24039
24451
  res.status(400).json({ detail: "Missing key in request body." });
24040
24452
  return;
24041
24453
  }
24042
- const { fullSessionPrefix } = buildSessionPrefixes(
24043
- authResult.user.id,
24044
- sessionId
24045
- );
24046
- if (!key.startsWith(fullSessionPrefix)) {
24454
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
24455
+ if (!authed) return;
24456
+ if (!key.startsWith(authed.fullSessionPrefix)) {
24047
24457
  res.status(403).json({ detail: "Key does not belong to this session." });
24048
24458
  return;
24049
24459
  }
24050
24460
  try {
24051
24461
  const result = await downloadKeyIntoSandbox({
24052
24462
  sessionId,
24053
- userId: authResult.user.id,
24463
+ userId: authed.ownerId,
24054
24464
  fullS3Key: key,
24055
24465
  config
24056
24466
  });
@@ -24064,11 +24474,6 @@ ${style.markdown}` : params.prompt;
24064
24474
  app.delete(
24065
24475
  "/sessions/:sessionId/files",
24066
24476
  async (req, res) => {
24067
- const authResult = await requestValidators.authenticate(req);
24068
- if (!authResult.user?.id) {
24069
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
24070
- return;
24071
- }
24072
24477
  const sessionId = req.params.sessionId;
24073
24478
  if (!sessionId) {
24074
24479
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -24079,11 +24484,9 @@ ${style.markdown}` : params.prompt;
24079
24484
  res.status(400).json({ detail: "Missing or invalid 'key' query parameter." });
24080
24485
  return;
24081
24486
  }
24082
- const { fullSessionPrefix } = buildSessionPrefixes(
24083
- authResult.user.id,
24084
- sessionId
24085
- );
24086
- if (!key.startsWith(fullSessionPrefix)) {
24487
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "write");
24488
+ if (!authed) return;
24489
+ if (!key.startsWith(authed.fullSessionPrefix)) {
24087
24490
  res.status(403).json({ detail: "Key does not belong to this session." });
24088
24491
  return;
24089
24492
  }
@@ -24102,11 +24505,6 @@ ${style.markdown}` : params.prompt;
24102
24505
  if (!req.headers.authorization && typeof req.query.auth === "string") {
24103
24506
  req.headers.authorization = `Bearer ${req.query.auth}`;
24104
24507
  }
24105
- const authResult = await requestValidators.authenticate(req);
24106
- if (!authResult.user?.id) {
24107
- res.status(authResult.code ?? 401).json({ detail: authResult.message });
24108
- return;
24109
- }
24110
24508
  const sessionId = req.params.sessionId;
24111
24509
  if (!sessionId) {
24112
24510
  res.status(400).json({ detail: "Missing sessionId in path." });
@@ -24117,11 +24515,9 @@ ${style.markdown}` : params.prompt;
24117
24515
  res.status(400).json({ detail: "Missing or invalid 'key' query parameter." });
24118
24516
  return;
24119
24517
  }
24120
- const { fullSessionPrefix } = buildSessionPrefixes(
24121
- authResult.user.id,
24122
- sessionId
24123
- );
24124
- if (!key.startsWith(fullSessionPrefix)) {
24518
+ const authed = await loadSessionFilesAuth(req, res, sessionId, "read");
24519
+ if (!authed) return;
24520
+ if (!key.startsWith(authed.fullSessionPrefix)) {
24125
24521
  res.status(403).json({ detail: "Key does not belong to this session." });
24126
24522
  return;
24127
24523
  }
@@ -24225,7 +24621,7 @@ ${style.markdown}` : params.prompt;
24225
24621
  ` - tracking.json tracking events linked to the user`,
24226
24622
  ``
24227
24623
  ].join("\n");
24228
- const zip = new import_jszip2.default();
24624
+ const zip = new import_jszip3.default();
24229
24625
  zip.file("README.txt", readme);
24230
24626
  zip.file("user_data.json", JSON.stringify(userExport, null, 2));
24231
24627
  zip.file("sessions.json", JSON.stringify(sessionsWithMessages, null, 2));