openclacky 1.3.9 → 1.3.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7208295135923baf898665ce973cbd07ec3e218437cf896bf3a6a2644b2d026
4
- data.tar.gz: ced24c041431be4afde0a6237afe250ca0876091c1b5f0ee3bf28eb4d166c152
3
+ metadata.gz: de09b43a446d08c592e1cd49deb914a3ff8c70375cf653f130d2aef9a1cd460e
4
+ data.tar.gz: 8030ce355abbe973c9b8294995744b5d4a96e3e38bfc494cb081621172619085
5
5
  SHA512:
6
- metadata.gz: eab17f40014529d41ab26f6aee158caee98f3d6a78eef6066bff86f65d24cd8cc949f20c4ce31e3a878c11bb0e70ee6210dbe68b2dbfcd2e4840dd041257e506
7
- data.tar.gz: cfdbd77b8bdd77d8a065fe3eea1222e6d272f792ad116ca511f50becb25f0db5690bb6683cddd8e1f33fe25228bacadaec32582d6d37daa3596d3d6133f19e10
6
+ metadata.gz: bd57f31e2d97f1a98b10a775017e2c42ce6354e3fab4a87f9392e884ecca3a2ca3c3ec19d30e8adcb7d8498330446b3d80be787c3594e26b63d07a3e2349f9a7
7
+ data.tar.gz: 4b18ae8839c6bd9ab319c7077dca1bc61ea836d38152438215c61a524099c7e17213b0b76f37f8157621a7b168325042fe3137bf7d0b5e257a3609c58ca7c8fc
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.3.10] - 2026-07-09
8
+
9
+ ### Added
10
+ - Extension author shown inline in title row on extension cards
11
+ - Version input in publish modal — writes back to `ext.yml`, shows friendly Chinese error on version conflict
12
+ - Local extensions now included in the installed extensions list
13
+
14
+ ### Improved
15
+ - Published extensions list in publish panel — better style and UX
16
+ - Publishing state, success label, and done button added to publish modal flow
17
+ - OSS CDN prioritized for latest version check (faster upgrade detection)
18
+
19
+ ### Fixed
20
+ - Agent card description clamped to 2 lines; author hidden for built-in agents
21
+ - New-session scrollbar centering — use padding calc instead of max-width
22
+ - `file://` links with spaces and non-ASCII paths now render correctly (#350)
23
+ - Invalid API key error message improved to cover expired key case
24
+ - Local extension card in ext-studio now correctly appends action buttons
25
+ - Navigate back to list when extension detail fails to load
26
+ - Page reloads correctly after install/enable/disable/uninstall extension
27
+ - Enriched installed extensions with market data; shows unlisted badge
28
+
7
29
  ## [1.3.9] - 2026-07-08
8
30
 
9
31
  ### Added
@@ -129,7 +129,33 @@ class ExtStudioExt < Clacky::ApiExtension
129
129
  json(ok: true, ext_id: ext_id)
130
130
  end
131
131
 
132
- # POST /api/ext/ext-studio/develop
132
+ # POST /api/ext/ext-studio/set_version
133
+ # body: { ext_id, version }
134
+ # Writes the new version string back to the local ext.yml.
135
+ post "/set_version" do
136
+ ext_id = require_ext_id!
137
+ version = presence(json_body["version"])
138
+ error!("version required", status: 422) unless version
139
+
140
+ result = Clacky::ExtensionLoader.load_all(force: false)
141
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
142
+ error!("extension not found: #{ext_id}", status: 404) unless container
143
+
144
+ yml_path = File.join(container[:dir], "ext.yml")
145
+ error!("ext.yml not found", status: 404) unless File.exist?(yml_path)
146
+
147
+ content = File.read(yml_path)
148
+ if content =~ /^version:/
149
+ content = content.sub(/^version:.*$/, "version: #{version}")
150
+ else
151
+ content = content.rstrip + "\nversion: #{version}\n"
152
+ end
153
+ File.write(yml_path, content)
154
+
155
+ json(ok: true, ext_id: ext_id, version: version)
156
+ end
157
+
158
+
133
159
  # body: { idea? }
134
160
  # Spawns a session bound to the ext-developer agent, optionally seeded with
135
161
  # the user's idea as the first task — the "let AI build it for me" entry.
@@ -19,8 +19,8 @@ contributes:
19
19
  - id: ext-developer
20
20
  title: Extension Developer
21
21
  title_zh: 扩展开发
22
- description: AI expert that helps you build, debug, and publish OpenClacky extensions
23
- description_zh: 帮你开发、调试、发布 OpenClacky 扩展的 AI 专家
22
+ description: AI expert that helps you build, debug, and publish extensions
23
+ description_zh: 帮你开发、调试、发布扩展的 AI 专家
24
24
  order: 5
25
25
  prompt: agents/ext-developer/system_prompt.md
26
26
  avatar: agents/ext-developer/avatar.png
@@ -48,9 +48,11 @@
48
48
  "btn.unpublish": "Unpublish",
49
49
  "published.confirm": "Unpublish {{id}} from the marketplace?",
50
50
  "err.generic": "Something went wrong: {{msg}}",
51
+ "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
+ "pub.version.label": "Version",
51
53
 
52
54
  "publish.confirm.title": "Publish to the marketplace?",
53
- "publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the OpenClacky marketplace, where anyone can discover and install it. Make sure it's ready to share publicly.",
55
+ "publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it. Make sure it's ready to share publicly.",
54
56
  "publish.confirm.ok": "Publish",
55
57
  "publish.confirm.cancel": "Cancel",
56
58
  "bind.title": "Authorize this device",
@@ -67,7 +69,7 @@
67
69
 
68
70
  "pub.title.new": "Publish to the marketplace",
69
71
  "pub.title.update": "Publish a new version",
70
- "pub.intro": "\"{{name}}\" will be packed and uploaded to the OpenClacky marketplace, where anyone can discover and install it.",
72
+ "pub.intro": "\"{{name}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it.",
71
73
  "pub.version.new": "First release · v{{ver}}",
72
74
  "pub.version.update": "v{{prev}} → v{{ver}}",
73
75
  "pub.version.missing": "No version in ext.yml — add a \"version\" field before publishing.",
@@ -77,10 +79,13 @@
77
79
  "pub.readme.ok": "README.md detected",
78
80
  "pub.readme.missing": "No README.md — consider adding one so users understand your extension.",
79
81
  "pub.btn.publish": "Publish v{{ver}}",
82
+ "pub.btn.publishing": "Publishing…",
83
+ "pub.btn.done": "Done",
80
84
  "pub.btn.cancel": "Cancel",
81
85
  "pub.progress.packing": "Packing…",
82
86
  "pub.progress.uploading": "Uploading to marketplace…",
83
87
  "pub.done": "Published {{id}} v{{ver}} — {{status}}",
88
+ "pub.success": "Published successfully",
84
89
  "pub.done.close": "Done",
85
90
  "pub.retry": "Try again",
86
91
  "extlist.section.cloud": "Published Extensions",
@@ -132,7 +137,7 @@
132
137
  "skills.shadow.tooltip": "Local copy shadows a same-named brand skill",
133
138
  "skills.newSkill.label": "Create a new skill with /skill-creator",
134
139
  "skills.newSkill.btn": "Create New Skill",
135
- "skills.promo.text": "Publish your skills & build your own brand on OpenClacky.",
140
+ "skills.promo.text": "Publish your skills & build your own brand.",
136
141
  "skills.promo.link": "Learn more →",
137
142
  "skills.locked": "Creator license required to publish cloud skills.",
138
143
  "skills.publishing": "Publishing…",
@@ -178,9 +183,11 @@
178
183
  "btn.unpublish": "下架",
179
184
  "published.confirm": "确定要从市场下架 {{id}} 吗?",
180
185
  "err.generic": "出错了:{{msg}}",
186
+ "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
187
+ "pub.version.label": "版本号",
181
188
 
182
189
  "publish.confirm.title": "确定发布到市场?",
183
- "publish.confirm.body": "「{{id}}」将被打包并上传到 OpenClacky 市场,任何人都能发现并安装它。请确认它已准备好公开分享。",
190
+ "publish.confirm.body": "「{{id}}」将被打包并上传到扩展市场,任何人都能发现并安装它。请确认它已准备好公开分享。",
184
191
  "publish.confirm.ok": "发布",
185
192
  "publish.confirm.cancel": "取消",
186
193
  "bind.title": "授权此设备",
@@ -197,7 +204,7 @@
197
204
 
198
205
  "pub.title.new": "发布到市场",
199
206
  "pub.title.update": "发布新版本",
200
- "pub.intro": "「{{name}}」将被打包并上传到 OpenClacky 市场,任何人都能发现并安装它。",
207
+ "pub.intro": "「{{name}}」将被打包并上传到扩展市场,任何人都能发现并安装它。",
201
208
  "pub.version.new": "首次发布 · v{{ver}}",
202
209
  "pub.version.update": "v{{prev}} → v{{ver}}",
203
210
  "pub.version.missing": "ext.yml 里没有 version — 请先补上 version 字段再发布。",
@@ -207,10 +214,13 @@
207
214
  "pub.readme.ok": "已检测到 README.md",
208
215
  "pub.readme.missing": "未找到 README.md — 建议补一份,方便用户了解你的扩展。",
209
216
  "pub.btn.publish": "发布 v{{ver}}",
217
+ "pub.btn.publishing": "发布中…",
218
+ "pub.btn.done": "完成",
210
219
  "pub.btn.cancel": "取消",
211
220
  "pub.progress.packing": "打包中…",
212
221
  "pub.progress.uploading": "上传到市场…",
213
222
  "pub.done": "已发布 {{id}} v{{ver}} — {{status}}",
223
+ "pub.success": "已发布成功",
214
224
  "pub.done.close": "完成",
215
225
  "pub.retry": "重试",
216
226
  "extlist.section.cloud": "已发布扩展",
@@ -262,7 +272,7 @@
262
272
  "skills.shadow.tooltip": "本地副本覆盖了同名品牌 skill",
263
273
  "skills.newSkill.label": "用 /skill-creator 创建新 skill",
264
274
  "skills.newSkill.btn": "创建新 Skill",
265
- "skills.promo.text": "发布你的 skill,在 OpenClacky 上打造自己的品牌。",
275
+ "skills.promo.text": "发布你的 skill,打造自己的品牌。",
266
276
  "skills.promo.link": "了解更多 →",
267
277
  "skills.locked": "发布云端 skill 需要创作者授权。",
268
278
  "skills.publishing": "发布中…",
@@ -465,44 +475,48 @@
465
475
 
466
476
  // Unified publish flow: one modal that walks the creator from a release form
467
477
  // (version + notes) through progress → device binding (if needed) → done.
468
- // The creator owns the version in ext.yml; a new release must be greater than
469
- // the currently published version — the backend enforces that. `prevVersion`
470
- // is the latest published version when updating, or null for a first release.
478
+ // The creator owns the version; if it conflicts with the latest published
479
+ // version they can bump it inline — the modal writes it back to ext.yml
480
+ // before uploading. `prevVersionOrPromise` may be a value or a Promise.
471
481
  // Resolves true when a publish succeeded, false otherwise.
472
- function runPublishFlow(ext, prevVersion) {
482
+ function runPublishFlow(ext, prevVersionOrPromise) {
473
483
  return new Promise((resolve) => {
474
- const version = ext.version;
475
- const isUpdate = !!prevVersion;
476
-
477
- const intro = el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) });
478
-
479
- const verText = !version
480
- ? t("pub.version.missing")
481
- : isUpdate
482
- ? t("pub.version.update", { prev: prevVersion, ver: version })
483
- : t("pub.version.new", { ver: version });
484
-
485
- const metaBlock = el("div", { class: "studio-pub-meta" });
486
- metaBlock.appendChild(el("span", { class: "studio-pub-version", text: verText }));
484
+ let currentVersion = ext.version || "";
485
+ let isUpdate = false;
486
+
487
+ const verField = el("div", { class: "studio-field" });
488
+ verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
489
+ const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
490
+ verInput.addEventListener("input", () => {
491
+ currentVersion = verInput.value.trim();
492
+ publishBtn.disabled = !currentVersion;
493
+ publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
494
+ });
495
+ verField.appendChild(verInput);
487
496
 
488
497
  const notesField = el("div", { class: "studio-field" });
489
498
  notesField.appendChild(el("label", { class: "studio-label", text: t("pub.notes.label") }));
490
- const notes = el("textarea", { class: "studio-textarea", rows: "3", placeholder: t("pub.notes.placeholder") });
499
+ const notes = el("textarea", { class: "studio-textarea", rows: "4", placeholder: t("pub.notes.placeholder") });
491
500
  notesField.appendChild(notes);
492
501
 
493
502
  const status = el("p", { class: "studio-modal-status" });
494
503
  status.style.display = "none";
495
504
 
496
- const bodyChildren = [intro, metaBlock, notesField, status];
505
+ const bodyChildren = [
506
+ el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) }),
507
+ verField,
508
+ notesField,
509
+ status,
510
+ ];
497
511
 
498
512
  let done = false;
499
513
  const modal = openModal({
500
- title: isUpdate ? t("pub.title.update") : t("pub.title.new"),
514
+ title: t("pub.title.new"),
501
515
  body: el("div", null, bodyChildren),
502
516
  buttons: [
503
517
  { label: t("pub.btn.cancel"), keepOpen: false, onClick: () => { if (!done) resolve(false); } },
504
518
  {
505
- label: version ? t("pub.btn.publish", { ver: version }) : t("pub.btn.publish", { ver: "?" }),
519
+ label: currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" }),
506
520
  primary: true,
507
521
  keepOpen: true,
508
522
  onClick: () => submit(),
@@ -512,50 +526,82 @@
512
526
 
513
527
  const publishBtn = modal.footer.querySelector(".studio-btn-primary");
514
528
  const cancelBtn = modal.footer.querySelector(".studio-btn:not(.studio-btn-primary)");
515
- if (!version) publishBtn.disabled = true;
529
+ if (!currentVersion) publishBtn.disabled = true;
530
+
531
+ Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
532
+ if (done || !prevVersion) return;
533
+ isUpdate = true;
534
+ const titleEl = modal.overlay.querySelector(".studio-modal-title");
535
+ if (titleEl) titleEl.textContent = t("pub.title.update");
536
+ if (prevVersion !== currentVersion) {
537
+ currentVersion = prevVersion;
538
+ verInput.value = currentVersion;
539
+ publishBtn.disabled = false;
540
+ publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
541
+ }
542
+ });
516
543
 
517
- function setProgress(msg) {
544
+ function setProgress(msg, isError) {
518
545
  status.style.display = "";
519
546
  status.textContent = msg;
547
+ status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
548
+ }
549
+
550
+ function resetButtons() {
551
+ publishBtn.disabled = !currentVersion;
552
+ publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
553
+ cancelBtn.disabled = false;
554
+ notes.disabled = false;
555
+ verInput.disabled = false;
520
556
  }
521
557
 
522
558
  async function submit() {
523
559
  if (done || publishBtn.disabled) return;
560
+ const ver = currentVersion;
561
+ if (!ver) return;
562
+
524
563
  publishBtn.disabled = true;
564
+ publishBtn.textContent = t("pub.btn.publishing");
525
565
  cancelBtn.disabled = true;
526
566
  notes.disabled = true;
527
- setProgress(t("pub.progress.packing"));
567
+ verInput.disabled = true;
568
+ status.style.display = "none";
569
+
528
570
  try {
529
- setProgress(t("pub.progress.uploading"));
571
+ if (ver !== ext.version) {
572
+ await postJson("/set_version", { ext_id: ext.id, version: ver });
573
+ }
574
+
530
575
  const data = await publishWithBinding({
531
576
  ext_id: ext.id,
532
577
  force: isUpdate,
533
578
  changelog: notes.value.trim(),
534
579
  });
535
- if (data === null) {
536
- publishBtn.disabled = false;
537
- cancelBtn.disabled = false;
538
- notes.disabled = false;
539
- status.style.display = "none";
540
- return;
541
- }
580
+
581
+ if (data === null) { resetButtons(); return; }
542
582
  if (!data.ok) throw new Error(data.error || "Publish failed");
583
+
543
584
  done = true;
544
- setProgress(t("pub.done", {
545
- id: data.ext_id,
546
- ver: data.version || version,
547
- status: data.status || "",
548
- }));
549
- publishBtn.style.display = "none";
550
- cancelBtn.textContent = t("pub.done.close");
551
- cancelBtn.disabled = false;
552
- cancelBtn.onclick = () => { modal.close(); resolve(true); };
553
- } catch (e) {
554
- setProgress(t("err.generic", { msg: e.message }));
585
+ const successLabel = el("span", { class: "studio-pub-success", text: t("pub.success") });
586
+ modal.footer.insertBefore(successLabel, modal.footer.firstChild);
587
+ cancelBtn.style.display = "none";
555
588
  publishBtn.disabled = false;
556
- publishBtn.textContent = t("pub.retry");
557
- cancelBtn.disabled = false;
558
- notes.disabled = false;
589
+ publishBtn.textContent = t("pub.btn.done");
590
+ publishBtn.onclick = () => { modal.close(); resolve(true); };
591
+ } catch (e) {
592
+ const msg = e.message || "";
593
+ const isConflict = /must be greater than/i.test(msg);
594
+ if (isConflict) {
595
+ setProgress(t("err.version_conflict", { ver }), true);
596
+ verInput.classList.add("studio-input-error");
597
+ verInput.disabled = false;
598
+ verInput.focus();
599
+ verInput.select();
600
+ verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
601
+ } else {
602
+ setProgress(t("err.generic", { msg }), true);
603
+ }
604
+ resetButtons();
559
605
  }
560
606
  }
561
607
  });
@@ -739,13 +785,13 @@
739
785
  async function doPublish() {
740
786
  const ext = store.selected();
741
787
  if (!ext) return;
742
- let prevVersion = null;
743
- try {
744
- const data = await getJson("/published");
745
- const match = (data.extensions || []).find((e) => e.id === ext.id);
746
- prevVersion = match ? match.version : null;
747
- } catch (_e) { /* treat as first release */ }
748
- const ok = await runPublishFlow(ext, prevVersion);
788
+ const prevVersionPromise = getJson("/published")
789
+ .then((data) => {
790
+ const match = (data.extensions || []).find((e) => e.id === ext.id);
791
+ return match ? match.version : null;
792
+ })
793
+ .catch(() => null);
794
+ const ok = await runPublishFlow(ext, prevVersionPromise);
749
795
  if (ok) loadPublished();
750
796
  }
751
797
 
@@ -774,17 +820,23 @@
774
820
  const exts = data.extensions || [];
775
821
  if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t("published.empty") })); return; }
