openclacky 1.4.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29e2af05d27f902fc645a1399eb16d16d41681ec03312ee861fdd594292368c2
4
- data.tar.gz: bbb62ec065f07867a2085b6dc0f81d5702c34cf0ff4e10e0b88513daa5fb4541
3
+ metadata.gz: 4c8311a2bd1bd4cd050fce3e806479c580afb4da476201e59a041c2293347b5f
4
+ data.tar.gz: 520ba1c8c1821aede0b4766be3f321b3c121a91d3d9fe9e5dada0034479a3d4e
5
5
  SHA512:
6
- metadata.gz: bf7f4ad60821686404ee3b43607e59c51cb0d38420171b94eaea2c6d2877d71c03052f9147b56f61aeb5fc01155127d4aab15a8f0b6ecc0b73d6b294fb0bbe3c
7
- data.tar.gz: ba3a05a10e80fd8b48f10f0778388c5417ce8fa79e0f3936cb6ef734117609bc85f56868227e174675e4132d0c619a8b385619c7aaf62e75039fc3f4895d15f8
6
+ metadata.gz: e2d171e71ae9eb9cbecfc79bbf6ac1f3977268baf5a4026a57caffc378501ca6ca1bad8426237bcd949564ac1986d1b5b4bfe4f549f108d35f3e216edee75bdc
7
+ data.tar.gz: 8ba4d9fe3a50aacd4a247aae6877f391c73297c4e63b8c428b99a09f9c2d9ab642104ff5d6370c04556b679b2e3d041785f1833727605b3205638e7c7ea01126
data/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ 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
+
7
36
  ## [1.4.0] - 2026-07-14
8
37
 
9
38
  ### 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
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
@@ -149,6 +169,81 @@ 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
+
152
247
  # POST /api/ext/ext-studio/set_meta
153
248
  # body: { ext_id, name?, description?, entry_points? }
154
249
  # entry_points: [{ unit_id, slot }] — stored under contributes.panels[id].entry_points
@@ -217,6 +312,50 @@ class ExtStudioExt < Clacky::ApiExtension
217
312
  end
218
313
 
219
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
+
220
359
  # body: { idea? }
221
360
  # Spawns a session bound to the ext-developer agent, optionally seeded with
222
361
  # the user's idea as the first task — the "let AI build it for me" entry.
@@ -296,6 +435,9 @@ class ExtStudioExt < Clacky::ApiExtension
296
435
  entry_points = panels.flat_map do |p|
297
436
  Array(p["entry_points"]).map { |ep| { panel_id: p["id"], slot: ep["slot"] } }
298
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?
299
441
  {
300
442
  id: ext_id,
301
443
  name: raw["name"] || ext_id,
@@ -309,6 +451,7 @@ class ExtStudioExt < Clacky::ApiExtension
309
451
  unit_counts: unit_counts(ext_units),
310
452
  contributes: raw["contributes"] || {},
311
453
  entry_points: entry_points,
454
+ readme: readme_content,
312
455
  error_count: ext_issues.count { |i| i.level == :error },
313
456
  warning_count: ext_issues.count { |i| i.level == :warning }
314
457
  }