openclacky 1.3.8 → 1.3.9

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: 589d52edb2af8ec7ebea8d596a4b342791230009666a8757f4391b95df22d667
4
- data.tar.gz: 07c80f79e8a9df57b4232a1317b1a6f8b4557cf136289f7ea1b7a9e93caba5aa
3
+ metadata.gz: d7208295135923baf898665ce973cbd07ec3e218437cf896bf3a6a2644b2d026
4
+ data.tar.gz: ced24c041431be4afde0a6237afe250ca0876091c1b5f0ee3bf28eb4d166c152
5
5
  SHA512:
6
- metadata.gz: e474dff0d682c03bbd9756a37e9c64a29d13f51c35b73a76440e62eeac4b92d5ce6251e7357493531ce0669a770607a37a17ceb8715eadf5f849a7b04a6f373a
7
- data.tar.gz: 8863d9a310224162b3fc0dad4513c933ebafcc74be5d2157d890d7a4f2dd667f66c9f658e61d289c078fdf39c5961bb5c0895317553c0f25b5054142f2c127b9
6
+ metadata.gz: eab17f40014529d41ab26f6aee158caee98f3d6a78eef6066bff86f65d24cd8cc949f20c4ce31e3a878c11bb0e70ee6210dbe68b2dbfcd2e4840dd041257e506
7
+ data.tar.gz: cfdbd77b8bdd77d8a065fe3eea1222e6d272f792ad116ca511f50becb25f0db5690bb6683cddd8e1f33fe25228bacadaec32582d6d37daa3596d3d6133f19e10
data/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  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
+
7
+ ## [1.3.9] - 2026-07-08
8
+
9
+ ### Added
10
+ - Installed filter tab in extension marketplace toolbar — quickly view only installed extensions
11
+ - Extension detail page UI improvements with better uninstall behavior
12
+ - `send_data` helper for API extensions to return binary file responses
13
+
14
+ ### Improved
15
+ - Extension marketplace upload flow reworked — `origin` field removed, cleaner package structure
16
+ - Publish modal redesigned with meta block, improved layout, size, and animation
17
+ - Debug panel verify result now shown inline in action bar with auto-hide and friendly hint
18
+
19
+ ### Fixed
20
+ - Zero-byte zip download when exporting extensions — binary responses now handled correctly
21
+ - Filter tab bottom padding adjusted for better underline spacing
22
+ - New-session advanced panel margin-top adjusted
23
+ - Debug panel detail no longer shows origin field
6
24
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
25
 
8
26
  ## [1.3.8] - 2026-07-07
@@ -596,7 +596,7 @@ module Clacky
596
596
  #
597
597
  # Returns { success: true, extension: {...} } or
598
598
  # { success: false, error: "...", already_exists: Boolean }.
599
- def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil)
599
+ def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, origin: 'marketplace')
600
600
  identity = Clacky::Identity.load
601
601
  return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
602
602
 
@@ -607,6 +607,7 @@ module Clacky
607
607
  end
608
608
 
609
609
  fields = { "device_token" => identity.device_token }
610
+ fields["origin"] = origin.to_s if origin
610
611
  fields["status"] = status.to_s if status
611
612
  fields["changelog"] = changelog.to_s if changelog
612
613
 
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  agents:
11
10
  - id: coding
@@ -40,7 +40,6 @@ id: my-ext
40
40
  name: My Extension
41
41
  description: what it does
42
42
  version: "0.1.0"
43
- origin: self
44
43
  contributes:
45
44
  api: api/handler.rb
46
45
  panels:
@@ -48,12 +48,13 @@ class ExtStudioExt < Clacky::ApiExtension
48
48
 
49
49
  # POST /api/ext/ext-studio/pack
50
50
  # body: { ext_id }
51
- # Packs a local container into a zip (into a temp dir) and reports its path.
51
+ # Packs a local container and streams the zip back as a file download.
52
52
  post "/pack" do
53
53
  ext_id = require_ext_id!
54
54
  Dir.mktmpdir("clacky-ext-studio-pack") do |tmp|
55
- res = Clacky::ExtensionPackager.pack(ext_id, out_dir: tmp)
56
- json(ok: true, ext_id: res.ext_id, path: res.path)
55
+ res = Clacky::ExtensionPackager.pack(ext_id, out_dir: tmp)
56
+ zip_data = File.binread(res.path)
57
+ send_data(zip_data, content_type: "application/zip", filename: "#{res.ext_id}.zip")
57
58
  end
58
59
  rescue Clacky::ExtensionPackager::Error => e
59
60
  error!(e.message, status: 422)
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  api: api/handler.rb
11
10
  panels:
@@ -29,7 +29,7 @@
29
29
  "verify.errors": "{{n}} error(s)",
30
30
  "verify.warnings": "{{n}} warning(s)",
31
31
  "verify.hint": "Hint",
32
- "hint.reload": "After fixing, reload this page extensions hot-reload per request.",
32
+ "hint.reload": "After fixing, reload this page to apply changes.",
33
33
  "publish.status": "Status",
34
34
  "publish.status.draft": "Draft",
35
35
  "publish.status.published": "Published",
@@ -39,9 +39,9 @@
39
39
  "btn.publish": "Publish to marketplace",
40
40
  "btn.publishing": "Publishing…",
41
41
  "btn.pack": "Pack (.zip)",
42
+ "btn.packing": "Packing…",
42
43
  "publish.needLicense": "Publishing requires an activated user license.",
43
44
  "publish.done": "Published {{id}} {{ver}} — {{status}}",
44
- "publish.packed": "Packed → {{path}}",
45
45
  "publish.already": "Already published. Enable \"publish a new version\" to ship.",
46
46
  "published.title": "Your published extensions",
47
47
  "published.empty": "You haven't published anything yet.",
@@ -98,6 +98,7 @@
98
98
  "extlist.btn.publish": "Publish",
99
99
  "extlist.btn.update": "Update",
100
100
  "extlist.btn.pack": "Pack",
101
+ "extlist.btn.packing": "Packing…",
101
102
  "extlist.btn.unpublish": "Unpublish",
102
103
  "extlist.btn.iterate": "Iterate",
103
104
  "extlist.iterate.seed": "Iterate on extension {{id}}",
@@ -105,7 +106,6 @@
105
106
  "extlist.overwrite.confirm": "\"{{id}}\" is already published. Publish a new version?",
