openclacky 1.5.7 → 1.5.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +3 -3
  4. data/README_CN.md +2 -2
  5. data/README_JA.md +2 -2
  6. data/lib/clacky/agent.rb +2 -3
  7. data/lib/clacky/agent_config.rb +40 -18
  8. data/lib/clacky/brand_config.rb +12 -7
  9. data/lib/clacky/cli.rb +0 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +27 -14
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +22 -5
  13. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  14. data/lib/clacky/extension/api_loader.rb +4 -11
  15. data/lib/clacky/extension/packager.rb +1 -1
  16. data/lib/clacky/extension/scaffold/templates/hello/test/handler_test.rb.erb +22 -0
  17. data/lib/clacky/extension/verifier.rb +1 -1
  18. data/lib/clacky/media/generator.rb +3 -3
  19. data/lib/clacky/message_format/anthropic.rb +19 -2
  20. data/lib/clacky/message_format/bedrock.rb +8 -3
  21. data/lib/clacky/message_history.rb +1 -1
  22. data/lib/clacky/providers.rb +159 -105
  23. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
  24. data/lib/clacky/server/http_server.rb +67 -79
  25. data/lib/clacky/session_manager.rb +7 -0
  26. data/lib/clacky/skill_loader.rb +4 -0
  27. data/lib/clacky/tools/base.rb +11 -1
  28. data/lib/clacky/tools/edit.rb +28 -0
  29. data/lib/clacky/tools/terminal.rb +5 -0
  30. data/lib/clacky/tools/trash_manager.rb +1 -1
  31. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  32. data/lib/clacky/ui2/components/input_area.rb +1 -0
  33. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  34. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  35. data/lib/clacky/ui2/line_editor.rb +1 -0
  36. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  37. data/lib/clacky/ui2/ui_controller.rb +1 -0
  38. data/lib/clacky/utils/model_pricing.rb +62 -27
  39. data/lib/clacky/utils/string_matcher.rb +29 -2
  40. data/lib/clacky/version.rb +1 -1
  41. data/lib/clacky/web/app.css +278 -221
  42. data/lib/clacky/web/components/chat-navigator.js +1 -1
  43. data/lib/clacky/web/features/billing/store.js +3 -3
  44. data/lib/clacky/web/features/billing/view.js +5 -61
  45. data/lib/clacky/web/features/extensions/store.js +65 -41
  46. data/lib/clacky/web/features/extensions/view.js +74 -10
  47. data/lib/clacky/web/features/new-session/view.js +24 -0
  48. data/lib/clacky/web/features/version/view.js +2 -2
  49. data/lib/clacky/web/i18n.js +36 -22
  50. data/lib/clacky/web/index.html +17 -17
  51. data/lib/clacky/web/projects.js +83 -285
  52. data/lib/clacky/web/sessions.js +151 -17
  53. data/lib/clacky/web/settings.js +6 -1
  54. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c045233115364b3165e5ca43d7b45b898250996f952c2db8baacb4435404cc0
4
- data.tar.gz: 3617f770edb88eaf6361bb0e6398f2ab6519f950ce234c89d0e9bce4fed4d486
3
+ metadata.gz: 565adf42059330fb821c57cb3b1200035fb866a1f2190ca87e093bc34de9edc0
4
+ data.tar.gz: 89db4dd987f39d4b77acd52121aa31bef54858f81d614557ac7b9984a1066ba0
5
5
  SHA512:
6
- metadata.gz: 6e5943dfb3599650349c8e09359e3fc1467e7fa6126f78ccf4840d2d9eaa02e5c06b088dc02bbeb789d53e8ae5fc128656a6e193dd385951a627f2349222262d
7
- data.tar.gz: 8565520e6fef3cf12bc8580c537b04499a0f3b6068b2ff88d495dbec51f2feb70ef2e45713d0c1d1a06af0b1e4fffbdf4e3d4a2f414649943515b83d7a7f2946
6
+ metadata.gz: e3bbb8988d833e3c6e6ce4cd13da64111e2ee15b400c2eb34b78bca9f51ae80525336baf923ba18cdbf85b5320d542f8050567792e7d5a437b28e2d09d29729d
7
+ data.tar.gz: ae508f3bdb44a35b8edd0c89ebcc53a64d3f7340214ded6c8dd47d0c757c66f5f99700f466c86278e8ee0a38ab838b8509c80ba8af2f253653f967b91f52a814
data/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.9] - 2026-08-14
8
+
9
+ ### Added
10
+ - OrcaRouter as a named provider preset (#480 - @Marc-oss-hub)
11
+ - DeepSeek peak/off-peak billing support
12
+ - Unicode homoglyph-tolerant string matching in edit mode
13
+ - Load-more pagination in extension marketplace
14
+ - Remove-from-project action in session menu
15
+ - Move-to-project submenu in session actions menu
16
+ - Changelog link in version badge and upgrade popover
17
+ - ext-studio scaffolding generates a runnable test/handler_test.rb and promotes "write tests" to its own step
18
+ - Lucide SVG icons for cron and extension group icons
19
+
20
+ ### Improved
21
+ - Skill "use" button themed with accent color
22
+ - Reordered provider presets list
23
+ - Replaced native confirm() with a Modal component and refreshed aside icons
24
+ - Billing sidebar icon switched to circle-dollar-sign
25
+ - Settings data tab renamed to backup management
26
+ - Refined project session group indentation and hover styling
27
+
28
+ ### Fixed
29
+ - Scroll to top when entering a session group sub-view
30
+ - Zhipu AI website_url now points to the console overview page
31
+ - WSL path translation and CRLF line-ending preservation (#454 - @shipinliang)
32
+ - Project sessions re-render on language switch
33
+ - Corrected arrow direction for move-in/move-out project icons
34
+ - Attachment drafts isolated per session (#478)
35
+ - Pinned sessions prioritized in project session lists
36
+ - Projects.all() returns a sorted list
37
+ - Builtin extension skills classified as system and hidden third-party ext skills from the panel
38
+ - Feishu WebSocket TLS SNI (#479 - @ayers-ltd)
39
+ - stop_reason mapping expanded from 3 to 8 cases (#469 - @shipinliang)
40
+
41
+ ### More
42
+ - Shortened ext-developer title
43
+ - cmdbar hover and session group indent tweaks
44
+
45
+ ## [1.5.8] - 2026-08-12
46
+
47
+ ### Added
48
+ - Inline SVG icons (edit, clear sessions, delete) in project action menu (#474)
49
+ - New project button in project selector popover
50
+ - Verified badge for officially reviewed extensions in marketplace
51
+
52
+ ### Improved
53
+ - Skip full session/file scan when count is under cap (#470 - @shipinliang)
54
+ - Token estimation uses count() instead of scan().length (#471 - @shipinliang)
55
+ - Faster server startup
56
+ - Brand user extension UI (non-admin publish button grayed out, unverified banner hidden)
57
+ - Sidebar my-data section icons refreshed
58
+ - Section chevron icon size increased from 11px to 13px
59
+ - Project sidebar hover effects, folder default hints, project icon sizing
60
+ - Billing UI cleanup and global theme color adjustment
61
+ - Section header font and project group spacing tweaks
62
+ - Project menu labels updated
63
+ - Billing disclaimer about provider billing
64
+
65
+ ### Fixed
66
+ - Extension pack max zip size increased from 50 MB to 80 MB
67
+ - New session button in project area now correctly preselects the project
68
+ - Chat navigation popup no longer overlaps sidebar collapse button
69
+ - Sub-model config preserved when switching modes (auto/off/custom)
70
+ - minimax-m2.7 vision detection fixed
71
+ - Project icon no longer reverts to default after page refresh
72
+ - Load constant warning fixed
73
+
74
+ ### More
75
+ - README cost table formatting normalized
76
+ - Ambiguous cost phrasing clarified across READMEs (#475 - @chengoak)
77
+
7
78
  ## [1.5.7] - 2026-08-10
8
79
 
9
80
  ### Added
data/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  **The most Token-efficient open-source AI Agent.**
16
16
 
17
- OpenClacky matches Claude Code on capability at comparable cost, and saves significantly against other open-source agents (~50% vs OpenClaw, ~3× cheaper than Hermes). 100% open source (MIT), BYOK with any OpenAI-compatible model, built on two years of Agentic R&D and harness engineering.
17
+ OpenClacky matches Claude Code on capability at comparable cost, and saves significantly against other open-source agents (~50% vs OpenClaw, costs only ~1/3 of Hermes). 100% open source (MIT), BYOK with any OpenAI-compatible model, built on two years of Agentic R&D and harness engineering.
18
18
 
19
19
  > Website: https://www.openclacky.com/ · Backed by **MiraclePlus · ZhenFund · Sequoia China · Hillhouse Capital**
20
20
 
@@ -24,7 +24,7 @@ Same task, how much do you pay? Under comparable agent workloads, OpenClacky sav
24
24
 
25
25
  | Agent | Relative cost | Notes |
26
26
  |---|---|---|
27
- | **OpenClacky** | **~0.8** | 16 tools · ~100% cache hit · subagent routing |
27
+ | **OpenClacky** | **~0.8×** | 16 tools · ~100% cache hit · subagent routing |
28
28
  | Claude Code | 1.0× (baseline) | World-class harness, closed-source subscription |
29
29
  | OpenClaw | ~1.5× | Comparable harness agent |
30
30
  | Hermes | ~3× | 52 built-in tools — schema bloat ~3–4× |
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
196
196
 
197
- Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
197
+ Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — or any custom endpoint.
198
198
 
199
199
  ## Coding use case
200
200
 
data/README_CN.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **最省 Token 的开源 AI Agent。**
12
12
 
13
- OpenClacky 在任务能力上对齐 Claude Code,成本相当,同时相比其他开源 Agent 有显著优势(约节省 50% vs OpenClaw,约便宜 vs Hermes)。100% 开源(MIT),支持 BYOK 接入任意 OpenAI 兼容模型,背后是两年 Agentic 研发与 Harness 工程积累。
13
+ OpenClacky 在任务能力上对齐 Claude Code,成本相当,同时相比其他开源 Agent 有显著优势(约节省 50% vs OpenClaw,成本约为 Hermes 1/3)。100% 开源(MIT),支持 BYOK 接入任意 OpenAI 兼容模型,背后是两年 Agentic 研发与 Harness 工程积累。
14
14
 
15
15
  > 官网:https://www.openclacky.com/ · 投资方:**奇绩创坛 · 真格基金 · 红杉中国 · 高瓴资本**
16
16
 
@@ -189,7 +189,7 @@ $ openclacky
189
189
 
190
190
  设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
191
191
 
192
- 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter**,或任意自定义端点。
192
+ 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter**,或任意自定义端点。
193
193
 
194
194
  ## 代码开发场景
195
195
 
data/README_JA.md CHANGED
@@ -24,7 +24,7 @@ OpenClacky は Claude Code と同等の性能を同等のコストで実現し
24
24
 
25
25
  | エージェント | 相対コスト | 備考 |
26
26
  |---|---|---|
27
- | **OpenClacky** | **約 0.8** | 16 ツール · キャッシュヒット率約 100% · サブエージェントルーティング |
27
+ | **OpenClacky** | **約 0.8×** | 16 ツール · キャッシュヒット率約 100% · サブエージェントルーティング |
28
28
  | Claude Code | 1.0×(基準) | 世界クラスのハーネス、クローズドソースのサブスクリプション |
29
29
  | OpenClaw | 約 1.5× | 同等のハーネスエージェント |
30
30
  | Hermes | 約 3× | 52 個の組み込みツール — スキーマの肥大化が約 3〜4 倍 |
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  **API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
196
196
 
197
- 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — または任意のカスタムエンドポイント。
197
+ 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — または任意のカスタムエンドポイント。
198
198
 
199
199
  ## コーディングのユースケース
200
200
 
data/lib/clacky/agent.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "securerandom"
4
4
  require "json"
5
5
  require "cgi"
6
- require "tty-prompt"
7
6
  require "set"
8
7
  require_relative "null_ui_controller"
9
8
  require_relative "utils/arguments_parser"
@@ -1392,7 +1391,7 @@ module Clacky
1392
1391
  # plain text, inflating token counts by 20-40x. The tool result carries a
1393
1392
  # plain-text description for the LLM; the actual image is delivered here.
1394
1393
  vision_supported = @config.current_model_supports?(:vision)
1395
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1394
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1396
1395
 
1397
1396
  tool_results.each do |tr|
1398
1397
  inject = tr[:image_inject]
@@ -1959,7 +1958,7 @@ module Clacky
1959
1958
  # OCR sidecar — only consulted when the primary doesn't see images.
1960
1959
  # When the sidecar entry has "primary"=>true, the primary itself can see,
1961
1960
  # so vision_supported was already true and we never enter the OCR branch.
1962
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1961
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1963
1962
 
1964
1963
  vision_images = [] # Array of { url:, name:, size_bytes:, path: }
1965
1964
  downgraded = []
@@ -633,16 +633,10 @@ module Clacky
633
633
  }.compact
634
634
  end
635
635
 
636
- # Find model by type (default or lite or media kind or ocr sidecar)
637
- # Returns the model hash or nil if not found.
638
- # For media kinds (image/video/audio): explicit user-configured (custom)
639
- # entries win; otherwise an auto-derived virtual entry is returned
640
- # based on the default model's provider — mirroring how lite is
641
- # virtually derived via #lite_model_config_for_current.
642
- # For "ocr": same custom→auto→nil pattern. Auto path first checks
643
- # whether the default model itself supports vision (zero-overhead path,
644
- # no sidecar needed); if not, derives from the provider's
645
- # default_ocr_model.
636
+ # Raw accessor - returns the stored model entry without mode-aware filtering.
637
+ # For media/ocr: does NOT check mode field, only legacy disabled flag.
638
+ # Mode-aware callers should use #effective_media_entry / #effective_ocr_entry.
639
+ # HTTP server uses this to read raw entries for saved_custom / api_key masking.
646
640
  def find_model_by_type(type)
647
641
  kind = type.to_s
648
642
  if Clacky::Providers::MEDIA_KINDS.include?(kind)
@@ -701,6 +695,38 @@ module Clacky
701
695
  @models.reject! { |m| m["auto_injected"] && Clacky::Providers::MEDIA_KINDS.include?(m["type"].to_s) }
702
696
  end
703
697
 
698
+ private def sidecar_off?(entry)
699
+ entry && (entry["disabled"] || entry["mode"] == "off")
700
+ end
701
+
702
+ private def sidecar_custom?(entry)
703
+ entry &&
704
+ entry["base_url"].to_s.strip != "" &&
705
+ entry["api_key"].to_s.strip != "" &&
706
+ entry["mode"] != "auto"
707
+ end
708
+
709
+ def effective_media_entry(kind)
710
+ kind = kind.to_s
711
+ raw_entry = @models.find { |m| m["type"] == kind }
712
+ return nil if sidecar_off?(raw_entry)
713
+
714
+ is_custom = sidecar_custom?(raw_entry)
715
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
716
+
717
+ is_custom ? raw_entry : derive_media_model(kind, model_override: override_model)
718
+ end
719
+
720
+ def effective_ocr_entry
721
+ raw_entry = @models.find { |m| m["type"] == "ocr" }
722
+ return nil if sidecar_off?(raw_entry)
723
+
724
+ is_custom = sidecar_custom?(raw_entry)
725
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
726
+
727
+ is_custom ? raw_entry : derive_ocr_model(model_override: override_model)
728
+ end
729
+
704
730
  # Derive an OCR sidecar model entry from the default model's provider.
705
731
  # Resolution order:
706
732
  # 1. If the default model itself supports vision → return the default
@@ -763,7 +789,7 @@ module Clacky
763
789
  kind = kind.to_s
764
790
  raw_entry = @models.find { |m| m["type"] == kind }
765
791
 
766
- if raw_entry && raw_entry["disabled"]
792
+ if sidecar_off?(raw_entry)
767
793
  default = find_model_by_type("default")
768
794
  default_provider = default && Clacky::Providers.resolve_provider(
769
795
  base_url: default["base_url"], api_key: default["api_key"]
@@ -782,9 +808,7 @@ module Clacky
782
808
  }
783
809
  end
784
810
 
785
- is_custom = raw_entry &&
786
- raw_entry["base_url"].to_s.strip != "" &&
787
- raw_entry["api_key"].to_s.strip != ""
811
+ is_custom = sidecar_custom?(raw_entry)
788
812
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
789
813
 
790
814
  entry = if is_custom
@@ -842,7 +866,7 @@ module Clacky
842
866
  )
843
867
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
844
868
 
845
- if raw_entry && raw_entry["disabled"]
869
+ if sidecar_off?(raw_entry)
846
870
  # A disabled OCR sidecar only means "no separate vision model"; it must
847
871
  # not override the fact that the chat model may handle images itself.
848
872
  anchor = current_model || default
@@ -872,9 +896,7 @@ module Clacky
872
896
  }
873
897
  end
874
898
 
875
- is_custom = raw_entry &&
876
- raw_entry["base_url"].to_s.strip != "" &&
877
- raw_entry["api_key"].to_s.strip != ""
899
+ is_custom = sidecar_custom?(raw_entry)
878
900
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
879
901
 
880
902
  entry = if is_custom
@@ -777,21 +777,26 @@ module Clacky
777
777
  end
778
778
 
779
779
  # Search the public extension marketplace. Anonymous — no license required.
780
- # Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
781
- def search_extensions!(query: nil, sort: nil)
780
+ # Uses GET /api/v1/extensions. Returns { success:, extensions: [], meta:, error: }.
781
+ # `meta` carries the platform's pagination info ({ current_page, total_pages,
782
+ # total_count, per_page }) when available.
783
+ def search_extensions!(query: nil, sort: nil, page: nil, per_page: nil)
782
784
  params = {}
783
- params["q"] = query if query && !query.to_s.strip.empty?
784
- params["sort"] = sort if sort && !sort.to_s.strip.empty?
785
+ params["q"] = query if query && !query.to_s.strip.empty?
786
+ params["sort"] = sort if sort && !sort.to_s.strip.empty?
787
+ params["page"] = page if page && page.to_i > 1
788
+ params["per_page"] = per_page if per_page && per_page.to_i > 0
785
789
  qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
786
790
  response = platform_client.get("/api/v1/extensions#{qs}")
787
791
 
788
792
  if response[:success]
789
- { success: true, extensions: response[:data]["extensions"] || [] }
793
+ data = response[:data] || {}
794
+ { success: true, extensions: data["extensions"] || [], meta: data["meta"] }
790
795
  else
791
- { success: false, error: response[:error] || "Search failed", extensions: [] }
796
+ { success: false, error: response[:error] || "Search failed", extensions: [], meta: nil }
792
797
  end
793
798
  rescue StandardError => e
794
- { success: false, error: "Network error: #{e.message}", extensions: [] }
799
+ { success: false, error: "Network error: #{e.message}", extensions: [], meta: nil }
795
800
  end
796
801
 
797
802
  # Fetch a single public marketplace extension's detail (contributes +
data/lib/clacky/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "tty-prompt"
5
4
  require "fileutils"
6
5
  require_relative "ui2"
7
6
  require_relative "json_ui_controller"
@@ -17,7 +17,7 @@ contributes:
17
17
  order: 210
18
18
  agents:
19
19
  - id: ext-developer
20
- title: Extension Developer
20
+ title: Ext Developer
21
21
  title_zh: 扩展开发
22
22
  description: AI expert that helps you build custom AI agent workflows
23
23
  description_zh: 帮你开发自定义 AI Agent 工作流的专家
@@ -148,6 +148,7 @@
148
148
  "extlist.unpublish.confirm": "Unpublish {{id}} from the marketplace?",
149
149
  "extlist.publishing": "Publishing…",
150
150
  "extlist.needLicense": "Publishing requires an activated user license.",
151
+ "extlist.brandLocked": "Publishing to the marketplace requires a creator license.",
151
152
  "extlist.newExt.label": "Create a new extension",
152
153
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
153
154
  "extlist.newExt.btn": "Create New Extension",
@@ -338,6 +339,7 @@
338
339
  "extlist.unpublish.confirm": "确定要从市场下架 {{id}} 吗?",
339
340
  "extlist.publishing": "发布中…",
340
341
  "extlist.needLicense": "发布需要已激活的用户授权。",
342
+ "extlist.brandLocked": "发布到市场需要创作者权限。",
341
343
  "extlist.newExt.label": "创建新扩展",
342
344
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
343
345
  "extlist.newExt.btn": "创建新扩展",
@@ -1370,7 +1372,7 @@
1370
1372
  const unpubBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-sm", text: t("btn.unpublish") });
1371
1373
  unpubBtn.style.marginLeft = "auto";
1372
1374
  unpubBtn.addEventListener("click", async () => {
1373
- if (!window.confirm(t("published.confirm", { id: e.id }))) return;
1375
+ if (!await Clacky.Modal.confirm(t("published.confirm", { id: e.id }))) return;
1374
1376
  unpubBtn.disabled = true;
1375
1377
  try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
1376
1378
  catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
@@ -1406,11 +1408,16 @@
1406
1408
  ));
1407
1409
 
1408
1410
  const bar = el("div", { class: "studio-actions" });
1409
- bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
1411
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1412
+ const pubBtn = el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish });
1413
+ pubBtn.disabled = brandNonAdmin;
1414
+ if (brandNonAdmin) pubBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1415
+ bar.appendChild(pubBtn);
1410
1416
  if (typeof Brand !== "undefined" && Brand.userLicensed) {
1411
1417
  bar.appendChild(el("button", { class: "studio-btn studio-publish-brand-btn", text: t("btn.publishBrand"), onclick: doPublishBrand }));
1412
1418
  }
1413
- bar.appendChild(el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack }));
1419
+ const packBtn = el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack });
1420
+ bar.appendChild(packBtn);
1414
1421
  container.appendChild(bar);
1415
1422
 
1416
1423
  container.appendChild(el("div", { class: "studio-feedback" }));
@@ -1518,7 +1525,7 @@
1518
1525
  actions.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
1519
1526
  const un = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.unpublish") });
1520
1527
  un.addEventListener("click", async () => {
1521
- if (!window.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1528
+ if (!await Clacky.Modal.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1522
1529
  un.disabled = true;
1523
1530
  try { await postJson("/unpublish", { ext_id: ext.id }); await reload(); rebuild(); }
1524
1531
  catch (e) { un.disabled = false; alert(t("err.generic", { msg: e.message })); }
@@ -1548,9 +1555,11 @@
1548
1555
  card.appendChild(meta);
1549
1556
 
1550
1557
  const actions = el("div", { class: "studio-actions" });
1558
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1551
1559
  const pub = el("button", { class: "studio-btn studio-btn-primary", text: published ? t("extlist.btn.update") : t("extlist.btn.publish") });
1552
- pub.disabled = !!ext.error_count;
1553
- pub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1560
+ pub.disabled = !!ext.error_count || brandNonAdmin;
1561
+ const pubTip = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : (brandNonAdmin ? t("extlist.brandLocked") : "");
1562
+ if (pubTip) pub.setAttribute("data-tooltip", pubTip);
1554
1563
  pub.addEventListener("click", () => doPublish(ext, cloudEntry ? cloudEntry.version : null));
1555
1564
  actions.appendChild(pub);
1556
1565
 
@@ -1558,7 +1567,7 @@
1558
1567
  const brandEntry = brandCloud.find((c) => c.id === ext.id);
1559
1568
  const brandPub = el("button", { class: "studio-btn", text: t("extlist.btn.publishBrand") });
1560
1569
  brandPub.disabled = !!ext.error_count;
1561
- brandPub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1570
+ if (ext.error_count) brandPub.setAttribute("data-tooltip", t("extlist.verify.errors", { n: ext.error_count }));
1562
1571
  brandPub.addEventListener("click", () => doPublish(ext, brandEntry ? brandEntry.version : null, "self"));
1563
1572
  actions.appendChild(brandPub);
1564
1573
  }
@@ -1590,7 +1599,7 @@
1590
1599
  }
1591
1600
 
1592
1601
  async function doDelete(ext, btn) {
1593
- if (!confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1602
+ if (!await Clacky.Modal.confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1594
1603
  btn.disabled = true;
1595
1604
  try {
1596
1605
  const res = await fetch("/api/ext/ext-studio/local", {
@@ -1635,11 +1644,15 @@
1635
1644
 
1636
1645
  if (headerActions) {
1637
1646
  headerActions.innerHTML = "";
1638
- headerActions.appendChild(el("button", {
1647
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1648
+ const newBtn = el("button", {
1639
1649
  class: "studio-btn studio-btn-primary",
1640
1650
  text: t("extlist.newExt.btn"),
1641
1651
  onclick: () => createExtension(t("extlist.newExt.seed")),
1642
- }));
1652
+ });
1653
+ newBtn.disabled = brandNonAdmin;
1654
+ if (brandNonAdmin) newBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1655
+ headerActions.appendChild(newBtn);
1643
1656
  }
1644
1657
 
1645
1658
  container.appendChild(section("extlist.section.cloud", "extlist.section.cloudHint", cloud, cloudCard, "extlist.cloud.empty"));
@@ -1711,7 +1724,7 @@
1711
1724
  if (skill.local_present && skill.has_local_changes) {
1712
1725
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.update") });
1713
1726
  btn.disabled = locked;
1714
- btn.title = locked ? t("skills.locked") : "";
1727
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1715
1728
  btn.addEventListener("click", () => doPublish(skill.name, btn, true));
1716
1729
  actions.appendChild(btn);
1717
1730
  } else if (skill.local_present) {
@@ -1738,7 +1751,7 @@
1738
1751
  const actions = el("div", { class: "studio-actions" });
1739
1752
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.publish") });
1740
1753
  btn.disabled = locked;
1741
- btn.title = locked ? t("skills.locked") : "";
1754
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1742
1755
  btn.addEventListener("click", () => doPublish(skill.name, btn, false));
1743
1756
  actions.appendChild(btn);
1744
1757
  card.appendChild(actions);
@@ -1753,7 +1766,7 @@
1753
1766
  try {
1754
1767
  let result = await publish(name, isUpdate);
1755
1768
  if (!result.ok && result.already_exists && !isUpdate) {
1756
- if (window.confirm(`"${name}" already exists on the platform. Overwrite?`)) {
1769
+ if (await Clacky.Modal.confirm(`"${name}" already exists on the platform. Overwrite?`)) {
1757
1770
  result = await publish(name, true);
1758
1771
  } else {
1759
1772
  btn.disabled = false;
@@ -1798,7 +1811,7 @@
1798
1811
  onclick: () => createSkill(null),
1799
1812
  });
1800
1813
  newBtn.disabled = locked;
1801
- newBtn.title = locked ? t("skills.locked") : "";
1814
+ if (locked) newBtn.setAttribute("data-tooltip", t("skills.locked"));
1802
1815
  headerActions.appendChild(newBtn);
1803
1816
  }
1804
1817
 
@@ -280,14 +280,15 @@ This creates `~/.clacky/ext/local/<id>/` with a working hello panel + handler:
280
280
  - `ext.yml` — the manifest
281
281
  - `panels/hello/view.js` — a panel that pings the backend
282
282
  - `api/handler.rb` — a `Clacky::ApiExtension` subclass mounted at `/api/ext/<id>/`
283
+ - `test/handler_test.rb` — a runnable minitest example (`ruby test/handler_test.rb`)
283
284
 
284
285
  Use `--full` only when the user needs the kitchen-sink reference exercising all seven
285
286
  contributes types — it's a lot to read, so prefer the plain scaffold otherwise.
286
287
 
287
288
  ### 3 — Read what was generated
288
289
 
289
- Always read the generated `ext.yml`, `view.js`, and `handler.rb` before editing. This
290
- is your starting point; you'll reshape it to match the idea.
290
+ Always read the generated `ext.yml`, `view.js`, `handler.rb`, and `test/handler_test.rb`
291
+ before editing. This is your starting point; you'll reshape it to match the idea.
291
292
 
292
293
  ### 4 — Reshape to the idea
293
294
 
@@ -366,7 +367,22 @@ Rules while reshaping:
366
367
  reference `panels: [id]` and `skills: [id]`. Add those blocks to `ext.yml` only if the
367
368
  idea needs them.
368
369
 
369
- ### 5 — Confirm it loads
370
+ ### 5 — Write tests
371
+
372
+ Test the backend before you call it done. Rewrite the scaffolded `test/handler_test.rb`
373
+ to exercise your real routes (parse the JSON response, assert its fields), then run it
374
+ green:
375
+
376
+ ```
377
+ ruby test/handler_test.rb
378
+ ```
379
+
380
+ - If `test/` is missing from the scaffold, create `test/handler_test.rb` yourself
381
+ (`mkdir -p test` then write it) — do NOT skip tests just because the file is absent.
382
+ - A handler that only returns a static string may keep the scaffold's hello test;
383
+ anything that fetches, transforms, caches, or computes MUST have a real test.
384
+
385
+ ### 6 — Confirm it loads
370
386
 
371
387
  Run `clacky ext verify` and confirm the new units resolve with no errors, then have the
372
388
  user reload the WebUI page. If verify reports problems, go to **Debug & verify**.
@@ -423,8 +439,9 @@ smallest change, re-run verify, repeat until clean — fix ONE issue at a time.
423
439
  - **`schema.unknown_contributes`** → a top-level key under `contributes:` is misspelled.
424
440
  **Do:** fix the spelling to one of `panels api skills agents channels patches hooks`.
425
441
  - **`schema.unknown_key`** → an unknown **top-level** key in `ext.yml`. **Do:** fix the
426
- spelling. Allowed top-level keys: `id name title description version origin author
427
- homepage license public license_required keywords contributes`.
442
+ spelling. Allowed top-level keys: `id name name_zh display_name display_name_zh title
443
+ description description_zh emoji version origin author homepage license public
444
+ license_required keywords contributes`.
428
445
  - **`schema.unknown_field`** → a unit has a field not allowed for its type. **Do:** delete
429
446
  or rename that field. Allowed fields per type (this is the authoritative list — do not
430
447
  invent others):
@@ -15,4 +15,4 @@ contributes:
15
15
  order: 1
16
16
  prompt: agents/general/system_prompt.md
17
17
  avatar: agents/general/avatar.png
18
- panels: [meeting/meeting]
18
+ panels: []
@@ -65,17 +65,10 @@ module Clacky
65
65
  existing = reload ? Clacky::ApiExtension.registry[ext_id] : nil
66
66
  existing&.reset_routes!
67
67
 
68
- if reload
69
- old_verbose = $VERBOSE
70
- $VERBOSE = nil
71
- begin
72
- load(handler_path)
73
- ensure
74
- $VERBOSE = old_verbose
75
- end
76
- else
77
- require(handler_path)
78
- end
68
+ # Wrapped load: each handler gets a fresh anonymous module, so two exts
69
+ # defining the same top-level class/constant names cannot clobber each
70
+ # other (and the `inherited` hook fires even on repeat loads).
71
+ load(handler_path, true)
79
72
 
80
73
  new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
81
74
  klass = new_subclasses.last || existing
@@ -16,7 +16,7 @@ module Clacky
16
16
  # optional URL fetch in `install`.
17
17
  module ExtensionPackager
18
18
  MANIFEST = "ext.yml"
19
- MAX_ZIP_SIZE = 50 * 1024 * 1024
19
+ MAX_ZIP_SIZE = 80 * 1024 * 1024
20
20
 
21
21
  # Network timeouts for the download step. `read_timeout` is an *idle*
22
22
  # timeout (resets on every chunk received), so a stalled connection with
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run from the container root:
4
+ # ruby test/handler_test.rb
5
+ require "minitest/autorun"
6
+ require "clacky"
7
+ require "json"
8
+
9
+ load File.expand_path("../api/handler.rb", __dir__)
10
+
11
+ class <%= const_prefix %>ExtTest < Minitest::Test
12
+ def test_root_route_returns_json
13
+ route = <%= const_prefix %>Ext.routes.find { |r| r.method == :get && r.pattern == "/" }
14
+ refute_nil route, "expected a GET / route"
15
+
16
+ handler = <%= const_prefix %>Ext.new(req: nil, res: nil, route: route, params: {}, http_server: nil)
17
+ halt = assert_raises(Clacky::ApiExtension::Halt) { handler.invoke }
18
+
19
+ assert_equal 200, halt.status
20
+ assert_equal "hello from <%= slug %>", JSON.parse(halt.payload)["message"]
21
+ end
22
+ end
@@ -16,7 +16,7 @@ module Clacky
16
16
  module ExtensionVerifier
17
17
  Issue = Struct.new(:ext, :unit, :level, :code, :message, :file, :hint, keyword_init: true)
18
18
 
19
- KNOWN_TOP_KEYS = %w[id name title description version origin author homepage license public license_required keywords contributes].freeze
19
+ KNOWN_TOP_KEYS = %w[id name name_zh display_name display_name_zh title description description_zh emoji version origin author homepage license public license_required keywords contributes].freeze
20
20
  KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks].freeze
21
21
 
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
@@ -49,17 +49,17 @@ module Clacky
49
49
 
50
50
  # @return [Hash, nil] the type=image model entry, or nil if not configured
51
51
  def image_model_entry
52
- @agent_config.find_model_by_type("image")
52
+ @agent_config.effective_media_entry("image")
53
53
  end
54
54
 
55
55
  # @return [Hash, nil] the type=video model entry, or nil if not configured
56
56
  def video_model_entry
57
- @agent_config.find_model_by_type("video")
57
+ @agent_config.effective_media_entry("video")
58
58
  end
59
59
 
60
60
  # @return [Hash, nil] the type=audio model entry, or nil if not configured
61
61
  def audio_model_entry
62
- @agent_config.find_model_by_type("audio")
62
+ @agent_config.effective_media_entry("audio")
63
63
  end
64
64
 
65
65
  def generate_image(prompt:, aspect_ratio: "landscape", output_dir: nil, **kwargs)