776
822
  exts.forEach((e) => {
777
- const row = el("div", { class: "studio-published-row" });
778
- row.appendChild(el("span", { class: "studio-published-name", text: `${e.name}${e.version ? " v" + e.version : ""} [${e.status}]` }));
779
- row.appendChild(el("button", {
780
- class: "studio-btn studio-btn-danger", text: t("btn.unpublish"),
781
- onclick: async () => {
782
- if (!window.confirm(t("published.confirm", { id: e.id }))) return;
783
- try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
784
- catch (err) { feedback(t("err.generic", { msg: err.message }), "error"); }
785
- },
786
- }));
787
- box.appendChild(row);
823
+ const card = el("div", { class: "studio-skill-card" });
824
+ const head = el("div", { class: "studio-skill-head" });
825
+ head.appendChild(el("span", { class: "studio-skill-name", text: e.name || e.id }));
826
+ const statusKind = e.status === "draft" ? "local" : "published";
827
+ head.appendChild(el("span", { class: `studio-skill-badge studio-skill-badge-${statusKind}`, text: e.status === "draft" ? t("extlist.badge.draft") : t("extlist.badge.published") }));
828
+ if (e.version) head.appendChild(el("span", { style: "font-size:11px;color:var(--color-text-muted);", text: "v" + e.version }));
829
+ const unpubBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("btn.unpublish") });
830
+ unpubBtn.style.marginLeft = "auto";
831
+ unpubBtn.addEventListener("click", async () => {
832
+ if (!window.confirm(t("published.confirm", { id: e.id }))) return;
833
+ unpubBtn.disabled = true;
834
+ try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
835
+ catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
836
+ });
837
+ head.appendChild(unpubBtn);
838
+ card.appendChild(head);
839
+ box.appendChild(card);
788
840
  });