106
107
  "extlist.unpublish.confirm": "Unpublish {{id}} from the marketplace?",
107
108
  "extlist.publishing": "Publishing…",
108
- "extlist.packed": "Packed → {{path}}",
109
109
  "extlist.needLicense": "Publishing requires an activated user license.",
110
110
  "extlist.newExt.label": "Create a new extension",
111
111
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
@@ -159,7 +159,7 @@
159
159
  "verify.errors": "{{n}} 个错误",
160
160
  "verify.warnings": "{{n}} 个警告",
161
161
  "verify.hint": "提示",
162
- "hint.reload": "修复后刷新本页 —— 扩展每次请求热重载。",
162
+ "hint.reload": "修复问题后,刷新页面即可生效。",
163
163
  "publish.status": "状态",
164
164
  "publish.status.draft": "草稿",
165
165
  "publish.status.published": "已发布",
@@ -169,9 +169,9 @@
169
169
  "btn.publish": "发布到市场",
170
170
  "btn.publishing": "发布中…",
171
171
  "btn.pack": "打包 (.zip)",
172
+ "btn.packing": "打包中…",
172
173
  "publish.needLicense": "发布需要已激活的用户授权。",
173
174
  "publish.done": "已发布 {{id}} {{ver}} — {{status}}",
174
- "publish.packed": "已打包 → {{path}}",
175
175
  "publish.already": "已发布过。勾选「发布新版本」后再试。",
176
176
  "published.title": "你发布的扩展",
177
177
  "published.empty": "你还没有发布任何扩展。",
@@ -228,6 +228,7 @@
228
228
  "extlist.btn.publish": "发布",
229
229
  "extlist.btn.update": "更新",
230
230
  "extlist.btn.pack": "打包",
231
+ "extlist.btn.packing": "打包中…",
231
232
  "extlist.btn.unpublish": "下架",
232
233
  "extlist.btn.iterate": "迭代",
233
234
  "extlist.iterate.seed": "迭代扩展 {{id}}",
@@ -235,7 +236,6 @@
235
236
  "extlist.overwrite.confirm": "「{{id}}」已经发布过了。要发布新版本吗?",
236
237
  "extlist.unpublish.confirm": "确定要从市场下架 {{id}} 吗?",
237
238
  "extlist.publishing": "发布中…",
238
- "extlist.packed": "已打包 → {{path}}",
239
239
  "extlist.needLicense": "发布需要已激活的用户授权。",
240
240
  "extlist.newExt.label": "新建扩展",
241
241
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
@@ -302,6 +302,28 @@
302
302
  return data;
303
303
  }
304
304
 
305
+ async function downloadPack(ext_id) {
306
+ const res = await fetch(api("/pack"), {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ ext_id }),
310
+ });
311
+ if (!res.ok) {
312
+ let msg = `Request failed (${res.status})`;
313
+ try { msg = (await res.json()).error || msg; } catch (_e) {}
314
+ throw new Error(msg);
315
+ }
316
+ const blob = await res.blob();
317
+ const url = URL.createObjectURL(blob);
318
+ const a = document.createElement("a");
319
+ a.href = url;
320
+ a.download = `${ext_id}.zip`;
321
+ document.body.appendChild(a);
322
+ a.click();
323
+ document.body.removeChild(a);
324
+ URL.revokeObjectURL(url);
325
+ }
326
+
305
327
  // Skills tab talks to host-owned endpoints (not the ext prefix). We surface
306
328
  // the HTTP status so the caller can treat 403 as "locked" rather than error.
307
329
  async function getHost(path) {
@@ -452,29 +474,26 @@
452
474
  const version = ext.version;
453
475
  const isUpdate = !!prevVersion;
454
476
 
455
- const intro = el("p", { class: "studio-modal-status", text: t("pub.intro", { name: ext.name }) });
477
+ const intro = el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) });
456
478
 
457
479
  const verText = !version
458
480
  ? t("pub.version.missing")
459
481
  : isUpdate
460
482
  ? t("pub.version.update", { prev: prevVersion, ver: version })
461
483
  : t("pub.version.new", { ver: version });
462
- const verLine = el("p", { class: "studio-modal-code", text: verText });
463
484
 
464
- const unitList = (ext.units || []).map((u) => u.kind).join(" · ");
465
- const unitsLine = unitList
466
- ? el("p", { class: "studio-skill-hint", text: t("pub.units", { units: unitList }) })
467
- : null;
485
+ const metaBlock = el("div", { class: "studio-pub-meta" });
486
+ metaBlock.appendChild(el("span", { class: "studio-pub-version", text: verText }));
468
487
 
469
- const notesLabel = el("label", { class: "studio-label", text: t("pub.notes.label") });
488
+ const notesField = el("div", { class: "studio-field" });
489
+ notesField.appendChild(el("label", { class: "studio-label", text: t("pub.notes.label") }));
470
490
  const notes = el("textarea", { class: "studio-textarea", rows: "3", placeholder: t("pub.notes.placeholder") });
491
+ notesField.appendChild(notes);
471
492
 
472
493
  const status = el("p", { class: "studio-modal-status" });
473
494
  status.style.display = "none";
474
495
 
475
- const bodyChildren = [intro, verLine];
476
- if (unitsLine) bodyChildren.push(unitsLine);
477
- bodyChildren.push(notesLabel, notes, status);
496
+ const bodyChildren = [intro, metaBlock, notesField, status];
478
497
 
479
498
  let done = false;
