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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/lib/clacky/agent/session_serializer.rb +11 -4
- data/lib/clacky/agent/skill_manager.rb +1 -0
- data/lib/clacky/agent.rb +4 -5
- data/lib/clacky/brand_config.rb +53 -3
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +202 -3
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +610 -60
- data/lib/clacky/default_skills/media-gen/SKILL.md +182 -7
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +5 -3
- data/lib/clacky/extension/packager.rb +21 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/generator.rb +41 -0
- data/lib/clacky/media/volcengine.rb +394 -0
- data/lib/clacky/platform_http_client.rb +9 -7
- data/lib/clacky/providers.rb +4 -4
- data/lib/clacky/server/http_server.rb +67 -22
- data/lib/clacky/tools/grep.rb +6 -1
- data/lib/clacky/tools/terminal.rb +24 -8
- data/lib/clacky/ui2/layout_manager.rb +4 -0
- data/lib/clacky/ui2/screen_buffer.rb +31 -3
- data/lib/clacky/utils/environment_detector.rb +16 -9
- data/lib/clacky/utils/file_processor.rb +30 -28
- data/lib/clacky/utils/model_pricing.rb +49 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +229 -31
- data/lib/clacky/web/components/onboard.js +14 -4
- data/lib/clacky/web/features/backup/view.js +4 -3
- data/lib/clacky/web/features/extensions/store.js +2 -2
- data/lib/clacky/web/features/extensions/view.js +37 -7
- data/lib/clacky/web/features/mcp/view.js +5 -2
- data/lib/clacky/web/features/skills/view.js +61 -1
- data/lib/clacky/web/features/workspace/store.js +11 -0
- data/lib/clacky/web/features/workspace/view.js +10 -4
- data/lib/clacky/web/i18n.js +14 -2
- data/lib/clacky/web/index.html +29 -13
- data/lib/clacky/web/sessions.js +45 -8
- data/scripts/uninstall.sh +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c8311a2bd1bd4cd050fce3e806479c580afb4da476201e59a041c2293347b5f
|
|
4
|
+
data.tar.gz: 520ba1c8c1821aede0b4766be3f321b3c121a91d3d9fe9e5dada0034479a3d4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
1866
|
-
|
|
1867
|
-
name = File.basename(
|
|
1868
|
-
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 }
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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,
|