789
841
  } catch (e) {
790
842
  box.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) }));
@@ -927,6 +979,7 @@
927
979
  const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
928
980
  packBtn.addEventListener("click", () => doPack(ext, packBtn));
929
981
  actions.appendChild(packBtn);
982
+ card.appendChild(actions);
930
983
  return card;
931
984
  }
932
985
 
@@ -1291,8 +1344,8 @@
1291
1344
  .studio-feedback-error { color: var(--color-error); }
1292
1345
  .studio-feedback-warn { color: var(--color-warning, var(--color-text-secondary)); }
1293
1346
  .studio-published { margin-top: 16px; border-top: 1px solid var(--color-border-primary); padding-top: 12px; }
1294
- .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; font-size: 12px; }
1295
- .studio-published-name { color: var(--color-text-primary); }
1347
+ .studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; font-size: 12px; border-bottom: 1px solid var(--color-border-primary); }
1348
+ .studio-published-name { display: flex; align-items: center; gap: 6px; color: var(--color-text-primary); }
1296
1349
  .studio-page { width: 100%; }
1297
1350
  .studio-page-head { margin-bottom: 20px; }
1298
1351
  .studio-page-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
@@ -1305,7 +1358,7 @@
1305
1358
  .studio-skill-section { margin-bottom: 28px; }
1306
1359
  .studio-skill-section-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
1307
1360
  .studio-skill-hint { font-size: 11px; color: var(--color-text-muted); }
1308
- .studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 14px 16px; margin: 0 0 10px; background: var(--color-bg-secondary); }
1361
+ .studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 12px; margin: 0 0 10px; background: var(--color-bg-secondary); }
1309
1362
  .studio-skill-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
1310
1363
  .studio-skill-name { font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1311
1364
  .studio-skill-badge { font-size: 11px; padding: 1px 8px; border-radius: 10px; }
@@ -1314,7 +1367,7 @@
1314
1367
  .studio-skill-badge-changed { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
1315
1368
  .studio-skill-badge-shadow { background: var(--color-bg-hover); color: var(--color-text-secondary); }
1316
1369
  .studio-skill-desc { margin: 6px 0 8px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
1317
- .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); margin-bottom: 8px; }
1370
+ .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); }
1318
1371
  .studio-skill-promo { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 16px 18px; margin: 0 0 24px; background: var(--color-bg-secondary); }
1319
1372
  .studio-skill-promo-text { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1320
1373
  .studio-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; animation: studio-overlay-in 0.15s ease; }
@@ -1329,9 +1382,14 @@
1329
1382
  .studio-pub-units::before { content: "·"; margin-right: 10px; }
1330
1383
  .studio-modal-body { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1331
1384
  .studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1385
+ .studio-modal-status-error { color: var(--color-error); }
1386
+ .studio-input { width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 7px 10px; color: var(--color-text-primary); font-size: 13px; font-family: monospace; }
1387
+ .studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
1388
+ .studio-input-error { border-color: var(--color-error) !important; }
1332
1389
  .studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
1333
1390
  .studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
1334
1391
  .studio-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--color-border-primary); }
1392
+ .studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
1335
1393
  `;
1336
1394
  document.head.appendChild(style);
1337
1395
  })();
@@ -5,7 +5,7 @@ module Clacky
5
5
  EN = {
6
6
  "llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
7
7
  "llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
8
- "llm.error.invalid_api_key" => "Invalid API key, please check your configuration",
8
+ "llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
9
9
  "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
10
  "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
11
  "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
@@ -5,7 +5,7 @@ module Clacky
5
5
  ZH = {
6
6
  "llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
7
7
  "llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
8
- "llm.error.invalid_api_key" => "API 密钥无效,请检查配置",
8
+ "llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
9
9
  "llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
10
10
  "llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
11
11
  "llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
@@ -3,7 +3,7 @@
3
3
  - Ask clarifying questions if requirements are unclear.
4
4
  - Break down complex tasks into manageable steps.
5
5
  - **USE TOOLS to create/modify files** — don't just return content.
6
- - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply.
6
+ - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
7
7
 
8
8
  ## Tool Usage Rules
9
9
 
@@ -117,6 +117,7 @@ module Clacky
117
117
  WEB_ROOT = File.expand_path("../web", __dir__)
118
118
  EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
119
119
  EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
120
+ OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
120
121
 
121
122
  # Default SOUL.md written when the user skips the onboard conversation.
122
123
  # A richer version is created by the Agent during the soul_setup phase.
@@ -538,7 +539,8 @@ module Clacky
538
539
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
539
540
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
540
541
  when ["GET", "/api/store/skills"] then api_store_skills(res)
541
- when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
542
+ when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
543
+ when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
542
544
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
543
545
  when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
544
546
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
@@ -2347,6 +2349,66 @@ module Clacky
2347
2349
  end
2348
2350
  end
2349
2351
 
2352
+ # GET /api/store/extensions/installed
2353
+ #
2354
+ # Returns all locally installed extensions (all layers: builtin, installed,
2355
+ # local) regardless of whether they are still listed on the marketplace.
2356
+ def api_store_extensions_installed(res)
2357
+ result = Clacky::ExtensionLoader.load_all
2358
+ disabled = Clacky::ExtensionLoader.disabled_ids
2359
+
2360
+ local_entries = Array(result&.containers).filter_map do |ext_id, container|
2361
+ next unless container[:layer] == :installed
2362
+
2363
+ [ext_id, container]
2364
+ end.to_h
2365
+
2366
+ market_by_slug = fetch_batch_market_data(local_entries.keys)
2367
+
2368
+ extensions = local_entries.map do |ext_id, container|
2369
+ market = market_by_slug[ext_id]
2370
+ {
2371
+ "id" => ext_id,
2372
+ "name" => market ? (market["name"] || ext_id) : ext_id,
2373
+ "name_zh" => market&.dig("name_zh"),
2374
+ "name_en" => market&.dig("name_en"),
2375
+ "slug" => ext_id,
2376
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2377
+ "description" => market&.dig("description"),
2378
+ "author" => market&.dig("author"),
2379
+ "icon_url" => market&.dig("icon_url"),
2380
+ "units" => market&.dig("units"),
2381
+ "homepage" => market ? (market["homepage"] || "") : "",
2382
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2383
+ "hub_active" => market&.dig("hub_active"),
2384
+ "unlisted" => market.nil?,
2385
+ "layer" => container[:layer].to_s,
2386
+ "installed" => true,
2387
+ "removable" => true,
2388
+ "disabled" => disabled.include?(ext_id),
2389
+ }
2390
+ end
2391
+
2392
+ json_response(res, 200, { ok: true, extensions: extensions })
2393
+ rescue StandardError => e
2394
+ json_response(res, 500, { ok: false, error: e.message })
2395
+ end
2396
+
2397
+ private def fetch_batch_market_data(slugs)
2398
+ return {} if slugs.empty?
2399
+
2400
+ client = Clacky::PlatformHttpClient.new
2401
+ slugs.each_slice(50).each_with_object({}) do |batch, result|
2402
+ ids_param = batch.join(",")
2403
+ response = client.get("/api/v1/extensions/batch?ids=#{ids_param}")
2404
+ next unless response[:success]
2405
+
2406
+ Array(response.dig(:data, "extensions")).each { |ext| result[ext["name"]] = ext }
2407
+ end
2408
+ rescue StandardError
2409
+ {}
2410
+ end
2411
+
2350
2412
  # Slugs of every extension container currently loaded (any layer), used to
2351
2413
  # flag "installed" on the public marketplace catalog.
2352
2414
  def installed_extension_slugs
@@ -2381,7 +2443,32 @@ module Clacky
2381
2443
  )
2382
2444
  json_response(res, 200, { ok: true, extension: ext })
2383
2445
  else
2384
- json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2446
+ container = extension_container(id)
2447
+ if container && container[:layer] == :installed
2448
+ market = fetch_batch_market_data([id])[id]
2449
+ ext = {
2450
+ "id" => id,
2451
+ "name" => market ? (market["name"] || id) : id,
2452
+ "name_zh" => market&.dig("name_zh"),
2453
+ "name_en" => market&.dig("name_en"),
2454
+ "slug" => id,
2455
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2456
+ "description" => market&.dig("description"),
2457
+ "author" => market&.dig("author"),
2458
+ "icon_url" => market&.dig("icon_url"),
2459
+ "units" => market&.dig("units"),
2460
+ "homepage" => market ? (market["homepage"] || "") : "",
2461
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2462
+ "hub_active" => market&.dig("hub_active"),
2463
+ "unlisted" => market.nil?,
2464
+ "installed" => true,
2465
+ "removable" => true,
2466
+ "disabled" => container[:disabled] == true,
2467
+ }
2468
+ json_response(res, 200, { ok: true, extension: ext })
2469
+ else
2470
+ json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2471
+ end
2385
2472
  end
2386
2473
  end
2387
2474
 
@@ -2899,7 +2986,7 @@ module Clacky
2899
2986
  require "net/http"
2900
2987
  require "uri"
2901
2988
 
2902
- oss_base = "https://oss.1024code.com/openclacky"
2989
+ oss_base = OSS_CDN_BASE
2903
2990
  latest_url = "#{oss_base}/latest.txt"
2904
2991
 
2905
2992
  Clacky::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
@@ -3071,11 +3158,12 @@ module Clacky
3071
3158
  end
3072
3159
 
3073
3160
  # Query the latest openclacky version.
3074
- # Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
3075
- # then fall back to `gem list -r` (respects user's configured gem source).
3076
- # Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
3161
+ # Strategy: OSS CDN latest.txt first (fast, CDN-accelerated), then RubyGems API,
3162
+ # then fall back to `gem list -r` as a last resort.
3077
3163
  private def fetch_latest_version_from_gem
3078
- fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
3164
+ fetch_oss_latest_version("#{OSS_CDN_BASE}/latest.txt") ||
3165
+ fetch_latest_version_from_rubygems_api ||
3166
+ fetch_latest_version_from_gem_command
3079
3167
  end
3080
3168
 
3081
3169
  # Try RubyGems official REST API — fast and always up-to-date.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.9"
4
+ VERSION = "1.3.10"
5
5
  end
@@ -1874,9 +1874,7 @@ body {
1874
1874
  flex-direction: column;
1875
1875
  gap: 1.25rem;
1876
1876
  width: 100%;
1877
- max-width: 60rem;
1878
- margin: 0 auto;
1879
- padding: 5.5rem 1.5rem 1rem;
1877
+ padding: 6rem max(1.5rem, calc(50% - 30rem)) 1rem;
1880
1878
  }
1881
1879
  .new-session-composer {
1882
1880
  position: relative;
@@ -2007,6 +2005,11 @@ body {
2007
2005
  font-size: 0.78125rem;
2008
2006
  color: var(--color-text-tertiary);
2009
2007
  line-height: 1.4;
2008
+ flex: 1;
2009
+ overflow: hidden;
2010
+ display: -webkit-box;
2011
+ -webkit-line-clamp: 2;
2012
+ -webkit-box-orient: vertical;
2010
2013
  }
2011
2014
  .agent-card-badge {
2012
2015
  position: absolute;
@@ -2026,7 +2029,6 @@ body {
2026
2029
  color: var(--color-accent-primary);
2027
2030
  }
2028
2031
  .agent-card-author {
2029
- margin-top: 0.25rem;
2030
2032
  font-size: 0.72rem;
2031
2033
  color: var(--color-text-tertiary);
2032
2034
  opacity: 0.8;
@@ -7169,7 +7171,7 @@ body {
7169
7171
  .extension-card:hover { border-color: var(--color-text-muted); }
7170
7172
  .extension-card-main {
7171
7173
  display: flex;
7172
- align-items: flex-start;
7174
+ align-items: center;
7173
7175
  gap: 0.75rem;
7174
7176
  }
7175
7177
  .extension-emoji {
@@ -7195,7 +7197,7 @@ body {
7195
7197
  color: var(--color-text-secondary);
7196
7198
  background: var(--color-bg-tertiary, rgba(255,255,255,0.05));
7197
7199
  border-radius: 4px;
7198
- padding: 0.0625rem 0rem;
7200
+ padding: 0.0625rem 0.375rem;
7199
7201
  }
7200
7202
  .extension-units {
7201
7203
  font-size: 0.6875rem;
@@ -7208,6 +7210,13 @@ body {
7208
7210
  border-radius: 4px;
7209
7211
  padding: 0.0625rem 0.375rem;
7210
7212
  }
7213
+ .extension-unlisted {
7214
+ font-size: 0.6875rem;
7215
+ color: var(--color-text-secondary);
7216
+ background: var(--color-surface-2, rgba(0,0,0,0.06));
7217
+ border-radius: 4px;
7218
+ padding: 0.0625rem 0.375rem;
7219
+ }
7211
7220
  .extension-desc {
7212
7221
  font-size: 0.75rem;
7213
7222
  color: var(--color-text-secondary);
@@ -7222,6 +7231,11 @@ body {
7222
7231
  font-size: 0.6875rem;
7223
7232
  color: var(--color-text-muted);
7224
7233
  }
7234
+ .extension-author {
7235
+ font-size: 0.75rem;
7236
+ color: var(--color-text-muted);
7237
+ font-weight: 400;
7238
+ }
7225
7239
  .extension-meta {
7226
7240
  display: flex;
7227
7241
  align-items: center;
@@ -77,10 +77,14 @@ const ExtensionsStore = (() => {
77
77
  const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
78
78
  const data = await res.json();
79
79
  _allExtensions = data.extensions || [];
80
- _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
81
80
  _error = data.warning || null;
82
81
  _loading = false;
83
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
82
+ if (_filterInstalled) {
83
+ await Extensions.setFilterInstalled(true);
84
+ } else {
85
+ _extensions = _allExtensions;
86
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
87
+ }
84
88
  } catch (e) {
85
89
  console.error("[Extensions] load failed", e);
86
90
  _extensions = [];
@@ -104,11 +108,26 @@ const ExtensionsStore = (() => {
104
108
  return Extensions.load();
105
109
  },
106
110
 
107
- /** Toggle the "installed only" filter (client-side, no new network request). */
108
- setFilterInstalled(onlyInstalled) {
111
+ /** Toggle the "installed only" filter fetches from local store when enabled. */
112
+ async setFilterInstalled(onlyInstalled) {
109
113
  _filterInstalled = !!onlyInstalled;
110
- _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
111
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
114
+ if (_filterInstalled) {
115
+ _loading = true;
116
+ _emit("extensions:loading");
117
+ try {
118
+ const res = await fetch("/api/store/extensions/installed");
119
+ const data = await res.json();
120
+ _extensions = data.extensions || [];
121
+ } catch (e) {
122
+ console.error("[Extensions] load installed failed", e);
123
+ _extensions = [];
124
+ } finally {
125
+ _loading = false;
126
+ }
127
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
128
+ } else {
129
+ return Extensions.load();
130
+ }
112
131
  },
113
132
 
114
133
  /** Open the detail view for one extension (fetches contributes + versions). */
@@ -158,8 +177,7 @@ const ExtensionsStore = (() => {
158
177
  });
159
178
  const data = await res.json();
160
179
  if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
161
- await Extensions.loadDetail(id);
162
- Extensions.load();
180
+ location.reload();
163
181
  } catch (e) {
164
182
  console.error("[Extensions] setEnabled failed", e);
165
183
  _detailError = e.message;
@@ -184,7 +202,7 @@ const ExtensionsStore = (() => {
184
202
  });
185
203
  const data = await res.json();
186
204
  if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
187
- await Extensions.loadDetail(id);
205
+ location.reload();
188
206
  } catch (e) {
189
207
  console.error("[Extensions] install failed", e);
190
208
  _detailError = e.message;
@@ -203,7 +221,7 @@ const ExtensionsStore = (() => {
203
221
  });
204
222
  const data = await res.json();
205
223
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
206
- await Extensions.loadDetail(id);
224
+ location.reload();
207
225
  } catch (e) {
208
226
  console.error("[Extensions] uninstall failed", e);
209
227
  _detailError = e.message;
@@ -89,12 +89,16 @@ const ExtensionsView = (() => {
89
89
  ? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
90
90
  const installedHtml = ext.installed
91
91
  ? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
92
+ const unlistedHtml = (ext.unlisted && ext.installed)
93
+ ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
92
94
  const unitsText = _formatUnits(ext.units);
93
95
  const unitsHtml = unitsText
94
96
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
95
97
  const homepageHtml = ext.homepage
96
98
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
97
99
  : "";
100
+ const authorHtml = ext.author
101
+ ? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
98
102
 
99
103
  const card = document.createElement("div");
100
104
  card.className = "extension-card extension-card-clickable";
@@ -105,14 +109,14 @@ const ExtensionsView = (() => {
105
109
  <div class="extension-info">
106
110
  <div class="extension-title">
107
111
  <span class="extension-name">${escapeHtml(name)}</span>
112
+ ${authorHtml}
108
113
  ${versionHtml}
109
114
  ${installedHtml}
115
+ ${unlistedHtml}
110
116
  ${unitsHtml}
111
117
  </div>
112
- <div class="extension-desc">${escapeHtml(description)}</div>
113
- <div class="extension-meta">
114
- ${homepageHtml}
115
- </div>
118
+ ${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
119
+ ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
116
120
  </div>
117
121
  </div>`;
118
122
  return card;
@@ -147,9 +151,8 @@ const ExtensionsView = (() => {
147
151
  }
148
152
 
149
153
  if (st.detailError) {
150
- panel.innerHTML = _detailShell(`
151
- <div class="extensions-empty">${escapeHtml(st.detailError)}</div>`);
152
- _wireDetail();
154
+ console.warn("[Extensions] detail error, navigating back:", st.detailError);
155
+ _backToList();
153
156
  return;
154
157
  }
155
158
 
@@ -219,6 +222,8 @@ const ExtensionsView = (() => {
219
222
  ? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
220
223
  const installedHtml = ext.installed
221
224
  ? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
225
+ const unlistedHtml = ext.unlisted
226
+ ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
222
227
  const unitsText = _formatUnits(ext.units);
223
228
  const unitsHtml = unitsText
224
229
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
@@ -226,7 +231,7 @@ const ExtensionsView = (() => {
226
231
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
227
232
  : "";
228
233
  const authorHtml = ext.author
229
- ? `<span class="extension-detail-author">${escapeHtml(ext.author)}</span>` : "";
234
+ ? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
230
235
 
231
236
  return `
232
237
  <div class="extension-detail-hero">
@@ -234,12 +239,14 @@ const ExtensionsView = (() => {
234
239
  <div class="extension-detail-heading">
235
240
  <div class="extension-title">
236
241
  <span class="extension-name extension-name-lg">${escapeHtml(name)}</span>
242
+ ${authorHtml}
237
243
  ${versionHtml}
238
244
  ${installedHtml}
245
+ ${unlistedHtml}
239
246
  ${unitsHtml}
240
247
  </div>
241
248
  ${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
242
- ${(authorHtml || homepageHtml) ? `<div class="extension-meta">${authorHtml}${homepageHtml}</div>` : ""}
249
+ ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
243
250
  ${_renderActions(ext)}
244
251
  </div>
245
252
  </div>
@@ -110,7 +110,7 @@ const NewSessionView = (() => {
110
110
  }
111
111
 
112
112
  const author = (a.author || "").trim();
113
- if (author) {
113
+ if (author && !_isBuiltin(a)) {
114
114
  const by = document.createElement("div");
115
115
  by.className = "agent-card-author";
116
116
  by.textContent = _isZh() ? `作者 ${author}` : `by ${author}`;
@@ -508,7 +508,7 @@ const I18n = (() => {
508
508
  "mcp.title": "MCP Servers",
509
509
  "mcp.subtitle": "Standard Model Context Protocol servers (Claude Desktop / Cursor compatible). Edit ~/.clacky/mcp.json to add or remove servers.",
510
510
  "extensions.title": "Extension Market",
511
- "extensions.subtitle": "Browse and manage your extensions to enhance OpenClacky with agents, skills, and panels.",
511
+ "extensions.subtitle": "Browse and manage extensions add agents, skills, and panels to supercharge your workflow.",
512
512
  "extensions.searchPlaceholder": "Search extensions…",
513
513
  "extensions.sort.newest": "Newest",
514
514
  "extensions.sort.updated": "Recently updated",
@@ -519,7 +519,9 @@ const I18n = (() => {
519
519
  "extensions.noResults": "No extensions match your search.",
520
520
  "extensions.loadFailed": "Could not reach the extension store.",
521
521
  "extensions.homepage": "Homepage",
522
+ "extensions.by": "by",
522
523
  "extensions.installed": "Installed",
524
+ "extensions.unlisted": "Unlisted",
523
525
  "extensions.unit.skill": "skill",
524
526
  "extensions.unit.skills": "skills",
525
527
  "extensions.unit.agent": "agent",
@@ -1469,7 +1471,7 @@ const I18n = (() => {
1469
1471
  "mcp.title": "MCP 工具",
1470
1472
  "mcp.subtitle": "标准 Model Context Protocol 服务(与 Claude Desktop / Cursor 兼容)。编辑 ~/.clacky/mcp.json 添加或移除服务。",
1471
1473
  "extensions.title": "扩展市场",
1472
- "extensions.subtitle": "浏览并管理你的扩展,用 Agent、技能与面板增强 OpenClacky。",
1474
+ "extensions.subtitle": "浏览并管理扩展,用 Agent、技能与面板增强你的工作流。",
1473
1475
  "extensions.searchPlaceholder": "搜索扩展…",
1474
1476
  "extensions.sort.newest": "最新",
1475
1477
  "extensions.sort.updated": "最近更新",
@@ -1480,7 +1482,9 @@ const I18n = (() => {
1480
1482
  "extensions.noResults": "没有匹配的扩展。",
1481
1483
  "extensions.loadFailed": "无法连接扩展市场。",
1482
1484
  "extensions.homepage": "主页",
1485
+ "extensions.by": "作者",
1483
1486
  "extensions.installed": "已安装",
1487
+ "extensions.unlisted": "已下架",
1484
1488
  "extensions.unit.skill": "个技能",
1485
1489
  "extensions.unit.skills": "个技能",
1486
1490
  "extensions.unit.agent": "个 Agent",
@@ -113,6 +113,33 @@ const Sessions = (() => {
113
113
  return html;
114
114
  }
115
115
 
116
+ // Encode file:// URLs inside raw markdown BEFORE marked parses it. The AI
117
+ // emits raw paths (spaces / non-ASCII / % literal); marked's link tokenizer
118
+ // treats a space as the end of the URL, so links with spaces are never
119
+ // recognized. Re-encoding the path (decode then encode, idempotent) turns
120
+ // spaces into %20 and a literal % into %25 so marked sees a valid URL.
121
+ function _encodeFileUrlsInMarkdown(text) {
122
+ return text.replace(
123
+ /(!?\[[^\]]*\]\()(file:\/{2,3})([^)]+)(\))/g,
124
+ (_m, open, scheme, path, close) => {
125
+ try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
126
+ return open + scheme + encodeURI(path) + close;
127
+ }
128
+ );
129
+ }
130
+
131
+ // Normalize a file:// href for use in <a href>. The AI emits raw paths
132
+ // (Chinese chars / spaces literal); encode them to a valid percent-encoded
133
+ // URL. Idempotent: an already-encoded path is decoded first so it isn't
134
+ // double-encoded. Non file:// links pass through untouched.
135
+ function _normalizeFileHref(href) {
136
+ if (typeof href !== "string" || !href.startsWith("file://")) return href;
137
+ const prefix = href.match(/^file:\/{2,3}/)[0];
138
+ let path = href.slice(prefix.length);
139
+ try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
140
+ return prefix + encodeURI(path);
141
+ }
142
+
116
143
  // Run marked on a text string. Returns HTML. Falls back to escaped plain text
117
144
  // if the marked library is unavailable.
118
145
  function _markedParse(text) {
@@ -122,6 +149,7 @@ const Sessions = (() => {
122
149
  const math = [];
123
150
  const PLACEHOLDER = (i) => `\u0000KTX${i}\u0000`;
124
151
  let prepared = _extractMath(text, math, PLACEHOLDER);
152
+ prepared = _encodeFileUrlsInMarkdown(prepared);
125
153
 
126
154
  let html;
127
155
  if (typeof marked !== "undefined") {
@@ -138,7 +166,7 @@ const Sessions = (() => {
138
166
  const renderer = new marked.Renderer();
139
167
  renderer.link = function({ href, title, text }) {
140
168
  const titleAttr = title ? ` title="${title}"` : "";
141
- return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
169
+ return `<a href="${_normalizeFileHref(href)}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
142
170
  };
143
171
  // Override code block rendering: apply syntax highlighting + header with
144
172
  // language label and copy button.
@@ -2307,7 +2335,9 @@ const Sessions = (() => {
2307
2335
  const link = e.target.closest("a[href^='file://']");
2308
2336
  if (!link) return;
2309
2337
  e.preventDefault();
2310
- let filePath = decodeURIComponent(link.getAttribute("href").replace(/^file:\/\//, ""));
2338
+ const rawHref = link.getAttribute("href").replace(/^file:\/\//, "");
2339
+ let filePath;
2340
+ try { filePath = decodeURIComponent(rawHref); } catch (_) { filePath = rawHref; }
2311
2341
  // file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
2312
2342
  if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
2313
2343
  if (!filePath) return;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.9
4
+ version: 1.3.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy