openclacky 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,10 +76,15 @@ 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 || [];
77
80
  _error = data.warning || null;
78
81
  _loading = false;
79
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
82
+ if (_filterInstalled) {
83
+ await Extensions.setFilterInstalled(true);
84
+ } else {
85
+ _extensions = _allExtensions;
86
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
87
+ }
80
88
  } catch (e) {
81
89
  console.error("[Extensions] load failed", e);
82
90
  _extensions = [];
@@ -100,6 +108,28 @@ const ExtensionsStore = (() => {
100
108
  return Extensions.load();
101
109
  },
102
110
 
111
+ /** Toggle the "installed only" filter — fetches from local store when enabled. */
112
+ async setFilterInstalled(onlyInstalled) {
113
+ _filterInstalled = !!onlyInstalled;
114
+ if (_filterInstalled) {
115
+ _loading = true;
116
+ _emit("extensions:loading");
117
+ try {
118
+ const res = await fetch("/api/store/extensions/installed");
119
+ const data = await res.json();
120
+ _extensions = data.extensions || [];
121
+ } catch (e) {
122
+ console.error("[Extensions] load installed failed", e);
123
+ _extensions = [];
124
+ } finally {
125
+ _loading = false;
126
+ }
127
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
128
+ } else {
129
+ return Extensions.load();
130
+ }
131
+ },
132
+
103
133
  /** Open the detail view for one extension (fetches contributes + versions). */
104
134
  async loadDetail(id) {
105
135
  if (!id) return;
@@ -147,8 +177,7 @@ const ExtensionsStore = (() => {
147
177
  });
148
178
  const data = await res.json();
149
179
  if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
150
- await Extensions.loadDetail(id);
151
- Extensions.load();
180
+ location.reload();
152
181
  } catch (e) {
153
182
  console.error("[Extensions] setEnabled failed", e);
154
183
  _detailError = e.message;
@@ -156,6 +185,31 @@ const ExtensionsStore = (() => {
156
185
  }
157
186
  },
158
187
 
188
+ /** Install a marketplace extension by fetching its download_url then posting to the local server. */
189
+ async install(id) {
190
+ if (!id) return;
191
+ try {
192
+ const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
193
+ const detailData = await detailRes.json();
194
+ if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
195
+ const ext = detailData.extension;
196
+ const download_url = ext.download_url;
197
+ if (!download_url) throw new Error("No download URL available");
198
+ const res = await fetch("/api/store/extension/install", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ download_url, name: ext.name }),
202
+ });
203
+ const data = await res.json();
204
+ if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
205
+ location.reload();
206
+ } catch (e) {
207
+ console.error("[Extensions] install failed", e);
208
+ _detailError = e.message;
209
+ _emit("extensions:detail");
210
+ }
211
+ },
212
+
159
213
  /** Remove an installed extension, then return to the list. */
160
214
  async uninstall(id) {
161
215
  if (!id) return;
@@ -167,8 +221,7 @@ const ExtensionsStore = (() => {
167
221
  });
168
222
  const data = await res.json();
169
223
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
170
- Extensions.closeDetail();
171
- Extensions.load();
224
+ location.reload();
172
225
  } catch (e) {
173
226
  console.error("[Extensions] uninstall failed", e);
174
227
  _detailError = e.message;
@@ -89,12 +89,16 @@ const ExtensionsView = (() => {
89
89
  ? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
90
90
  const installedHtml = ext.installed
91
91
  ? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
92
+ const unlistedHtml = (ext.unlisted && ext.installed)
93
+ ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
92
94
  const unitsText = _formatUnits(ext.units);
93
95
  const unitsHtml = unitsText
94
96
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
95
97
  const homepageHtml = ext.homepage
96
98
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
97
99
  : "";
100
+ const authorHtml = ext.author
101
+ ? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
98
102
 
99
103
  const card = document.createElement("div");
100
104
  card.className = "extension-card extension-card-clickable";
@@ -105,14 +109,14 @@ const ExtensionsView = (() => {
105
109
  <div class="extension-info">
106
110
  <div class="extension-title">
107
111
  <span class="extension-name">${escapeHtml(name)}</span>
112
+ ${authorHtml}
108
113
  ${versionHtml}
109
114
  ${installedHtml}
115
+ ${unlistedHtml}
110
116
  ${unitsHtml}
111
117
  </div>
112
- <div class="extension-desc">${escapeHtml(description)}</div>
113
- <div class="extension-meta">
114
- ${homepageHtml}
115
- </div>
118
+ ${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
119
+ ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
116
120
  </div>
117
121
  </div>`;
118
122
  return card;
@@ -147,9 +151,8 @@ const ExtensionsView = (() => {
147
151
  }
148
152
 
149
153
  if (st.detailError) {
150
- panel.innerHTML = _detailShell(`
151
- <div class="extensions-empty">${escapeHtml(st.detailError)}</div>`);
152
- _wireDetail();
154
+ console.warn("[Extensions] detail error, navigating back:", st.detailError);
155
+ _backToList();
153
156
  return;
154
157
  }
155
158
 
@@ -189,6 +192,16 @@ const ExtensionsView = (() => {
189
192
  if (ok) Extensions.uninstall(id);
190
193
  });
191
194
  }
195
+
196
+ const installBtn = document.querySelector("[data-ext-install]");
197
+ if (installBtn) {
198
+ installBtn.addEventListener("click", () => {
199
+ const id = installBtn.getAttribute("data-ext-install");
200
+ installBtn.disabled = true;
201
+ installBtn.textContent = I18n.t("extensions.action.installing");
202
+ Extensions.install(id);
203
+ });
204
+ }
192
205
  }
193
206
 
194
207
  function _backToList() {
@@ -209,12 +222,16 @@ const ExtensionsView = (() => {
209
222
  ? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
210
223
  const installedHtml = ext.installed
211
224
  ? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
225
+ const unlistedHtml = ext.unlisted
226
+ ? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
212
227
  const unitsText = _formatUnits(ext.units);
213
228
  const unitsHtml = unitsText
214
229
  ? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
215
230
  const homepageHtml = ext.homepage
216
231
  ? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
217
232
  : "";
233
+ const authorHtml = ext.author
234
+ ? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
218
235
 
219
236
  return `
220
237
  <div class="extension-detail-hero">
@@ -222,14 +239,14 @@ const ExtensionsView = (() => {
222
239
  <div class="extension-detail-heading">
223
240
  <div class="extension-title">
224
241
  <span class="extension-name extension-name-lg">${escapeHtml(name)}</span>
242
+ ${authorHtml}
225
243
  ${versionHtml}
226
244
  ${installedHtml}
245
+ ${unlistedHtml}
227
246
  ${unitsHtml}
228
247
  </div>
229
- <div class="extension-desc">${escapeHtml(description)}</div>
230
- <div class="extension-meta">
231
- ${homepageHtml}
232
- </div>
248
+ ${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
249
+ ${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
233
250
  ${_renderActions(ext)}
234
251
  </div>
235
252
  </div>
@@ -240,8 +257,17 @@ const ExtensionsView = (() => {
240
257
  // Manage buttons for a locally installed extension: enable/disable toggle
241
258
  // (always available when installed) plus remove (installed layer only).
242
259
  function _renderActions(ext) {
243
- if (!ext.installed) return "";
244
- const slug = ext.name || ext.slug || (ext.id != null ? String(ext.id) : "");
260
+ const id = ext.id != null ? String(ext.id) : (ext.name || ext.slug || "");
261
+ if (!ext.installed) {
262
+ if (ext.origin === "marketplace" && ext.download_url) {
263
+ return `
264
+ <div class="extension-detail-actions">
265
+ <button type="button" class="extension-action extension-action-install" data-ext-install="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.install"))}</button>
266
+ </div>`;
267
+ }
268
+ return "";
269
+ }
270
+ const slug = ext.name || ext.slug || id;
245
271
  const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
246
272
  const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
247
273
  const disabledBadge = ext.disabled
@@ -274,10 +300,13 @@ const ExtensionsView = (() => {
274
300
  const singular = type.replace(/s$/, "");
275
301
  const heading = _sectionHeading(type);
276
302
  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 || "");
303
+ const isStr = typeof it === "string";
304
+ const title = isStr ? it
305
+ : ((currentLang === "zh" && it.title_zh) ? it.title_zh
306
+ : (it.title || it.name || it.id || singular));
307
+ const desc = isStr ? ""
308
+ : ((currentLang === "zh" && it.description_zh) ? it.description_zh
309
+ : (it.description || ""));
281
310
  return `
282
311
  <li class="extension-contrib-item">
283
312
  <span class="extension-contrib-title">${escapeHtml(String(title))}</span>
@@ -340,6 +369,14 @@ const ExtensionsView = (() => {
340
369
  sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
341
370
  }
342
371
 
372
+ document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
373
+ btn.addEventListener("click", () => {
374
+ document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
375
+ btn.classList.add("extensions-filter-tab-active");
376
+ Extensions.setFilterInstalled(btn.dataset.filter === "installed");
377
+ });
378
+ });
379
+
343
380
  const list = $("extensions-list");
344
381
  if (list) {
345
382
  list.addEventListener("click", (e) => {
@@ -110,7 +110,7 @@ const NewSessionView = (() => {
110
110
  }
111
111
 
112
112
  const author = (a.author || "").trim();
113
- if (author) {
113
+ if (author && !_isBuiltin(a)) {
114
114
  const by = document.createElement("div");
115
115
  by.className = "agent-card-author";
116
116
  by.textContent = _isZh() ? `作者 ${author}` : `by ${author}`;
@@ -508,16 +508,20 @@ const I18n = (() => {
508
508
  "mcp.title": "MCP Servers",
509
509
  "mcp.subtitle": "Standard Model Context Protocol servers (Claude Desktop / Cursor compatible). Edit ~/.clacky/mcp.json to add or remove servers.",
510
510
  "extensions.title": "Extension Market",
511
- "extensions.subtitle": "Browse and manage your extensions to enhance OpenClacky with agents, skills, and panels.",
511
+ "extensions.subtitle": "Browse and manage extensions add agents, skills, and panels to supercharge your workflow.",
512
512
  "extensions.searchPlaceholder": "Search extensions…",
513
513
  "extensions.sort.newest": "Newest",
514
514
  "extensions.sort.updated": "Recently updated",
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.",
519
521
  "extensions.homepage": "Homepage",
522
+ "extensions.by": "by",
520
523
  "extensions.installed": "Installed",
524
+ "extensions.unlisted": "Unlisted",
521
525
  "extensions.unit.skill": "skill",
522
526
  "extensions.unit.skills": "skills",
523
527
  "extensions.unit.agent": "agent",
@@ -534,10 +538,12 @@ const I18n = (() => {
534
538
  "extensions.section.panels": "Panels",
535
539
  "extensions.section.api": "APIs",
536
540
  "extensions.disabled": "Disabled",
541
+ "extensions.action.install": "Install",
542
+ "extensions.action.installing": "Installing…",
537
543
  "extensions.action.disable": "Disable",
538
544
  "extensions.action.enable": "Enable",
539
545
  "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.",
546
+ "extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it later.",
541
547
  "mcp.loading": "Loading…",
542
548
  "mcp.empty.title": "No MCP servers configured",
543
549
  "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.",
@@ -1465,16 +1471,20 @@ const I18n = (() => {
1465
1471
  "mcp.title": "MCP 工具",
1466
1472
  "mcp.subtitle": "标准 Model Context Protocol 服务(与 Claude Desktop / Cursor 兼容)。编辑 ~/.clacky/mcp.json 添加或移除服务。",
1467
1473
  "extensions.title": "扩展市场",
1468
- "extensions.subtitle": "浏览并管理你的扩展,用 Agent、技能与面板增强 OpenClacky。",
1474
+ "extensions.subtitle": "浏览并管理扩展,用 Agent、技能与面板增强你的工作流。",
1469
1475
  "extensions.searchPlaceholder": "搜索扩展…",
1470
1476
  "extensions.sort.newest": "最新",
1471
1477
  "extensions.sort.updated": "最近更新",
1472
1478
  "extensions.sort.downloads": "下载最多",
1479
+ "extensions.filter.all": "全部",
1480
+ "extensions.filter.installed": "已安装",
1473
1481
  "extensions.empty": "暂无可用扩展。",
1474
1482
  "extensions.noResults": "没有匹配的扩展。",
1475
1483
  "extensions.loadFailed": "无法连接扩展市场。",
1476
1484
  "extensions.homepage": "主页",
1485
+ "extensions.by": "作者",
1477
1486
  "extensions.installed": "已安装",
1487
+ "extensions.unlisted": "已下架",
1478
1488
  "extensions.unit.skill": "个技能",
1479
1489
  "extensions.unit.skills": "个技能",
1480
1490
  "extensions.unit.agent": "个 Agent",
@@ -1491,10 +1501,12 @@ const I18n = (() => {
1491
1501
  "extensions.section.panels": "面板",
1492
1502
  "extensions.section.api": "API",
1493
1503
  "extensions.disabled": "已禁用",
1504
+ "extensions.action.install": "安装",
1505
+ "extensions.action.installing": "安装中…",
1494
1506
  "extensions.action.disable": "禁用",
1495
1507
  "extensions.action.enable": "启用",
1496
1508
  "extensions.action.remove": "移除",
1497
- "extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可从授权包重新安装。",
1509
+ "extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可重新安装。",
1498
1510
  "mcp.loading": "加载中…",
1499
1511
  "mcp.empty.title": "尚未配置 MCP 服务",
1500
1512
  "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>
@@ -113,6 +113,33 @@ const Sessions = (() => {
113
113
  return html;
114
114
  }
115
115
 
116
+ // Encode file:// URLs inside raw markdown BEFORE marked parses it. The AI
117
+ // emits raw paths (spaces / non-ASCII / % literal); marked's link tokenizer
118
+ // treats a space as the end of the URL, so links with spaces are never
119
+ // recognized. Re-encoding the path (decode then encode, idempotent) turns
120
+ // spaces into %20 and a literal % into %25 so marked sees a valid URL.
121
+ function _encodeFileUrlsInMarkdown(text) {
122
+ return text.replace(
123
+ /(!?\[[^\]]*\]\()(file:\/{2,3})([^)]+)(\))/g,
124
+ (_m, open, scheme, path, close) => {
125
+ try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
126
+ return open + scheme + encodeURI(path) + close;
127
+ }
128
+ );
129
+ }
130
+
131
+ // Normalize a file:// href for use in <a href>. The AI emits raw paths
132
+ // (Chinese chars / spaces literal); encode them to a valid percent-encoded
133
+ // URL. Idempotent: an already-encoded path is decoded first so it isn't
134
+ // double-encoded. Non file:// links pass through untouched.
135
+ function _normalizeFileHref(href) {
136
+ if (typeof href !== "string" || !href.startsWith("file://")) return href;
137
+ const prefix = href.match(/^file:\/{2,3}/)[0];
138
+ let path = href.slice(prefix.length);
139
+ try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
140
+ return prefix + encodeURI(path);
141
+ }
142
+
116
143
  // Run marked on a text string. Returns HTML. Falls back to escaped plain text
117
144
  // if the marked library is unavailable.
118
145
  function _markedParse(text) {
@@ -122,6 +149,7 @@ const Sessions = (() => {
122
149
  const math = [];
123
150
  const PLACEHOLDER = (i) => `\u0000KTX${i}\u0000`;
124
151
  let prepared = _extractMath(text, math, PLACEHOLDER);
152
+ prepared = _encodeFileUrlsInMarkdown(prepared);
125
153
 
126
154
  let html;
127
155
  if (typeof marked !== "undefined") {
@@ -138,7 +166,7 @@ const Sessions = (() => {
138
166
  const renderer = new marked.Renderer();
139
167
  renderer.link = function({ href, title, text }) {
140
168
  const titleAttr = title ? ` title="${title}"` : "";
141
- return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
169
+ return `<a href="${_normalizeFileHref(href)}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
142
170
  };
143
171
  // Override code block rendering: apply syntax highlighting + header with
144
172
  // language label and copy button.
@@ -2307,7 +2335,9 @@ const Sessions = (() => {
2307
2335
  const link = e.target.closest("a[href^='file://']");
2308
2336
  if (!link) return;
2309
2337
  e.preventDefault();
2310
- let filePath = decodeURIComponent(link.getAttribute("href").replace(/^file:\/\//, ""));
2338
+ const rawHref = link.getAttribute("href").replace(/^file:\/\//, "");
2339
+ let filePath;
2340
+ try { filePath = decodeURIComponent(rawHref); } catch (_) { filePath = rawHref; }
2311
2341
  // file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
2312
2342
  if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
2313
2343
  if (!filePath) return;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.8
4
+ version: 1.3.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy