openclacky 1.3.11 → 1.4.1

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/lib/clacky/agent/session_serializer.rb +11 -4
  4. data/lib/clacky/agent/skill_manager.rb +1 -0
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +53 -3
  7. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +202 -3
  8. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +610 -60
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +182 -7
  10. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +5 -3
  11. data/lib/clacky/extension/packager.rb +21 -1
  12. data/lib/clacky/extension/verifier.rb +1 -1
  13. data/lib/clacky/media/generator.rb +41 -0
  14. data/lib/clacky/media/volcengine.rb +394 -0
  15. data/lib/clacky/platform_http_client.rb +9 -7
  16. data/lib/clacky/providers.rb +4 -4
  17. data/lib/clacky/server/http_server.rb +67 -22
  18. data/lib/clacky/tools/grep.rb +6 -1
  19. data/lib/clacky/tools/terminal.rb +24 -8
  20. data/lib/clacky/ui2/layout_manager.rb +4 -0
  21. data/lib/clacky/ui2/screen_buffer.rb +31 -3
  22. data/lib/clacky/utils/environment_detector.rb +16 -9
  23. data/lib/clacky/utils/file_processor.rb +30 -28
  24. data/lib/clacky/utils/model_pricing.rb +49 -0
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +229 -31
  27. data/lib/clacky/web/components/onboard.js +14 -4
  28. data/lib/clacky/web/features/backup/view.js +4 -3
  29. data/lib/clacky/web/features/extensions/store.js +2 -2
  30. data/lib/clacky/web/features/extensions/view.js +37 -7
  31. data/lib/clacky/web/features/mcp/view.js +5 -2
  32. data/lib/clacky/web/features/skills/view.js +61 -1
  33. data/lib/clacky/web/features/workspace/store.js +11 -0
  34. data/lib/clacky/web/features/workspace/view.js +10 -4
  35. data/lib/clacky/web/i18n.js +14 -2
  36. data/lib/clacky/web/index.html +29 -13
  37. data/lib/clacky/web/sessions.js +45 -8
  38. data/scripts/uninstall.sh +1 -1
  39. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eccc2d78824c13072991199a00edd5b848216334422ce37876cd639854abb4ab
4
- data.tar.gz: c29754fc48b5b9638168fa167c5335c32ccf5c64a3db94d3e64ac2638581857e
3
+ metadata.gz: 4c8311a2bd1bd4cd050fce3e806479c580afb4da476201e59a041c2293347b5f
4
+ data.tar.gz: 520ba1c8c1821aede0b4766be3f321b3c121a91d3d9fe9e5dada0034479a3d4e
5
5
  SHA512:
6
- metadata.gz: 6d2e6ef2dcf07a50779241a3ed5b384c2fbf34e4265208874567b12d1280e29f2fe79124286ab7b73251bfc836de01ebf9d8ad03f91ae12ad55ac4575128bfd3
7
- data.tar.gz: 77088322e7cecc8c0cc0f7138786263c3de14dff2d1b33e99cee763437918e226bd34f0ef318a12d4f7292bcad5a935a8ca2ca16c9a15d8d2bd7262eebee9ccd
6
+ metadata.gz: e2d171e71ae9eb9cbecfc79bbf6ac1f3977268baf5a4026a57caffc378501ca6ca1bad8426237bcd949564ac1986d1b5b4bfe4f549f108d35f3e216edee75bdc
7
+ data.tar.gz: 8ba4d9fe3a50aacd4a247aae6877f391c73297c4e63b8c428b99a09f9c2d9ab642104ff5d6370c04556b679b2e3d041785f1833727605b3205638e7c7ea01126
data/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ 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.4.1] - 2026-07-20
8
+
9
+ ### Added
10
+ - Extension README edit/validate flow and screenshot upload in ext-studio publish
11
+ - Kimi k3 and k2.7-code models added to Kimi preset
12
+ - New session split-button with improved styling
13
+ - Rename-on-name-conflict flow in ext-studio publish
14
+ - Skip OpenClacky AI Keys card for branded onboarding
15
+
16
+ ### Improved
17
+ - Secondary API host added with reduced failover latency
18
+ - Extensions default sort changed to most-installed (downloads first)
19
+
20
+ ### Fixed
21
+ - WSL file panel "Copy Path" now copies Windows-accessible UNC path via `wslpath` (C-5693)
22
+ - Edit button hidden on compressed user messages archived to chunk (C-5723)
23
+ - Message-edit Enter key blocked during IME composition (C-5742)
24
+ - Markdown images with `file://~/` tilde paths now render correctly (C-5747)
25
+ - Local `file://` path resolution unified for Windows drive letters (C-5746, C-5708)
26
+ - Brand extension installs under its real ID, not as unpacked (C-5735)
27
+ - Backup success status no longer stays displayed forever (C-5711)
28
+ - `grep` tool defaults to recursive behavior
29
+ - `display_name` fields included in installed extensions API response
30
+ - Published list reloads after setup entry point and publish
31
+ - Unpublish button size matches iterate button in ext-studio
32
+ - `device_token` correctly passed in screenshot upload multipart fields
33
+ - MCP tool list rendering fixed and display styling improved
34
+ - Uninstall script now removes all openclacky gem versions
35
+
36
+ ## [1.4.0] - 2026-07-14
37
+
38
+ ### Added
39
+ - Volcengine Ark Seedance video generation provider (async submit + poll)
40
+ - Entry points config and extension info editing in ext-studio
41
+ - Search box in skills panel tab bar
42
+ - Default letter gradient icon for extensions without a custom icon
43
+ - Skeleton loading for published extensions list in ext-studio
44
+
45
+ ### Improved
46
+ - Extensions market renders `display_name` instead of raw slug
47
+ - ext-studio extension picker and cards show display name; unit tags aggregated by type
48
+ - Session list "Today / Yesterday" labels localized
49
+
50
+ ### Fixed
51
+ - Terminal Pass 1 echo-strip no longer swallows real output (C-5701)
52
+ - OS metadata files (`.DS_Store`, `__MACOSX`) excluded from packed extension zips (C-5728)
53
+ - TUI exit now restores original terminal settings via `stty` (C-5724)
54
+ - Local image cache busted with mtime version param when file is overwritten (C-5703)
55
+ - Generated media usage correctly associated with sessions (C-5739)
56
+ - Mobile header search action alignment (C-5733)
57
+ - Session search shortcut hint adapted for web (C-5734)
58
+ - Memory card expand button height aligned with other action buttons
59
+ - Billing model filter select styling improved
60
+ - Modal overlay click-to-close disabled (prevents accidental dismissal)
61
+ - Version number / date separator added in extension version history
62
+ - All users can now publish extensions (permission gate removed)
63
+ - Invalid extension version format shows inline error in publish modal
64
+ - Brand update button hover color uses correct CSS variable
65
+
7
66
  ## [1.3.11] - 2026-07-12
8
67
 
9
68
  ### Added
@@ -257,8 +257,10 @@ module Clacky
257
257
  end
258
258
 
259
259
  if is_real_user_msg
260
- # Start a new round at each real user message
261
- current_round = { user_msg: msg, events: [] }
260
+ # Start a new round at each real user message.
261
+ # editable: true this message still lives in the active in-memory
262
+ # @history, so truncate_from_created_at can locate and truncate it.
263
+ current_round = { user_msg: msg, events: [], editable: true }
262
264
  rounds << current_round
263
265
  elsif current_round
264
266
  current_round[:events] << msg
@@ -325,7 +327,8 @@ module Clacky
325
327
  preview_path: f[:preview_path] || f["preview_path"] }
326
328
  }
327
329
  all_files = image_files + disk_files
328
- ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files)
330
+ ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
331
+ editable: round[:editable] != false)
329
332
 
330
333
  round[:events].each do |ev|
331
334
  # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
@@ -480,7 +483,11 @@ module Clacky
480
483
  created_at: synthetic_ts,
481
484
  _from_chunk: true
482
485
  },
483
- events: []
486
+ events: [],
487
+ # editable: false — this message was archived into a chunk MD and no
488
+ # longer exists in the active in-memory @history, so it cannot be
489
+ # truncated/edited (truncate_from_created_at would silently no-op).
490
+ editable: false
484
491
  }
485
492
  rounds << current_round
486
493
  elsif current_round
@@ -452,6 +452,7 @@ module Clacky
452
452
  # @return [Hash<String, Proc>]
453
453
  def build_template_context
454
454
  {
455
+ "session_id" => -> { @session_id },
455
456
  "memories_meta" => -> { load_memories_meta },
456
457
  "all_skills_meta" => -> { load_all_skills_meta }
457
458
  }
data/lib/clacky/agent.rb CHANGED
@@ -1862,11 +1862,10 @@ module Clacky
1862
1862
  files = []
1863
1863
  content.scan(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1864
1864
  inline = $1 == "!"
1865
- # URL-decode percent-encoded characters (e.g. Chinese filenames encoded by AI)
1866
- raw_path = CGI.unescape($3)
1867
- name = File.basename(raw_path)
1868
- path = File.expand_path(raw_path)
1869
- Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} expanded=#{path.inspect} exist=#{File.exist?(path)}")
1865
+ # Resolve the AI-emitted path: decode, WSL drive-letter normalize, expand.
1866
+ path = Clacky::Utils::EnvironmentDetector.resolve_local_path($3)
1867
+ name = File.basename(path)
1868
+ Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} resolved=#{path.inspect} exist=#{File.exist?(path)}")
1870
1869
  files << { name: name, path: path, inline: inline }
1871
1870
  end
1872
1871
  { text: content, files: files }
@@ -622,7 +622,7 @@ module Clacky
622
622
  #
623
623
  # Returns { success: true, extension: {...} } or
624
624
  # { success: false, error: "...", already_exists: Boolean }.
625
- def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, origin: 'marketplace')
625
+ def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace')
626
626
  identity = Clacky::Identity.load
627
627
  return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
628
628
 
@@ -636,6 +636,7 @@ module Clacky
636
636
  fields["origin"] = origin.to_s if origin
637
637
  fields["status"] = status.to_s if status
638
638
  fields["changelog"] = changelog.to_s if changelog
639
+ fields["readme"] = readme.to_s if readme
639
640
 
640
641
  body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data)
641
642
 
@@ -700,6 +701,55 @@ module Clacky
700
701
  end
701
702
  end
702
703
 
704
+ # Update an extension's readme text without publishing a new version.
705
+ # Uses PATCH /api/v1/client/extensions/:id.
706
+ # Returns { success:, extension:, error: }
707
+ def update_extension_readme!(ext_id, readme)
708
+ identity = Clacky::Identity.load
709
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
710
+
711
+ path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
712
+ payload = { "device_token" => identity.device_token, "readme" => readme.to_s }
713
+ result = platform_client.patch(path, payload)
714
+
715
+ if result[:success]
716
+ { success: true, extension: result[:data]["extension"] }
717
+ else
718
+ body = result[:data] || {}
719
+ { success: false, error: result[:error] || body["code"] || "Update readme failed" }
720
+ end
721
+ rescue StandardError => e
722
+ { success: false, error: "Network error: #{e.message}" }
723
+ end
724
+
725
+ # Upload any file to the platform's model-agnostic upload endpoint.
726
+ # Returns an orphan blob URL — no extension record required.
727
+ # Uses POST /api/v1/client/uploads.
728
+ # Parameters:
729
+ # data (bytes) - raw file bytes
730
+ # filename (string) - filename including extension
731
+ # content_type (string) - MIME type
732
+ # Returns { success:, url:, filename:, blob_key:, error: }
733
+ def upload_file!(data, filename:, content_type: "image/png")
734
+ identity = Clacky::Identity.load
735
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
736
+
737
+ path = "/api/v1/client/uploads"
738
+ fields = { "device_token" => identity.device_token }
739
+ body_bytes, boundary = build_multipart(fields, "file", filename, data, content_type: content_type)
740
+ result = platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60)
741
+
742
+ if result[:success]
743
+ d = result[:data] || {}
744
+ { success: true, url: d["url"], filename: d["filename"], blob_key: d["blob_key"] }
745
+ else
746
+ body = result[:data] || {}
747
+ { success: false, error: result[:error] || body["code"] || "File upload failed" }
748
+ end
749
+ rescue StandardError => e
750
+ { success: false, error: "Network error: #{e.message}" }
751
+ end
752
+
703
753
  # Search the public extension marketplace. Anonymous — no license required.
704
754
  # Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
705
755
  def search_extensions!(query: nil, sort: nil)
@@ -1579,7 +1629,7 @@ module Clacky
1579
1629
 
1580
1630
  # Assemble a binary multipart/form-data body: text fields + one file part.
1581
1631
  # Kept binary-safe so null bytes in the ZIP survive. Returns [body, boundary].
1582
- private def build_multipart(fields, file_field, filename, file_bytes)
1632
+ private def build_multipart(fields, file_field, filename, file_bytes, content_type: "application/zip")
1583
1633
  boundary = "----ClackyMultipart#{SecureRandom.hex(8)}"
1584
1634
  crlf = "\r\n"
1585
1635
  parts = []
@@ -1591,7 +1641,7 @@ module Clacky
1591
1641
  end
1592
1642
  parts << "--#{boundary}#{crlf}"
1593
1643
  parts << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"#{crlf}"
1594
- parts << "Content-Type: application/zip#{crlf}#{crlf}"
1644
+ parts << "Content-Type: #{content_type}#{crlf}#{crlf}"
1595
1645
  parts << file_bytes.b
1596
1646
  parts << "#{crlf}--#{boundary}--#{crlf}"
1597
1647
  [parts.map(&:b).join, boundary]
@@ -79,11 +79,24 @@ class ExtStudioExt < Clacky::ApiExtension
79
79
  res = Clacky::ExtensionPackager.pack(ext_id, out_dir: tmp)
80
80
  zip_data = File.binread(res.path)
81
81
 
82
+ # Read readme from local README.md if present, so first-publish carries it.
83
+ local_readme = nil
84
+ result_load = Clacky::ExtensionLoader.load_all(force: false)
85
+ container = Array(result_load.containers).find { |id, _| id == res.ext_id }&.last
86
+ if container
87
+ readme_path = File.join(container[:dir], "README.md")
88
+ if File.exist?(readme_path)
89
+ local_readme = File.read(readme_path, encoding: "utf-8").strip
90
+ local_readme = nil if local_readme.empty?
91
+ end
92
+ end
93
+
82
94
  result = brand.upload_extension!(
83
95
  res.ext_id, zip_data,
84
96
  force: json_body["force"] == true,
85
97
  status: presence(json_body["status"]),
86
- changelog: presence(json_body["changelog"])
98
+ changelog: presence(json_body["changelog"]),
99
+ readme: local_readme
87
100
  )
88
101
 
89
102
  if result[:success]
@@ -91,8 +104,15 @@ class ExtStudioExt < Clacky::ApiExtension
91
104
  ver = (ext["latest_version"] || {})["version"]
92
105
  json(ok: true, ext_id: res.ext_id, version: ver, status: ext["status"])
93
106
  elsif result[:already_exists]
107
+ # The same user has already published this extension — they need to use
108
+ # force: true to publish a new version.
94
109
  json(ok: false, already_exists: true,
95
110
  error: "#{res.ext_id} already published. Publish a new version with force.")
111
+ elsif result[:error].to_s.include?("already taken")
112
+ # Another user has claimed this name globally — the creator must rename
113
+ # their extension before publishing.
114
+ json(ok: false, name_taken: true,
115
+ error: result[:error])
96
116
  else
97
117
  error!(result[:error] || "publish failed", status: 502)
98
118
  end
@@ -111,7 +131,7 @@ class ExtStudioExt < Clacky::ApiExtension
111
131
  exts = Array(result[:extensions]).map do |ext|
112
132
  {
113
133
  id: ext["name"] || ext["slug"] || ext["id"],
114
- name: ext["name"],
134
+ name: ext["display_name"] || ext["name"],
115
135
  version: (ext["latest_version"] || {})["version"] || ext["version"],
116
136
  status: ext["status"],
117
137
  units: ext["units"] || {}
@@ -149,6 +169,122 @@ class ExtStudioExt < Clacky::ApiExtension
149
169
  json(ok: true, ext_id: ext_id)
150
170
  end
151
171
 
172
+ # PATCH /api/ext/ext-studio/readme
173
+ # body: { ext_id, readme }
174
+ # Two modes:
175
+ # - Extension already published → save to platform via API
176
+ # - Extension not yet published → write readme field to local ext.yml
177
+ patch "/readme" do
178
+ ext_id = require_ext_id!
179
+ readme = json_body["readme"].to_s
180
+
181
+ # Check whether this extension has been published on the platform.
182
+ brand = Clacky::BrandConfig.load
183
+ identity = Clacky::Identity.load
184
+ published = false
185
+
186
+ if identity.bound?
187
+ my_result = brand.fetch_my_extensions!
188
+ if my_result[:success]
189
+ published = Array(my_result[:extensions]).any? { |e| (e["name"] || e["slug"]) == ext_id }
190
+ end
191
+ end
192
+
193
+ if published
194
+ # Already on the platform — update readme there.
195
+ result = brand.update_extension_readme!(ext_id, readme)
196
+ error!(result[:error] || "readme update failed", status: 502) unless result[:success]
197
+ else
198
+ # Not yet published — persist readme to local README.md so it's included on first publish.
199
+ result = Clacky::ExtensionLoader.load_all(force: false)
200
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
201
+ error!("extension not found: #{ext_id}", status: 404) unless container
202
+
203
+ readme_path = File.join(container[:dir], "README.md")
204
+ if readme.strip.empty?
205
+ File.delete(readme_path) if File.exist?(readme_path)
206
+ else
207
+ File.write(readme_path, readme, encoding: "utf-8")
208
+ end
209
+ end
210
+
211
+ json(ok: true, ext_id: ext_id, saved_to: published ? "platform" : "local")
212
+ end
213
+
214
+ # POST /api/ext/ext-studio/screenshot
215
+ # Multipart body: { ext_id (field), file (image file) }
216
+ # Uploads a screenshot image via the platform's model-agnostic upload API
217
+ # and returns the CDN URL. Does NOT require the extension to be published yet.
218
+ post "/screenshot" do
219
+ q = req.query
220
+ ext_id = q["ext_id"].to_s.strip
221
+ error!("ext_id required", status: 422) if ext_id.empty?
222
+
223
+ uploaded = q["file"]
224
+ error!("missing file", status: 422) unless uploaded
225
+
226
+ # WEBrick::HTTPUtils::FormData inherits from String — .to_s is the file bytes,
227
+ # .filename is the original filename, .["content-type"] gives the MIME type.
228
+ filename = uploaded.respond_to?(:filename) ? (uploaded.filename || "screenshot.png") : "screenshot.png"
229
+ content_type = (uploaded.respond_to?(:[]) ? uploaded["content-type"].to_s : "")
230
+ content_type = "image/png" if content_type.empty?
231
+ data = uploaded.to_s
232
+ error!("missing file", status: 422) if data.empty?
233
+
234
+ unless Clacky::Identity.load.bound?
235
+ error!("device not bound to a platform account", status: 428, needs_binding: true)
236
+ end
237
+
238
+ result = Clacky::BrandConfig.load.upload_file!(
239
+ data,
240
+ filename: filename,
241
+ content_type: content_type
242
+ )
243
+ error!(result[:error] || "screenshot upload failed", status: 502) unless result[:success]
244
+ json(ok: true, url: result[:url], filename: result[:filename])
245
+ end
246
+
247
+ # POST /api/ext/ext-studio/set_meta
248
+ # body: { ext_id, name?, description?, entry_points? }
249
+ # entry_points: [{ unit_id, slot }] — stored under contributes.panels[id].entry_points
250
+ # Writes display metadata back to the local ext.yml without touching other fields.
251
+ post "/set_meta" do
252
+ ext_id = require_ext_id!
253
+
254
+ result = Clacky::ExtensionLoader.load_all(force: false)
255
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
256
+ error!("extension not found: #{ext_id}", status: 404) unless container
257
+
258
+ yml_path = File.join(container[:dir], "ext.yml")
259
+ error!("ext.yml not found", status: 404) unless File.exist?(yml_path)
260
+
261
+ manifest = Psych.safe_load(File.read(yml_path), permitted_classes: [], aliases: true) || {}
262
+
263
+ manifest["name"] = presence(json_body["name"]) if json_body.key?("name")
264
+ manifest["description"] = presence(json_body["description"]) if json_body.key?("description")
265
+
266
+ if json_body.key?("entry_points")
267
+ eps = json_body["entry_points"]
268
+ by_panel = Hash.new { |h, k| h[k] = [] }
269
+ Array(eps).each { |ep| by_panel[ep["unit_id"].to_s] << { "slot" => ep["slot"] } if ep["slot"] }
270
+ panels = Array((manifest["contributes"] || {})["panels"])
271
+ panels.each do |panel|
272
+ pid = panel["id"].to_s
273
+ slots = by_panel[pid]
274
+ if slots.any?
275
+ panel["entry_points"] = slots
276
+ else
277
+ panel.delete("entry_points")
278
+ end
279
+ end
280
+ end
281
+
282
+ File.write(yml_path, Psych.dump(manifest))
283
+ Clacky::ExtensionLoader.load_all(force: true)
284
+
285
+ json(ok: true, ext_id: ext_id)
286
+ end
287
+
152
288
  # POST /api/ext/ext-studio/set_version
153
289
  # body: { ext_id, version }
154
290
  # Writes the new version string back to the local ext.yml.
@@ -176,6 +312,50 @@ class ExtStudioExt < Clacky::ApiExtension
176
312
  end
177
313
 
178
314
 
315
+ # POST /api/ext/ext-studio/set_id
316
+ # body: { ext_id, new_id }
317
+ # Renames an extension: updates the `id` field in ext.yml and renames the
318
+ # local container directory. Used when the user wants to republish under a
319
+ # different name (e.g. after a name-taken conflict on the marketplace).
320
+ post "/set_id" do
321
+ ext_id = require_ext_id!
322
+ new_id = presence(json_body["new_id"])
323
+ error!("new_id required", status: 422) unless new_id
324
+
325
+ slug_format = /\A[a-z0-9][a-z0-9\-]*[a-z0-9]\z/
326
+ unless new_id.length <= 64 && new_id.match?(slug_format)
327
+ error!("new_id must be lowercase letters, digits and hyphens (e.g. my-ext)", status: 422)
328
+ end
329
+
330
+ result = Clacky::ExtensionLoader.load_all(force: false)
331
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
332
+ error!("extension not found: #{ext_id}", status: 404) unless container
333
+
334
+ old_dir = container[:dir]
335
+ new_dir = File.join(File.dirname(old_dir), new_id)
336
+ error!("An extension named '#{new_id}' already exists locally", status: 409) if File.exist?(new_dir)
337
+
338
+ yml_path = File.join(old_dir, "ext.yml")
339
+ error!("ext.yml not found", status: 404) unless File.exist?(yml_path)
340
+
341
+ # Update the id field in ext.yml, preserving all other content.
342
+ content = File.read(yml_path)
343
+ if content =~ /^id:/
344
+ content = content.sub(/^id:.*$/, "id: #{new_id}")
345
+ else
346
+ content = "id: #{new_id}\n" + content
347
+ end
348
+ File.write(yml_path, content)
349
+
350
+ # Rename the container directory.
351
+ File.rename(old_dir, new_dir)
352
+
353
+ # Reload so the new id is visible immediately.
354
+ Clacky::ExtensionLoader.load_all(force: true)
355
+
356
+ json(ok: true, old_id: ext_id, new_id: new_id)
357
+ end
358
+
179
359
  # body: { idea? }
180
360
  # Spawns a session bound to the ext-developer agent, optionally seeded with
181
361
  # the user's idea as the first task — the "let AI build it for me" entry.
@@ -250,6 +430,14 @@ class ExtStudioExt < Clacky::ApiExtension
250
430
  raw = container[:raw] || {}
251
431
  ext_issues = issues.select { |i| i.ext == ext_id }
252
432
  dir = container[:dir]
433
+ ext_units = result.units.select { |u| u.ext_id == ext_id }
434
+ panels = Array((raw["contributes"] || {})["panels"])
435
+ entry_points = panels.flat_map do |p|
436
+ Array(p["entry_points"]).map { |ep| { panel_id: p["id"], slot: ep["slot"] } }
437
+ end
438
+ readme_path = File.join(dir, "README.md")
439
+ readme_content = File.exist?(readme_path) ? File.read(readme_path, encoding: "utf-8").strip : nil
440
+ readme_content = nil if readme_content&.empty?
253
441
  {
254
442
  id: ext_id,
255
443
  name: raw["name"] || ext_id,
@@ -259,7 +447,11 @@ class ExtStudioExt < Clacky::ApiExtension
259
447
  layer: container[:layer].to_s,
260
448
  dir: dir,
261
449
  mtime: File.mtime(File.join(dir, "ext.yml")).to_i,
262
- units: result.units.select { |u| u.ext_id == ext_id }.map { |u| serialize_unit(u) },
450
+ units: ext_units.map { |u| serialize_unit(u) },
451
+ unit_counts: unit_counts(ext_units),
452
+ contributes: raw["contributes"] || {},
453
+ entry_points: entry_points,
454
+ readme: readme_content,
263
455
  error_count: ext_issues.count { |i| i.level == :error },
264
456
  warning_count: ext_issues.count { |i| i.level == :warning }
265
457
  }
@@ -269,6 +461,13 @@ class ExtStudioExt < Clacky::ApiExtension
269
461
  { kind: unit.kind.to_s, id: unit.id, layer: unit.layer.to_s }
270
462
  end
271
463
 
464
+ private def unit_counts(units)
465
+ units.each_with_object({}) do |unit, counts|
466
+ kind = unit.kind.to_s
467
+ counts[kind] = (counts[kind] || 0) + 1
468
+ end
469
+ end
470
+
272
471
  private def serialize_issue(issue)
273
472
  {
274
473
  ext: issue.ext,