openclacky 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -3
  4. data/lib/clacky/agent/session_serializer.rb +50 -9
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +102 -10
  7. data/lib/clacky/client.rb +22 -16
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
  13. data/lib/clacky/extension/api_extension.rb +5 -6
  14. data/lib/clacky/extension/loader.rb +16 -2
  15. data/lib/clacky/extension/packager.rb +10 -1
  16. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  17. data/lib/clacky/mcp/http_transport.rb +1 -1
  18. data/lib/clacky/platform_http_client.rb +9 -7
  19. data/lib/clacky/providers.rb +113 -5
  20. data/lib/clacky/server/http_server.rb +131 -38
  21. data/lib/clacky/server/session_registry.rb +9 -0
  22. data/lib/clacky/tools/grep.rb +6 -1
  23. data/lib/clacky/utils/environment_detector.rb +16 -9
  24. data/lib/clacky/utils/file_processor.rb +21 -24
  25. data/lib/clacky/utils/model_pricing.rb +85 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +441 -48
  28. data/lib/clacky/web/app.js +29 -6
  29. data/lib/clacky/web/components/onboard.js +21 -7
  30. data/lib/clacky/web/features/backup/view.js +4 -3
  31. data/lib/clacky/web/features/extensions/store.js +56 -4
  32. data/lib/clacky/web/features/extensions/view.js +54 -24
  33. data/lib/clacky/web/features/mcp/view.js +5 -2
  34. data/lib/clacky/web/features/new-session/store.js +8 -4
  35. data/lib/clacky/web/features/version/view.js +5 -1
  36. data/lib/clacky/web/features/workspace/store.js +11 -0
  37. data/lib/clacky/web/features/workspace/view.js +10 -4
  38. data/lib/clacky/web/i18n.js +58 -36
  39. data/lib/clacky/web/index.html +74 -7
  40. data/lib/clacky/web/sessions.js +72 -15
  41. data/lib/clacky/web/settings.js +22 -3
  42. data/lib/clacky/web/theme.js +27 -58
  43. data/scripts/uninstall.sh +1 -1
  44. metadata +2 -5
  45. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
  46. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
  47. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
  48. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
@@ -17,6 +17,7 @@
17
17
  const Onboard = (() => {
18
18
  let _providers = [];
19
19
  let _selectedLang = I18n.lang(); // language chosen during setup
20
+ let _branded = false; // true when running under a brand license
20
21
 
21
22
  // ── Public API ──────────────────────────────────────────────────────────────
22
23
 
@@ -27,6 +28,7 @@ const Onboard = (() => {
27
28
  if (!data.needs_onboard) return { needsOnboard: false, phase: null };
28
29
 
29
30
  const phase = data.phase;
31
+ _branded = !!data.branded;
30
32
 
31
33
  if (phase === "key_setup") {
32
34
  // Mandatory: show full-screen setup panel, block boot.
@@ -111,9 +113,16 @@ const Onboard = (() => {
111
113
  $("setup-dot-1").className = "setup-step" + (step === "lang" ? " active" : " done");
112
114
  $("setup-dot-2").className = "setup-step" + (step === "key" ? " active" : "");
113
115
  if (step === "key") {
114
- $("setup-device-block").style.display = "";
115
- $("setup-manual-toggle").style.display = "";
116
- $("setup-manual-section").style.display = "none";
116
+ if (_branded) {
117
+ // Brand mode: skip the OpenClacky AI Keys card, go straight to manual config
118
+ $("setup-device-block").style.display = "none";
119
+ $("setup-manual-toggle").style.display = "none";
120
+ $("setup-manual-section").style.display = "";
121
+ } else {
122
+ $("setup-device-block").style.display = "";
123
+ $("setup-manual-toggle").style.display = "";
124
+ $("setup-manual-section").style.display = "none";
125
+ }
117
126
  }
118
127
  }
119
128
 
@@ -153,16 +162,20 @@ const Onboard = (() => {
153
162
  dropdown.appendChild(placeholder);
154
163
 
155
164
  _providers.forEach(p => {
165
+ // Prefer i18n key (localised); fall back to shipped English `name`.
166
+ const translated = p.name_key ? I18n.t(p.name_key) : null;
167
+ const displayName = (translated && translated !== p.name_key) ? translated : p.name;
168
+
156
169
  const opt = document.createElement("div");
157
170
  opt.className = "custom-select-option";
158
171
  opt.dataset.value = p.id;
159
- opt.dataset.label = p.name;
172
+ opt.dataset.label = displayName;
160
173
  if (p.id === "openclacky") {
161
174
  const nameSpan = document.createElement("span");
162
- nameSpan.textContent = p.name;
175
+ nameSpan.textContent = displayName;
163
176
  opt.innerHTML = nameSpan.outerHTML + ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>`;
164
177
  } else {
165
- opt.textContent = p.name;
178
+ opt.textContent = displayName;
166
179
  }
167
180
  dropdown.appendChild(opt);
168
181
  });
@@ -379,7 +392,8 @@ const Onboard = (() => {
379
392
  });
380
393
 
381
394
  $("setup-btn-back").addEventListener("click", () => {
382
- if ($("setup-manual-section").style.display !== "none") {
395
+ if (!_branded && $("setup-manual-section").style.display !== "none") {
396
+ // Non-brand: collapse manual section back to device card
383
397
  $("setup-device-block").style.display = "";
384
398
  $("setup-manual-toggle").style.display = "";
385
399
  $("setup-manual-section").style.display = "none";
@@ -58,7 +58,8 @@ const BackupView = (() => {
58
58
  a.click();
59
59
  a.remove();
60
60
  URL.revokeObjectURL(url);
61
- if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloaded"); statusEl.className = "model-test-result success"; }
61
+ if (statusEl) { statusEl.textContent = ""; statusEl.className = "model-test-result"; }
62
+ Modal.toast(I18n.t("settings.backup.downloaded"), "success");
62
63
  } else if (statusEl) {
63
64
  statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
64
65
  statusEl.className = "model-test-result error";
@@ -108,8 +109,8 @@ const BackupView = (() => {
108
109
  if (ev.type !== "_ws_connected" || !_restoreStatusEl) return;
109
110
  const el = _restoreStatusEl;
110
111
  _restoreStatusEl = null;
111
- el.textContent = I18n.t("settings.backup.restartOk");
112
- el.className = "model-test-result success";
112
+ if (el) { el.textContent = ""; el.className = "model-test-result"; }
113
+ Modal.toast(I18n.t("settings.backup.restartOk"), "success");
113
114
  if (typeof Settings !== "undefined") Settings.open();
114
115
  });
115
116
  }
@@ -22,8 +22,9 @@ const ExtensionsStore = (() => {
22
22
  let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
23
23
  let _allExtensions = []; // unfiltered result from server
24
24
  let _query = ""; // current search text
25
- let _sort = "newest"; // "newest" | "updated" | "downloads"
25
+ let _sort = "downloads"; // "newest" | "updated" | "downloads"
26
26
  let _filterInstalled = false; // when true, show only installed extensions
27
+ let _filterBrand = false; // when true, show only brand-private extensions
27
28
  let _loading = false;
28
29
  let _error = null; // soft warning when the store is unreachable
29
30
  let _detail = null; // currently opened extension detail, or null
@@ -53,6 +54,7 @@ const ExtensionsStore = (() => {
53
54
  get query() { return _query; },
54
55
  get sort() { return _sort; },
55
56
  get filterInstalled() { return _filterInstalled; },
57
+ get filterBrand() { return _filterBrand; },
56
58
  get loading() { return _loading; },
57
59
  get error() { return _error; },
58
60
  get detail() { return _detail; },
@@ -67,6 +69,7 @@ const ExtensionsStore = (() => {
67
69
  /** Fetch the catalog from the server for the current query + sort. */
68
70
  async load() {
69
71
  if (_filterInstalled) return;
72
+ if (_filterBrand) return Extensions.loadBrandExtensions();
70
73
  _loading = true;
71
74
  _error = null;
72
75
  _emit("extensions:loading");
@@ -97,18 +100,22 @@ const ExtensionsStore = (() => {
97
100
  setQuery(query) {
98
101
  _query = (query || "").trim();
99
102
  if (_filterInstalled) return Extensions.setFilterInstalled(true);
103
+ if (_filterBrand) return Extensions.loadBrandExtensions();
100
104
  return Extensions.load();
101
105
  },
102
106
 
103
107
  /** Set the sort order and reload. */
104
108
  setSort(sort) {
105
- _sort = sort || "newest";
109
+ _sort = sort || "downloads";
110
+ if (_filterBrand) return Extensions.loadBrandExtensions();
111
+ if (_filterInstalled) return Extensions.setFilterInstalled(true);
106
112
  return Extensions.load();
107
113
  },
108
114
 
109
115
  /** Toggle the "installed only" filter — fetches from local store when enabled. */
110
116
  async setFilterInstalled(onlyInstalled) {
111
117
  _filterInstalled = !!onlyInstalled;
118
+ _filterBrand = false;
112
119
  if (_filterInstalled) {
113
120
  _loading = true;
114
121
  _emit("extensions:loading");
@@ -132,6 +139,40 @@ const ExtensionsStore = (() => {
132
139
  }
133
140
  },
134
141
 
142
+ /** Toggle the "brand only" filter — fetches brand-private extensions. */
143
+ async setFilterBrand(onlyBrand) {
144
+ _filterBrand = !!onlyBrand;
145
+ _filterInstalled = false;
146
+ if (_filterBrand) {
147
+ return Extensions.loadBrandExtensions();
148
+ } else {
149
+ return Extensions.load();
150
+ }
151
+ },
152
+
153
+ /** Fetch brand-private extensions from /api/store/extensions/brand. */
154
+ async loadBrandExtensions() {
155
+ _loading = true;
156
+ _error = null;
157
+ _emit("extensions:loading");
158
+ try {
159
+ const res = await fetch("/api/store/extensions/brand");
160
+ const data = await res.json();
161
+ _extensions = data.extensions || [];
162
+ _error = data.warning || null;
163
+ _loading = false;
164
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
165
+ } catch (e) {
166
+ console.error("[Extensions] loadBrandExtensions failed", e);
167
+ _extensions = [];
168
+ _error = I18n.t("extensions.loadFailed");
169
+ _loading = false;
170
+ _emit("extensions:error", { network: true });
171
+ } finally {
172
+ _loading = false;
173
+ }
174
+ },
175
+
135
176
  /** Open the detail view for one extension (fetches contributes + versions). */
136
177
  async loadDetail(id) {
137
178
  if (!id) return;
@@ -159,6 +200,17 @@ const ExtensionsStore = (() => {
159
200
  }
160
201
  },
161
202
 
203
+ /** Fetch /api/brand/status and return { branded: bool }. */
204
+ async fetchBrandStatus() {
205
+ try {
206
+ const res = await fetch("/api/brand/status");
207
+ const data = await res.json();
208
+ return data;
209
+ } catch (_e) {
210
+ return { branded: false };
211
+ }
212
+ },
213
+
162
214
  /** Close the detail view. */
163
215
  closeDetail() {
164
216
  _detail = null;
@@ -218,13 +270,13 @@ const ExtensionsStore = (() => {
218
270
  },
219
271
 
220
272
  /** Remove an installed extension, then return to the list. */
221
- async uninstall(id) {
273
+ async uninstall(id, purgeData = false) {
222
274
  if (!id) return;
223
275
  try {
224
276
  const res = await fetch("/api/store/extension", {
225
277
  method: "DELETE",
226
278
  headers: { "Content-Type": "application/json" },
227
- body: JSON.stringify({ id }),
279
+ body: JSON.stringify({ id, purge_data: purgeData }),
228
280
  });
229
281
  const data = await res.json();
230
282
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
@@ -209,8 +209,11 @@ const ExtensionsView = (() => {
209
209
  if (remove) {
210
210
  remove.addEventListener("click", async () => {
211
211
  const id = remove.getAttribute("data-ext-remove");
212
- const ok = await Modal.confirm(I18n.t("extensions.action.removeConfirm"));
213
- if (ok) Extensions.uninstall(id);
212
+ const { ok, checked } = await Modal.confirmWithCheckbox(
213
+ I18n.t("extensions.action.removeConfirm"),
214
+ I18n.t("extensions.action.removePurgeData")
215
+ );
216
+ if (ok) Extensions.uninstall(id, checked);
214
217
  });
215
218
  }
216
219
 
@@ -291,16 +294,31 @@ const ExtensionsView = (() => {
291
294
  ${_renderActions(ext)}
292
295
  </div>
293
296
  </div>
294
- ${_renderEntryPoints(ext.contributes)}
297
+ ${_renderReadme(ext.readme)}
295
298
  ${_renderVersions(ext.versions)}`;
296
299
  }
297
300
 
301
+ // Renders Markdown readme content. Uses marked.js if available, falls back to plain text.
302
+ function _renderReadme(readme) {
303
+ if (!readme || !readme.trim()) return "";
304
+ const html = typeof marked !== "undefined"
305
+ ? marked.parse(readme, { breaks: true, gfm: true })
306
+ : `<pre style="white-space:pre-wrap">${escapeHtml(readme)}</pre>`;
307
+ return `
308
+ <div class="extension-detail-block extension-readme">
309
+ <h3 class="extension-detail-block-title">${escapeHtml(I18n.t("extensions.detail.readme"))}</h3>
310
+ <div class="extension-readme-body">${html}</div>
311
+ </div>`;
312
+ }
313
+
298
314
  // Manage buttons for a locally installed extension: enable/disable toggle
299
315
  // (always available when installed) plus remove (installed layer only).
300
316
  function _renderActions(ext) {
301
317
  const id = ext.id != null ? String(ext.id) : (ext.name || ext.slug || "");
302
318
  if (!ext.installed) {
303
- if (ext.origin === "marketplace" && ext.download_url) {
319
+ // Show install button for both marketplace and brand-private (origin=self) extensions,
320
+ // as long as a download_url is available.
321
+ if (ext.download_url) {
304
322
  return `
305
323
  <div class="extension-detail-actions">
306
324
  <button type="button" class="extension-action extension-action-install" data-ext-install="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.install"))}</button>
@@ -374,24 +392,6 @@ const ExtensionsView = (() => {
374
392
  </div>`;
375
393
  }
376
394
 
377
- function _renderEntryPoints(contributes) {
378
- if (!contributes || !Array.isArray(contributes.panels)) return "";
379
- const eps = contributes.panels.flatMap((p) => Array.isArray(p.entry_points) ? p.entry_points : []);
380
- if (eps.length === 0) return "";
381
- const rows = eps.map((ep) => {
382
- const slotKey = "extensions.slot." + ep.slot;
383
- const label = I18n.t(slotKey) !== slotKey ? I18n.t(slotKey) : ep.slot;
384
- return `<li class="extension-contrib-item"><span class="extension-contrib-title">${escapeHtml(label)}</span></li>`;
385
- }).join("");
386
- return `
387
- <div class="extension-detail-block">
388
- <h3 class="extension-detail-block-title">${escapeHtml(I18n.t("extensions.detail.entry_points"))}</h3>
389
- <div class="extension-detail-section">
390
- <ul class="extension-contrib-list">${rows}</ul>
391
- </div>
392
- </div>`;
393
- }
394
-
395
395
  function _renderVersions(versions) {
396
396
  if (!Array.isArray(versions) || versions.length === 0) return "";
397
397
  const rows = versions.map((v) => {
@@ -412,6 +412,17 @@ const ExtensionsView = (() => {
412
412
  </div>`;
413
413
  }
414
414
 
415
+ // Show/hide the brand filter tab based on brand status from the store.
416
+ async function _applyBrandTab() {
417
+ try {
418
+ const data = await Extensions.fetchBrandStatus();
419
+ const brandTab = $("tab-extensions-brand");
420
+ if (brandTab) brandTab.style.display = data.branded ? "" : "none";
421
+ } catch (_e) {
422
+ // On error, keep tab hidden.
423
+ }
424
+ }
425
+
415
426
  function _wireDom() {
416
427
  if (_domWired) return;
417
428
 
@@ -437,10 +448,25 @@ const ExtensionsView = (() => {
437
448
  btn.addEventListener("click", () => {
438
449
  document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
439
450
  btn.classList.add("extensions-filter-tab-active");
440
- Extensions.setFilterInstalled(btn.dataset.filter === "installed");
451
+ const filter = btn.dataset.filter;
452
+ if (filter === "installed") {
453
+ Extensions.setFilterInstalled(true);
454
+ } else if (filter === "brand") {
455
+ Extensions.setFilterBrand(true);
456
+ } else {
457
+ Extensions.setFilterInstalled(false);
458
+ }
441
459
  });
442
460
  });
443
461
 
462
+ // Subscribe to brand status changes to show/hide the brand tab.
463
+ if (window.Skills && Skills.on) {
464
+ Skills.on("brandStatus:changed", (p) => {
465
+ const brandTab = $("tab-extensions-brand");
466
+ if (brandTab) brandTab.style.display = p.branded ? "" : "none";
467
+ });
468
+ }
469
+
444
470
  const list = $("extensions-list");
445
471
  if (list) {
446
472
  list.addEventListener("click", (e) => {
@@ -471,8 +497,12 @@ const ExtensionsView = (() => {
471
497
  }
472
498
 
473
499
  const viewApi = {
474
- onPanelShow(opts) {
500
+ async onPanelShow(opts) {
475
501
  _wireDom();
502
+ // Apply brand tab visibility based on current status (fast path),
503
+ // then also refresh in background so it stays up-to-date.
504
+ _applyBrandTab();
505
+ if (window.Skills && Skills.refreshBrandStatus) Skills.refreshBrandStatus();
476
506
  const detailId = opts && opts.detailId;
477
507
  if (detailId) {
478
508
  Extensions.load();
@@ -84,6 +84,11 @@ const McpView = (() => {
84
84
 
85
85
  data.servers.forEach(server => {
86
86
  list.appendChild(_renderCard(server));
87
+ // _renderTools uses getElementById which requires the card to already be
88
+ // in the DOM, so we call it after appendChild (not inside _renderCard).
89
+ if (McpStore.state.isExpanded(server.name) && !server.disabled) {
90
+ _renderTools(server.name);
91
+ }
87
92
  });
88
93
  }
89
94
 
@@ -170,8 +175,6 @@ const McpView = (() => {
170
175
  card.querySelector(`#toggle-mcp-${CSS.escape(server.name)}`)
171
176
  ?.addEventListener("change", (ev) => _toggle(server.name, ev.target.checked));
172
177
 
173
- if (isExpanded && !server.disabled) _renderTools(server.name);
174
-
175
178
  return card;
176
179
  }
177
180
 
@@ -122,19 +122,23 @@ const NewSessionStore = (() => {
122
122
  return "Session " + (maxN + 1);
123
123
  }
124
124
 
125
- async function createSession({ existingSessions } = {}) {
125
+ async function createSession({ existingSessions, useDefaults = false } = {}) {
126
126
  if (_state.creating) return null;
127
127
  _state.creating = true;
128
128
  _emit("newSession:creating", { creating: true });
129
129
 
130
130
  try {
131
- const agentId = _state.selectedAgentId || "general";
131
+ // useDefaults=true: quick-create from sidebar — use the default agent and
132
+ // skip advanced options (model / dir) so the session always starts clean.
133
+ const agentId = useDefaults ? "general" : (_state.selectedAgentId || "general");
132
134
  const adv = _state.advanced;
133
135
  const name = adv.name.trim() || _autoName(existingSessions);
134
136
 
135
137
  const payload = { name, agent_profile: agentId, source: "manual" };
136
- if (adv.workingDir.trim()) payload.working_dir = adv.workingDir.trim();
137
- if (adv.modelId) payload.model_id = adv.modelId;
138
+ if (!useDefaults) {
139
+ if (adv.workingDir.trim()) payload.working_dir = adv.workingDir.trim();
140
+ if (adv.modelId) payload.model_id = adv.modelId;
141
+ }
138
142
 
139
143
  const res = await fetch("/api/sessions", {
140
144
  method: "POST",
@@ -237,7 +237,11 @@ const VersionView = (() => {
237
237
  _closePopover();
238
238
  if (!_autoReloaded) {
239
239
  _autoReloaded = true;
240
- setTimeout(() => window.location.reload(), 800);
240
+ // Delay reload to ensure the server is fully ready after restart.
241
+ // /api/version responding OK does not guarantee the full server stack
242
+ // (static assets, routes) is ready; a short extra wait avoids a blank
243
+ // page caused by the browser hitting a not-yet-ready server.
244
+ setTimeout(() => window.location.reload(), 3000);
241
245
  }
242
246
  return;
243
247
  }
@@ -72,6 +72,17 @@ const WorkspaceStore = (() => {
72
72
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
73
73
  },
74
74
 
75
+ async displayPath(entry) {
76
+ const resp = await fetch("/api/file-action", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ path: _absPath(entry.path), action: "display-path" })
80
+ });
81
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
82
+ const data = await resp.json();
83
+ return data.path;
84
+ },
85
+
75
86
  async fetchFileBlob(entry) {
76
87
  const resp = await fetch("/api/file-action", {
77
88
  method: "POST",
@@ -156,10 +156,16 @@ const WorkspaceView = (() => {
156
156
  if (existing) existing.remove();
157
157
  }
158
158
 
159
- function copyPath(entry) {
160
- const absPath = Workspace.state.workingDir.replace(/\/+$/, "") + "/" + entry.path.replace(/^\/+/, "");
161
- navigator.clipboard.writeText(absPath).then(() => {
162
- Modal.toast(absPath, "info");
159
+ async function copyPath(entry) {
160
+ const fallback = Workspace.state.workingDir.replace(/\/+$/, "") + "/" + entry.path.replace(/^\/+/, "");
161
+ let target = fallback;
162
+ try {
163
+ target = await Workspace.displayPath(entry);
164
+ } catch (err) {
165
+ target = fallback;
166
+ }
167
+ navigator.clipboard.writeText(target).then(() => {
168
+ Modal.toast(target, "info");
163
169
  });
164
170
  }
165
171