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 +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/clacky/brand_config.rb +2 -1
- data/lib/clacky/default_extensions/coding/ext.yml +0 -1
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +0 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -3
- data/lib/clacky/default_extensions/ext-studio/ext.yml +0 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +94 -45
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +1 -2
- data/lib/clacky/default_extensions/general/ext.yml +0 -1
- data/lib/clacky/default_extensions/git/ext.yml +0 -1
- data/lib/clacky/default_extensions/meeting/ext.yml +0 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +0 -1
- data/lib/clacky/extension/api_extension.rb +11 -2
- data/lib/clacky/extension/dispatcher.rb +3 -2
- data/lib/clacky/extension/packager.rb +0 -6
- data/lib/clacky/server/http_server.rb +20 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +105 -28
- data/lib/clacky/web/features/extensions/store.js +41 -6
- data/lib/clacky/web/features/extensions/view.js +40 -10
- data/lib/clacky/web/i18n.js +10 -2
- data/lib/clacky/web/index.html +12 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7208295135923baf898665ce973cbd07ec3e218437cf896bf3a6a2644b2d026
|
|
4
|
+
data.tar.gz: ced24c041431be4afde0a6237afe250ca0876091c1b5f0ee3bf28eb4d166c152
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
56
|
-
|
|
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)
|
|
@@ -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
|
|
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-
|
|
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
|
|
465
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
611
|
-
if (
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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
|
-
|
|
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 {
|
|
1235
|
-
.studio-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-
|
|
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-
|
|
1238
|
-
.studio-verify-
|
|
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:
|
|
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:
|
|
1280
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
@@ -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)
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
|
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:
|
|
7228
|
-
padding-bottom:
|
|
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.
|
|
7233
|
-
.extension-name-lg { font-size: 1.
|
|
7234
|
-
.extension-detail-block { margin-top: 1.
|
|
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.
|
|
7276
|
+
font-size: 0.75rem;
|
|
7237
7277
|
font-weight: 600;
|
|
7238
|
-
|
|
7239
|
-
|
|
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 {
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
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:
|
|
7257
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
7311
|
-
border-radius:
|
|
7312
|
-
border: 1px solid var(--color-border);
|
|
7313
|
-
background: var(--color-
|
|
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-
|
|
7320
|
-
border-color: var(--color-
|
|
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
|
|
23
|
-
let
|
|
24
|
-
let
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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) => {
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -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
|
|
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 一致,现有配置可直接复用。",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -662,12 +662,18 @@
|
|
|
662
662
|
</div>
|
|
663
663
|
|
|
664
664
|
<div class="extensions-toolbar">
|
|
665
|
-
<
|
|
666
|
-
<
|
|
667
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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>
|