480
499
  const modal = openModal({
@@ -586,6 +605,7 @@
586
605
  function createDebugPanel() {
587
606
  let container = null;
588
607
  let unsub = null;
608
+ let summaryFadeTimer = null;
589
609
 
590
610
  async function runVerify() {
591
611
  const ext = store.selected();
@@ -597,6 +617,8 @@
597
617
  renderVerify(data);
598
618
  } catch (e) {
599
619
  renderError(e);
620
+ } finally {
621
+ if (status) status.textContent = t("btn.recheck");
600
622
  }
601
623
  }
602
624
 
@@ -607,16 +629,22 @@
607
629
  const errs = (data.issues || []).filter((i) => i.level === "error");
608
630
  const warns = (data.issues || []).filter((i) => i.level === "warning");
609
631
 
610
- const summary = el("div", { class: data.ok ? "studio-verify-ok" : "studio-verify-fail" });
611
- if (data.ok && !warns.length) {
612
- summary.textContent = "✓ " + t("verify.ok");
613
- } else {
614
- const parts = [];
615
- if (errs.length) parts.push(t("verify.errors", { n: errs.length }));
616
- if (warns.length) parts.push(t("verify.warnings", { n: warns.length }));
617
- summary.textContent = parts.join(" · ");
632
+ const summary = container.querySelector(".studio-verify-summary");
633
+ if (summary) {
634
+ clearTimeout(summaryFadeTimer);
635
+ summary.textContent = "";
636
+ if (data.ok && !warns.length) {
637
+ summary.textContent = "✓ " + t("verify.ok");
638
+ summary.className = "studio-verify-summary studio-verify-ok";
639
+ summaryFadeTimer = setTimeout(() => { summary.textContent = ""; }, 3000);
640
+ } else {
641
+ const parts = [];
642
+ if (errs.length) parts.push(t("verify.errors", { n: errs.length }));
643
+ if (warns.length) parts.push(t("verify.warnings", { n: warns.length }));
644
+ summary.textContent = parts.join(" · ");
645
+ summary.className = "studio-verify-summary studio-verify-fail";
646
+ }
618
647
  }
619
- box.appendChild(summary);
620
648
 
621
649
  (data.issues || []).forEach((i) => {
622
650
  const item = el("div", { class: "studio-issue studio-issue-" + i.level });
@@ -630,7 +658,9 @@
630
658
 
631
659
  function renderError(e) {
632
660
  const box = container.querySelector(".studio-verify");
633
- if (box) { box.innerHTML = ""; box.appendChild(el("div", { class: "studio-verify-fail", text: t("err.generic", { msg: e.message }) })); }
661
+ const summary = container.querySelector(".studio-verify-summary");
662
+ if (summary) { summary.textContent = t("err.generic", { msg: e.message }); summary.className = "studio-verify-summary studio-verify-fail"; }
663
+ if (box) { box.innerHTML = ""; }
634
664
  }
635
665
 
636
666
  function renderDetail() {
@@ -647,7 +677,6 @@
647
677
  const row = (label, value) => { meta.appendChild(el("dt", { text: label })); meta.appendChild(el("dd", { text: value || "—" })); };
648
678
  row(t("detail.version"), ext.version);
649
679
  row(t("detail.layer"), ext.layer);
650
- row(t("detail.origin"), ext.origin);
651
680
  detail.appendChild(meta);
652
681
 
653
682
  detail.appendChild(el("div", { class: "studio-label", text: t("detail.units") }));
@@ -674,6 +703,7 @@
674
703
 
675
704
  const bar = el("div", { class: "studio-actions" });
676
705
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-verify-status", text: t("btn.recheck"), onclick: runVerify }));
706
+ bar.appendChild(el("span", { class: "studio-verify-summary" }));
677
707
  container.appendChild(bar);
678
708
 
679
709
  container.appendChild(el("div", { class: "studio-verify" }));
@@ -722,11 +752,15 @@
722
752
  async function doPack() {
723
753
  const ext = store.selected();
724
754
  if (!ext) return;
755
+ const btn = container.querySelector(".studio-pack-btn");
756
+ const orig = btn ? btn.textContent : null;
757
+ if (btn) { btn.disabled = true; btn.textContent = t("btn.packing"); }
725
758
  try {
726
- const data = await postJson("/pack", { ext_id: ext.id });
727
- feedback(t("publish.packed", { path: data.path }), "success");
759
+ await downloadPack(ext.id);
728
760
  } catch (e) {
729
761
  feedback(t("err.generic", { msg: e.message }), "error");
762
+ } finally {
763
+ if (btn) { btn.disabled = false; btn.textContent = orig; }
730
764
  }
731
765
  }
732
766
 
@@ -766,7 +800,7 @@
766
800
 
767
801
  const bar = el("div", { class: "studio-actions" });
768
802
  bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
769
- bar.appendChild(el("button", { class: "studio-btn studio-btn-ghost", text: t("btn.pack"), onclick: doPack }));
803
+ bar.appendChild(el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack }));
770
804
  container.appendChild(bar);
771
805
 
772
806
  container.appendChild(el("div", { class: "studio-feedback" }));
@@ -890,17 +924,21 @@
890
924
  actions.appendChild(pub);
891
925
 
892
926
  actions.appendChild(el("button", { class: "studio-btn", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
893
- actions.appendChild(el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack"), onclick: () => doPack(ext) }));
894
- card.appendChild(actions);
927
+ const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
928
+ packBtn.addEventListener("click", () => doPack(ext, packBtn));
929
+ actions.appendChild(packBtn);
895
930
  return card;
896
931
  }
897
932
 
898
- async function doPack(ext) {
933
+ async function doPack(ext, btn) {
934
+ const orig = btn ? btn.textContent : null;
935
+ if (btn) { btn.disabled = true; btn.textContent = t("extlist.btn.packing"); }
899
936
  try {
900
- const data = await postJson("/pack", { ext_id: ext.id });
901
- alert(t("extlist.packed", { path: data.path }));
937
+ await downloadPack(ext.id);
902
938
  } catch (e) {
903
939
  alert(t("err.generic", { msg: e.message }));
940
+ } finally {
941
+ if (btn) { btn.disabled = false; btn.textContent = orig; }
904
942
  }
905
943
  }
906
944
 
@@ -1210,6 +1248,8 @@
1210
1248
  .studio-page .studio-panel { padding: 0; }
1211
1249
  .studio-field { margin-bottom: 14px; }
1212
1250
  .studio-label { display: block; font-size: 12px; font-weight: 600; color: var(--color-text-tertiary); margin-bottom: 6px; }
1251
+ .studio-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
1252
+ .studio-label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); }
1213
1253
  .studio-select, .studio-textarea { 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 8px; color: var(--color-text-primary); font-size: 13px; font-family: inherit; }
1214
1254
  .studio-select:focus, .studio-textarea:focus { border-color: var(--color-accent-primary); outline: none; }
1215
1255
  .studio-empty { color: var(--color-text-muted); font-size: 12px; margin: 4px 0; }
@@ -1222,7 +1262,7 @@
1222
1262
  .studio-meta dd { margin: 0; color: var(--color-text-primary); }
1223
1263
  .studio-units { display: flex; flex-wrap: wrap; gap: 6px; }
1224
1264
  .studio-unit-chip { display: inline-flex; background: var(--color-bg-hover); color: var(--color-text-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 2px 8px; font-size: 12px; }
1225
- .studio-actions { display: flex; gap: 8px; margin: 12px 0; }
1265
+ .studio-actions { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
1226
1266
  .studio-skill-promo .studio-actions { margin: 12px 0 0; }
1227
1267
  .studio-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; }
1228
1268
  .studio-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
@@ -1231,11 +1271,13 @@
1231
1271
  .studio-btn-primary:disabled { opacity: 0.6; cursor: default; }
1232
1272
  .studio-btn-danger { color: var(--color-error); border-color: var(--color-error-border); padding: 4px 10px; }
1233
1273
  .studio-btn-danger:hover { background: var(--color-error-bg); color: var(--color-error); }
1234
- .studio-btn-ghost { border-color: transparent; color: var(--color-text-tertiary); font-weight: 400; }
1235
- .studio-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-secondary); }
1274
+ .studio-btn-ghost { color: var(--color-text-secondary); font-weight: 500; }
1275
+ .studio-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
1236
1276
  .studio-verify { margin-top: 8px; }
1237
- .studio-verify-ok { color: var(--color-success); font-size: 12px; font-weight: 600; margin-bottom: 8px; }
1238
- .studio-verify-fail { color: var(--color-error); font-size: 12px; font-weight: 600; margin-bottom: 8px; }
1277
+ .studio-verify-summary { font-size: 13px; font-weight: 600; }
1278
+ .studio-verify-ok { color: var(--color-success); font-size: 12px; font-weight: 600; }
1279
+ .studio-verify-fail { color: var(--color-error); font-size: 12px; font-weight: 600; }
1280
+ .studio-verify > *:first-child { margin-bottom: 8px; }
1239
1281
  .studio-issue { border-left: 3px solid var(--color-border-primary); padding: 6px 10px; margin-bottom: 8px; background: var(--color-bg-secondary); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
1240
1282
  .studio-issue-error { border-left-color: var(--color-error); }
1241
1283
  .studio-issue-warning { border-left-color: var(--color-warning, var(--color-text-tertiary)); }
@@ -1244,7 +1286,7 @@
1244
1286
  .studio-issue-file { font-size: 11px; font-family: monospace; color: var(--color-text-muted); margin-top: 2px; }
1245
1287
  .studio-issue-hint { font-size: 11px; color: var(--color-text-secondary); margin-top: 4px; font-style: italic; }
1246
1288
  .studio-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 12px; cursor: pointer; }
1247
- .studio-feedback { font-size: 12px; margin: 8px 0; min-height: 14px; line-height: 1.4; }
1289
+ .studio-feedback { font-size: 12px; margin: 6px 0 0; line-height: 1.4; }
1248
1290
  .studio-feedback-success { color: var(--color-success); }
1249
1291
  .studio-feedback-error { color: var(--color-error); }
1250
1292
  .studio-feedback-warn { color: var(--color-warning, var(--color-text-secondary)); }
@@ -1275,14 +1317,21 @@
1275
1317
  .studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); margin-bottom: 8px; }
1276
1318
  .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); }
1277
1319
  .studio-skill-promo-text { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
1278
- .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; }
1279
- .studio-modal { background: var(--color-bg-primary, var(--color-bg-secondary)); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 20px 22px; width: 420px; max-width: calc(100vw - 40px); box-shadow: 0 12px 40px rgba(0,0,0,0.35); }
1280
- .studio-modal-title { margin: 0 0 10px; font-size: 16px; font-weight: 600; color: var(--color-text-primary); }
1320
+ .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; }
1321
+ .studio-modal { background: var(--color-bg-primary, var(--color-bg-secondary)); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 28px 28px 22px; width: 520px; max-width: calc(100vw - 40px); box-shadow: 0 12px 40px rgba(0,0,0,0.35); animation: studio-modal-in 0.2s cubic-bezier(0.34,1.4,0.64,1); }
1322
+ @keyframes studio-overlay-in { from { opacity: 0; } to { opacity: 1; } }
1323
+ @keyframes studio-modal-in { from { opacity: 0; transform: scale(0.93) translateY(6px); } to { opacity: 1; transform: scale(1) translateY(0); } }
1324
+ .studio-modal-title { margin: 0 0 16px; font-size: 16px; font-weight: 600; color: var(--color-text-primary); }
1325
+ .studio-modal-intro { margin: 0 0 16px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1326
+ .studio-pub-meta { display: flex; align-items: center; gap: 10px; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm, 6px); padding: 10px 14px; margin-bottom: 16px; }
1327
+ .studio-pub-version { font-size: 13px; font-weight: 600; color: var(--color-text-primary); font-family: monospace; }
1328
+ .studio-pub-units { font-size: 12px; color: var(--color-text-tertiary); }
1329
+ .studio-pub-units::before { content: "·"; margin-right: 10px; }
1281
1330
  .studio-modal-body { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1282
1331
  .studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
1283
1332
  .studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
1284
1333
  .studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
1285
- .studio-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
1334
+ .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); }
1286
1335
  `;
1287
1336
  document.head.appendChild(style);
1288
1337
  })();
@@ -11,8 +11,7 @@ it's live.
11
11
  ## Before publishing
12
12
 
13
13
  - The extension must live in the **local** layer (`~/.clacky/ext/local/<id>/`). Only
14
- local containers can be packed. Marketplace-origin and encrypted (`SKILL.md.enc`)
15
- containers are rejected.
14
+ local containers can be packed. Encrypted (`SKILL.md.enc`) containers are rejected.
16
15
  - Publishing requires an **activated user license** — it proves creator identity, and
17
16
  the platform attributes the extension to that account. If activation is missing, tell
18
17
  the user to activate first; don't try to work around it.
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  agents:
11
10
  - id: general
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  panels:
11
10
  - id: git
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  api: api/handler.rb
11
10
  panels:
@@ -5,7 +5,6 @@ version: "0.1.0"
5
5
  author: OpenClacky
6
6
  homepage: https://www.openclacky.com
7
7
  license: MIT
8
- origin: self
9
8
  contributes:
10
9
  panels:
11
10
  - id: time_machine
@@ -30,13 +30,14 @@ module Clacky
30
30
  Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
31
31
 
32
32
  class Halt < StandardError
33
- attr_reader :status, :payload, :content_type
33
+ attr_reader :status, :payload, :content_type, :extra_headers
34
34
 
35
- def initialize(status, payload, content_type)
35
+ def initialize(status, payload, content_type, extra_headers: {})
36
36
  super("api_ext halt #{status}")
37
37
  @status = status
38
38
  @payload = payload
39
39
  @content_type = content_type
40
+ @extra_headers = extra_headers
40
41
  end
41
42
  end
42
43
 
@@ -201,6 +202,14 @@ module Clacky
201
202
  raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
202
203
  end
203
204
 
205
+ def send_data(bytes, content_type:, filename: nil, status: 200)
206
+ disposition = filename ? "attachment; filename=\"#{filename}\"" : "attachment"
207
+ raise Halt.new(status, bytes, content_type, extra_headers: {
208
+ "Content-Disposition" => disposition,
209
+ "Content-Length" => bytes.bytesize.to_s
210
+ })
211
+ end
212
+
204
213
  def error!(message, status: 400, **extra)
205
214
  payload = { error: message.to_s }
206
215
  payload.merge!(extra) unless extra.empty?
@@ -97,7 +97,7 @@ module Clacky
97
97
  # Handler exited without writing — empty 204
98
98
  empty_response(res)
99
99
  rescue Clacky::ApiExtension::Halt => halt
100
- write_response(res, halt.status, halt.payload, halt.content_type)
100
+ write_response(res, halt.status, halt.payload, halt.content_type, halt.extra_headers)
101
101
  rescue Timeout::Error
102
102
  Clacky::Logger.warn("[api_ext:#{klass.ext_id}] Timed out after #{timeout_sec}s on #{route.method.upcase} #{route.pattern}")
103
103
  write_json(res, 503, error: "request timed out")
@@ -106,10 +106,11 @@ module Clacky
106
106
  write_json(res, 500, error: e.message)
107
107
  end
108
108
 
109
- private def write_response(res, status, body, content_type)
109
+ private def write_response(res, status, body, content_type, extra_headers = {})
110
110
  res.status = status
111
111
  res.content_type = content_type
112
112
  res["Access-Control-Allow-Origin"] = "*"
113
+ extra_headers.each { |k, v| res[k] = v }
113
114
  res.body = body
114
115
  end
115
116
 
@@ -98,12 +98,6 @@ module Clacky
98
98
  # carrying encrypted skills is produced by the platform pipeline; refuse
99
99
  # to re-pack it so we never smuggle marketplace artifacts out of band.
100
100
  private def refuse_protected!(slug, container_dir)
101
- manifest = YAML.safe_load(File.read(File.join(container_dir, MANIFEST)), permitted_classes: [Symbol]) || {}
102
- origin = (manifest["origin"] || "self").to_s
103
- if origin == "marketplace"
104
- raise Error, "refusing to pack #{slug}: origin=marketplace is produced by the platform, not hand-packed"
105
- end
106
-
107
101
  enc = Dir.glob(File.join(container_dir, "**", "SKILL.md.enc"))
108
102
  return if enc.empty?
109
103
 
@@ -540,6 +540,7 @@ module Clacky
540
540
  when ["GET", "/api/store/skills"] then api_store_skills(res)
541
541
  when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
542
542
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
543
+ when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
543
544
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
544
545
  when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
545
546
  when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
@@ -2419,6 +2420,25 @@ module Clacky
2419
2420
  end
2420
2421
 
2421
2422
  # DELETE /api/store/extension body: { id: <slug> }
2423
+ def api_store_extension_install(req, res)
2424
+ body = parse_json_body(req)
2425
+ download_url = body["download_url"].to_s.strip
2426
+ name = body["name"].to_s.strip
2427
+
2428
+ if download_url.empty?
2429
+ json_response(res, 400, { ok: false, error: "Missing download_url." })
2430
+ return
2431
+ end
2432
+
2433
+ Clacky::ExtensionPackager.install(download_url, force: true)
2434
+ Clacky::ExtensionLoader.invalidate_cache!
2435
+ json_response(res, 200, { ok: true, name: name })
2436
+ rescue Clacky::ExtensionPackager::Error => e
2437
+ json_response(res, 422, { ok: false, error: e.message })
2438
+ rescue StandardError => e
2439
+ json_response(res, 500, { ok: false, error: e.message })
2440
+ end
2441
+
2422
2442
  def api_store_extension_uninstall(req, res)
2423
2443
  id = parse_json_body(req)["id"].to_s
2424
2444
  container = extension_container(id)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.8"
4
+ VERSION = "1.3.9"
5
5
  end
@@ -2096,7 +2096,7 @@ body {
2096
2096
  flex-direction: column;
2097
2097
  gap: 0.5rem;
2098
2098
  align-items: flex-start;
2099
- margin-top: -0.75rem;
2099
+ margin-top: -0.5rem;
2100
2100
  }
2101
2101
  .new-session-advanced-toggle {
2102
2102
  display: inline-flex;
@@ -7079,13 +7079,45 @@ body {
7079
7079
  gap: 1.25rem;
7080
7080
  }
7081
7081
  .extensions-toolbar {
7082
+ display: flex;
7083
+ align-items: center;
7084
+ justify-content: space-between;
7085
+ gap: 0.75rem;
7086
+ margin-bottom: 0.25rem;
7087
+ }
7088
+ .extensions-toolbar-right {
7082
7089
  display: flex;
7083
7090
  align-items: center;
7084
7091
  gap: 0.5rem;
7085
7092
  }
7093
+ .extensions-filter-tabs {
7094
+ display: flex;
7095
+ align-items: center;
7096
+ gap: 0;
7097
+ flex-shrink: 0;
7098
+ }
7099
+ .extensions-filter-tab {
7100
+ font-size: 0.875rem;
7101
+ font-weight: 500;
7102
+ padding: 0.25rem 0.875rem 0.5rem;
7103
+ border: none;
7104
+ background: transparent;
7105
+ color: var(--color-text-tertiary);
7106
+ cursor: pointer;
7107
+ white-space: nowrap;
7108
+ border-bottom: 2px solid transparent;
7109
+ transition: color .15s, border-color .15s;
7110
+ }
7111
+ .extensions-filter-tab:hover {
7112
+ color: var(--color-text-primary);
7113
+ }
7114
+ .extensions-filter-tab-active {
7115
+ color: var(--color-text-primary);
7116
+ border-bottom-color: var(--color-accent, #6366f1);
7117
+ }
7086
7118
  .extensions-search {
7087
7119
  flex: 1;
7088
- min-width: 0;
7120
+ min-width: 15rem;
7089
7121
  font-size: 0.8125rem;
7090
7122
  padding: 0.4375rem 0.75rem;
7091
7123
  border-radius: 6px;
@@ -7163,7 +7195,7 @@ body {
7163
7195
  color: var(--color-text-secondary);
7164
7196
  background: var(--color-bg-tertiary, rgba(255,255,255,0.05));
7165
7197
  border-radius: 4px;
7166
- padding: 0.0625rem 0.375rem;
7198
+ padding: 0.0625rem 0rem;
7167
7199
  }
7168
7200
  .extension-units {
7169
7201
  font-size: 0.6875rem;
@@ -7182,6 +7214,14 @@ body {
7182
7214
  line-height: 1.5;
7183
7215
  margin-bottom: 0.375rem;
7184
7216
  }
7217
+ .extension-desc-detail {
7218
+ font-size: 0.8125rem;
7219
+ margin-bottom: 0.5rem;
7220
+ }
7221
+ .extension-detail-author {
7222
+ font-size: 0.6875rem;
7223
+ color: var(--color-text-muted);
7224
+ }
7185
7225
  .extension-meta {
7186
7226
  display: flex;
7187
7227
  align-items: center;
@@ -7224,19 +7264,21 @@ body {
7224
7264
  .extension-detail-hero {
7225
7265
  display: flex;
7226
7266
  align-items: flex-start;
7227
- gap: 0.875rem;
7228
- padding-bottom: 1rem;
7267
+ gap: 1rem;
7268
+ padding-bottom: 1.25rem;
7229
7269
  border-bottom: 1px solid var(--color-border-primary);
7230
7270
  }
7231
7271
  .extension-detail-heading { flex: 1; min-width: 0; }
7232
- .extension-emoji-lg { font-size: 2.25rem; }
7233
- .extension-name-lg { font-size: 1.0625rem; }
7234
- .extension-detail-block { margin-top: 1.25rem; }
7272
+ .extension-emoji-lg { font-size: 2.5rem; line-height: 1; margin-top: 0.125rem; }
7273
+ .extension-name-lg { font-size: 1.125rem; font-weight: 700; }
7274
+ .extension-detail-block { margin-top: 1.5rem; }
7235
7275
  .extension-detail-block-title {
7236
- font-size: 0.8125rem;
7276
+ font-size: 0.75rem;
7237
7277
  font-weight: 600;
7238
- color: var(--color-text-primary);
7239
- margin: 0 0 0.625rem;
7278
+ text-transform: uppercase;
7279
+ letter-spacing: 0.04em;
7280
+ color: var(--color-text-muted);
7281
+ margin: 0 0 0.75rem;
7240
7282
  }
7241
7283
  .extension-detail-section { margin-bottom: 0.875rem; }
7242
7284
  .extension-detail-section-title {
@@ -7248,18 +7290,30 @@ body {
7248
7290
  margin: 0 0 0.375rem;
7249
7291
  }
7250
7292
  .extension-contrib-list,
7251
- .extension-version-list { list-style: none; margin: 0; padding: 0; }
7252
- .extension-contrib-item {
7253
- padding: 0.5rem 0.75rem;
7254
- background: var(--color-bg-secondary);
7293
+ .extension-version-list {
7294
+ list-style: none;
7295
+ margin: 0;
7296
+ padding: 0;
7255
7297
  border: 1px solid var(--color-border-primary);
7256
- border-radius: 6px;
7257
- margin-bottom: 0.375rem;
7298
+ border-radius: 8px;
7299
+ overflow: hidden;
7300
+ background: var(--color-bg-secondary);
7301
+ }
7302
+ .extension-contrib-item {
7303
+ padding: 0.625rem 0.875rem;
7304
+ border-bottom: 1px solid var(--color-border-primary);
7305
+ background: transparent;
7306
+ border-radius: 0;
7307
+ border-left: none;
7308
+ border-right: none;
7309
+ border-top: none;
7310
+ margin-bottom: 0;
7258
7311
  }
7312
+ .extension-contrib-item:last-child { border-bottom: none; }
7259
7313
  .extension-contrib-title {
7260
7314
  display: block;
7261
7315
  font-size: 0.8125rem;
7262
- font-weight: 600;
7316
+ font-weight: 500;
7263
7317
  color: var(--color-text-primary);
7264
7318
  }
7265
7319
  .extension-contrib-desc {
@@ -7270,7 +7324,7 @@ body {
7270
7324
  margin-top: 0.125rem;
7271
7325
  }
7272
7326
  .extension-version-item {
7273
- padding: 0.5rem 0.75rem;
7327
+ padding: 0.625rem 0.875rem;
7274
7328
  border-bottom: 1px solid var(--color-border-primary);
7275
7329
  }
7276
7330
  .extension-version-item:last-child { border-bottom: none; }
@@ -7306,20 +7360,43 @@ body {
7306
7360
  padding: 0.0625rem 0.375rem;
7307
7361
  }
7308
7362
  .extension-action {
7363
+ display: inline-flex;
7364
+ align-items: center;
7309
7365
  font-size: 0.75rem;
7310
- padding: 0.3125rem 0.75rem;
7311
- border-radius: 6px;
7312
- border: 1px solid var(--color-border);
7313
- background: var(--color-surface);
7314
- color: var(--color-text);
7366
+ padding: 0.25rem 0.625rem;
7367
+ border-radius: 5px;
7368
+ border: 1px solid var(--color-border-primary);
7369
+ background: var(--color-bg-primary);
7370
+ color: var(--color-text-secondary);
7315
7371
  cursor: pointer;
7372
+ transition: background .15s, border-color .15s, opacity .15s;
7373
+ white-space: nowrap;
7374
+ }
7375
+ .extension-action:hover { background: var(--color-bg-hover); }
7376
+ .extension-action-install {
7377
+ background: var(--color-button-primary);
7378
+ color: var(--color-button-primary-text);
7379
+ border-color: transparent;
7380
+ }
7381
+ .extension-action-install:hover {
7382
+ background: var(--color-button-primary-hover);
7383
+ }
7384
+ .extension-action-install:disabled {
7385
+ opacity: 0.5;
7386
+ cursor: not-allowed;
7387
+ }
7388
+ .extension-action-disable,
7389
+ .extension-action-enable {
7390
+ color: var(--color-text-tertiary);
7316
7391
  }
7317
- .extension-action:hover { background: var(--color-surface-hover, rgba(0,0,0,0.04)); }
7318
7392
  .extension-action-remove {
7319
- color: var(--color-danger, #dc2626);
7320
- border-color: var(--color-danger-border, rgba(220,38,38,0.4));
7393
+ color: var(--color-error);
7394
+ border-color: var(--color-error-border);
7395
+ }
7396
+ .extension-action-remove:hover {
7397
+ background: var(--color-error-bg);
7398
+ border-color: var(--color-error);
7321
7399
  }
7322
- .extension-action-remove:hover { background: var(--color-danger-bg, rgba(220,38,38,0.08)); }
7323
7400
 
7324
7401
  /* Brand Skill card */
7325
7402
  .brand-skill-card {
@@ -19,9 +19,11 @@
19
19
 
20
20
  const ExtensionsStore = (() => {
21
21
  // ── State (single source of truth) ─────────────────────────────────────
22
- let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
- let _query = ""; // current search text
24
- let _sort = "newest"; // "newest" | "updated" | "downloads"
22
+ let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
+ let _allExtensions = []; // unfiltered result from server
24
+ let _query = ""; // current search text
25
+ let _sort = "newest"; // "newest" | "updated" | "downloads"
26
+ let _filterInstalled = false; // when true, show only installed extensions
25
27
  let _loading = false;
26
28
  let _error = null; // soft warning when the store is unreachable
27
29
  let _detail = null; // currently opened extension detail, or null
@@ -50,6 +52,7 @@ const ExtensionsStore = (() => {
50
52
  get extensions() { return _extensions; },
51
53
  get query() { return _query; },
52
54
  get sort() { return _sort; },
55
+ get filterInstalled() { return _filterInstalled; },
53
56
  get loading() { return _loading; },
54
57
  get error() { return _error; },
55
58
  get detail() { return _detail; },
@@ -73,7 +76,8 @@ const ExtensionsStore = (() => {
73
76
  const qs = params.toString();
74
77
  const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
75
78
  const data = await res.json();
76
- _extensions = data.extensions || [];
79
+ _allExtensions = data.extensions || [];
80
+ _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
77
81
  _error = data.warning || null;
78
82
  _loading = false;
79
83
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
@@ -100,6 +104,13 @@ const ExtensionsStore = (() => {
100
104
  return Extensions.load();
101
105
  },
102
106
 
107
+ /** Toggle the "installed only" filter (client-side, no new network request). */
108
+ setFilterInstalled(onlyInstalled) {
109
+ _filterInstalled = !!onlyInstalled;
110
+ _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
111
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
112
+ },
113
+
103
114
  /** Open the detail view for one extension (fetches contributes + versions). */
104
115
  async loadDetail(id) {
105
116
  if (!id) return;
@@ -156,6 +167,31 @@ const ExtensionsStore = (() => {
156
167
  }
157
168
  },
158
169
 
170
+ /** Install a marketplace extension by fetching its download_url then posting to the local server. */
171
+ async install(id) {
172
+ if (!id) return;
173
+ try {
174
+ const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
175
+ const detailData = await detailRes.json();
176
+ if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
177
+ const ext = detailData.extension;
178
+ const download_url = ext.download_url;
179
+ if (!download_url) throw new Error("No download URL available");
180
+ const res = await fetch("/api/store/extension/install", {
181
+ method: "POST",
182
+ headers: { "Content-Type": "application/json" },
183
+ body: JSON.stringify({ download_url, name: ext.name }),
184
+ });
185
+ const data = await res.json();
186
+ if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
187
+ await Extensions.loadDetail(id);
188
+ } catch (e) {
189
+ console.error("[Extensions] install failed", e);
190
+ _detailError = e.message;
191
+ _emit("extensions:detail");
192
+ }
193
+ },
194
+
159
195
  /** Remove an installed extension, then return to the list. */
160
196
  async uninstall(id) {
161
197
  if (!id) return;
@@ -167,8 +203,7 @@ const ExtensionsStore = (() => {
167
203
  });
168
204
  const data = await res.json();
169
205
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
170
- Extensions.closeDetail();
171
- Extensions.load();
206
+ await Extensions.loadDetail(id);
172
207
  } catch (e) {
173
208
  console.error("[Extensions] uninstall failed", e);
174
209
  _detailError = e.message;
@@ -189,6 +189,16 @@ const ExtensionsView = (() => {
189
189
  if (ok) Extensions.uninstall(id);
190
190
  });
191
191
  }
192
+
193
+ const installBtn = document.querySelector("[data-ext-install]");
194
+ if (installBtn) {
195
+ installBtn.addEventListener("click", () => {
196
+ const id = installBtn.getAttribute("data-ext-install");
197
+ installBtn.disabled = true;
198
+ installBtn.textContent = I18n.t("extensions.action.installing");
199
+ Extensions.install(id);
200
+ });
201
+ }
192
202
  }
193
203
 
194
204
  function _backToList() {
@@ -215,6 +225,8 @@ const ExtensionsView = (() => {
215
225
  const homepageHtml = ext.homepage
216
226
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
217
227
  : "";
228
+ const authorHtml = ext.author
229
+ ? `<span class="extension-detail-author">${escapeHtml(ext.author)}</span>` : "";
218
230
 
219
231
  return `
220
232
  <div class="extension-detail-hero">
@@ -226,10 +238,8 @@ const ExtensionsView = (() => {
226
238
  ${installedHtml}
227
239
  ${unitsHtml}
228
240
  </div>
229
- <div class="extension-desc">${escapeHtml(description)}</div>
230
- <div class="extension-meta">
231
- ${homepageHtml}
232
- </div>
241
+ ${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
242
+ ${(authorHtml || homepageHtml) ? `<div class="extension-meta">${authorHtml}${homepageHtml}</div>` : ""}
233
243
  ${_renderActions(ext)}
234
244
  </div>
235
245
  </div>
@@ -240,8 +250,17 @@ const ExtensionsView = (() => {
240
250
  // Manage buttons for a locally installed extension: enable/disable toggle
241
251
  // (always available when installed) plus remove (installed layer only).
242
252
  function _renderActions(ext) {
243
- if (!ext.installed) return "";
244
- const slug = ext.name || ext.slug || (ext.id != null ? String(ext.id) : "");
253
+ const id = ext.id != null ? String(ext.id) : (ext.name || ext.slug || "");
254
+ if (!ext.installed) {
255
+ if (ext.origin === "marketplace" && ext.download_url) {
256
+ return `
257
+ <div class="extension-detail-actions">
258
+ <button type="button" class="extension-action extension-action-install" data-ext-install="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.install"))}</button>
259
+ </div>`;
260
+ }
261
+ return "";
262
+ }
263
+ const slug = ext.name || ext.slug || id;
245
264
  const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
246
265
  const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
247
266
  const disabledBadge = ext.disabled
@@ -274,10 +293,13 @@ const ExtensionsView = (() => {
274
293
  const singular = type.replace(/s$/, "");
275
294
  const heading = _sectionHeading(type);
276
295
  const rows = items.map((it) => {
277
- const title = (currentLang === "zh" && it.title_zh) ? it.title_zh
278
- : (it.title || it.name || it.id || singular);
279
- const desc = (currentLang === "zh" && it.description_zh) ? it.description_zh
280
- : (it.description || "");
296
+ const isStr = typeof it === "string";
297
+ const title = isStr ? it
298
+ : ((currentLang === "zh" && it.title_zh) ? it.title_zh
299
+ : (it.title || it.name || it.id || singular));
300
+ const desc = isStr ? ""
301
+ : ((currentLang === "zh" && it.description_zh) ? it.description_zh
302
+ : (it.description || ""));
281
303
  return `
282
304
  <li class="extension-contrib-item">
283
305
  <span class="extension-contrib-title">${escapeHtml(String(title))}</span>
@@ -340,6 +362,14 @@ const ExtensionsView = (() => {
340
362
  sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
341
363
  }
342
364
 
365
+ document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
366
+ btn.addEventListener("click", () => {
367
+ document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
368
+ btn.classList.add("extensions-filter-tab-active");
369
+ Extensions.setFilterInstalled(btn.dataset.filter === "installed");
370
+ });
371
+ });
372
+
343
373
  const list = $("extensions-list");
344
374
  if (list) {
345
375
  list.addEventListener("click", (e) => {
@@ -513,6 +513,8 @@ const I18n = (() => {
513
513
  "extensions.sort.newest": "Newest",
514
514
  "extensions.sort.updated": "Recently updated",
515
515
  "extensions.sort.downloads": "Most downloaded",
516
+ "extensions.filter.all": "All",
517
+ "extensions.filter.installed": "Installed",
516
518
  "extensions.empty": "No extensions available yet.",
517
519
  "extensions.noResults": "No extensions match your search.",
518
520
  "extensions.loadFailed": "Could not reach the extension store.",
@@ -534,10 +536,12 @@ const I18n = (() => {
534
536
  "extensions.section.panels": "Panels",
535
537
  "extensions.section.api": "APIs",
536
538
  "extensions.disabled": "Disabled",
539
+ "extensions.action.install": "Install",
540
+ "extensions.action.installing": "Installing…",
537
541
  "extensions.action.disable": "Disable",
538
542
  "extensions.action.enable": "Enable",
539
543
  "extensions.action.remove": "Remove",
540
- "extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it from your license package later.",
544
+ "extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it later.",
541
545
  "mcp.loading": "Loading…",
542
546
  "mcp.empty.title": "No MCP servers configured",
543
547
  "mcp.empty.body": "Create ~/.clacky/mcp.json to plug in MCP servers. The format matches Claude Desktop and Cursor, so existing configs work as-is.",
@@ -1470,6 +1474,8 @@ const I18n = (() => {
1470
1474
  "extensions.sort.newest": "最新",
1471
1475
  "extensions.sort.updated": "最近更新",
1472
1476
  "extensions.sort.downloads": "下载最多",
1477
+ "extensions.filter.all": "全部",
1478
+ "extensions.filter.installed": "已安装",
1473
1479
  "extensions.empty": "暂无可用扩展。",
1474
1480
  "extensions.noResults": "没有匹配的扩展。",
1475
1481
  "extensions.loadFailed": "无法连接扩展市场。",
@@ -1491,10 +1497,12 @@ const I18n = (() => {
1491
1497
  "extensions.section.panels": "面板",
1492
1498
  "extensions.section.api": "API",
1493
1499
  "extensions.disabled": "已禁用",
1500
+ "extensions.action.install": "安装",
1501
+ "extensions.action.installing": "安装中…",
1494
1502
  "extensions.action.disable": "禁用",
1495
1503
  "extensions.action.enable": "启用",
1496
1504
  "extensions.action.remove": "移除",
1497
- "extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可从授权包重新安装。",
1505
+ "extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可重新安装。",
1498
1506
  "mcp.loading": "加载中…",
1499
1507
  "mcp.empty.title": "尚未配置 MCP 服务",
1500
1508
  "mcp.empty.body": "新建 ~/.clacky/mcp.json 即可接入 MCP 服务。格式与 Claude Desktop、Cursor 一致,现有配置可直接复用。",
@@ -662,12 +662,18 @@
662
662
  </div>
663
663
 
664
664
  <div class="extensions-toolbar">
665
- <select id="extensions-sort" class="extensions-sort">
666
- <option value="newest" data-i18n="extensions.sort.newest">Newest</option>
667
- <option value="updated" data-i18n="extensions.sort.updated">Recently updated</option>
668
- <option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
669
- </select>
670
- <input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
665
+ <div class="extensions-filter-tabs">
666
+ <button type="button" class="extensions-filter-tab extensions-filter-tab-active" data-filter="all" data-i18n="extensions.filter.all">All</button>
667
+ <button type="button" class="extensions-filter-tab" data-filter="installed" data-i18n="extensions.filter.installed">Installed</button>
668
+ </div>
669
+ <div class="extensions-toolbar-right">
670
+ <input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
671
+ <select id="extensions-sort" class="extensions-sort">
672
+ <option value="newest" data-i18n="extensions.sort.newest">Newest</option>
673
+ <option value="updated" data-i18n="extensions.sort.updated">Recently updated</option>
674
+ <option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
675
+ </select>
676
+ </div>
671
677
  </div>
672
678
 
673
679
  <div id="extensions-warning" class="brand-skills-warning" style="display:none"></div>
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.8
4
+ version: 1.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy