openclacky 0.8.3 → 0.8.4
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 +21 -0
- data/lib/clacky/agent/skill_manager.rb +19 -1
- data/lib/clacky/agent.rb +1 -1
- data/lib/clacky/brand_config.rb +111 -24
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/server/http_server.rb +5 -2
- data/lib/clacky/skill_loader.rb +22 -18
- data/lib/clacky/ui2/layout_manager.rb +5 -0
- data/lib/clacky/ui2/screen_buffer.rb +24 -7
- data/lib/clacky/ui2/ui_controller.rb +56 -19
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +887 -346
- data/lib/clacky/web/app.js +30 -11
- data/lib/clacky/web/index.html +108 -30
- data/lib/clacky/web/onboard.js +92 -16
- data/lib/clacky/web/settings.js +115 -24
- data/lib/clacky/web/skills.js +7 -3
- data/lib/clacky/web/tasks.js +34 -8
- data/lib/clacky/web/theme.js +67 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9d125c88f92f71da5a1e3699c8c466886ef7077e8578b7abbf2a6e3c9c966c2
|
|
4
|
+
data.tar.gz: 852e04561b92ceaa5af0359996533bbfaf63a14559e528e7f58b48af6b85206d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfe068e23d38dd526ef634b940b6f4b4cb02297266b371d64294acd82ca40fac428fef16fec80e726907e1e4170f7d02b3515f723ac5da07ad888b32f621604d
|
|
7
|
+
data.tar.gz: 8ebdf651e254793b5e18f933167ee80abce8fea3405fe969e789adf85f3689c819eebe691f5783263c9581bf82dbaddeea514e5959d079370b28d843e2e022ab
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.4] - 2026-03-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **License verify & download skills**: brand distribution can now push skills to clients via license heartbeat — skills are downloaded and installed automatically on activation and heartbeat
|
|
14
|
+
- **Web UI theme system**: dark/light mode toggle with full CSS variable theming, persistent across sessions; all UI components (sessions, tasks, settings) updated to use theme variables
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
- **Skill loader default agent**: `SkillLoader` now applies a sensible default agent value, simplifying skill configuration for common cases
|
|
18
|
+
- **Web UI modernized**: redesigned session and task lists with active indicators, improved hover effects, and inline SVG icons (removed Lucide CDN dependency)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **UTF-8 input handling**: invalid UTF-8 bytes in terminal UI input and output are now scrubbed cleanly instead of raising encoding errors
|
|
22
|
+
- **UI thread deadlock**: progress and fullscreen threads now stop gracefully on shutdown, preventing rare deadlocks
|
|
23
|
+
- **IME composition input**: slash `/` command button is now disabled during IME composition (e.g. Chinese input), preventing double-submit on Enter
|
|
24
|
+
- **CLI `clear` command**: fixed a regression that broke the `clacky clear` command
|
|
25
|
+
|
|
26
|
+
### More
|
|
27
|
+
- Refactor: rename `set_skill_loader` to `set_agent` in `UiController` for clarity
|
|
28
|
+
- Chore: update onboard skill default AI identity wording
|
|
29
|
+
- Fix: append user shim after skill injection for Claude API compatibility
|
|
30
|
+
|
|
10
31
|
## [0.8.3] - 2026-03-09
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -158,7 +158,18 @@ module Clacky
|
|
|
158
158
|
# Expand skill content (substitutes $ARGUMENTS if present)
|
|
159
159
|
expanded_content = skill.process_content(arguments, template_context: build_template_context)
|
|
160
160
|
|
|
161
|
-
# Inject as a synthetic assistant message so the LLM treats it as already read
|
|
161
|
+
# Inject as a synthetic assistant message so the LLM treats it as already read.
|
|
162
|
+
#
|
|
163
|
+
# Then immediately append a synthetic user message to keep the conversation
|
|
164
|
+
# sequence valid for strict providers like Claude (Anthropic API), which require
|
|
165
|
+
# alternating user/assistant turns. Without this extra user message the next
|
|
166
|
+
# real LLM call would find an assistant message at the tail of the history,
|
|
167
|
+
# causing a 400 "invalid message order" error.
|
|
168
|
+
#
|
|
169
|
+
# Final message order:
|
|
170
|
+
# user: "/skill-name [args]" ← real user input
|
|
171
|
+
# assistant: "[expanded skill content]" ← system_injected (skill instructions)
|
|
172
|
+
# user: "[SYSTEM] Please proceed..." ← system_injected (Claude compat shim)
|
|
162
173
|
@messages << {
|
|
163
174
|
role: "assistant",
|
|
164
175
|
content: expanded_content,
|
|
@@ -166,6 +177,13 @@ module Clacky
|
|
|
166
177
|
system_injected: true
|
|
167
178
|
}
|
|
168
179
|
|
|
180
|
+
@messages << {
|
|
181
|
+
role: "user",
|
|
182
|
+
content: "[SYSTEM] The skill instructions above have been loaded. Please proceed to execute the task now.",
|
|
183
|
+
task_id: task_id,
|
|
184
|
+
system_injected: true
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
@ui&.log("Injected skill content for /#{skill.identifier}", level: :info)
|
|
170
188
|
end
|
|
171
189
|
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -76,7 +76,7 @@ module Clacky
|
|
|
76
76
|
@brand_config = Clacky::BrandConfig.load
|
|
77
77
|
|
|
78
78
|
# Skill loader for skill management (brand_config enables encrypted skill loading)
|
|
79
|
-
@skill_loader = SkillLoader.new(@working_dir, brand_config: @brand_config)
|
|
79
|
+
@skill_loader = SkillLoader.new(working_dir: @working_dir, brand_config: @brand_config)
|
|
80
80
|
|
|
81
81
|
# Background sync: compare remote skill versions and download updates quietly.
|
|
82
82
|
# Runs in a daemon thread so Agent startup is never blocked.
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Clacky
|
|
|
18
18
|
#
|
|
19
19
|
# brand.yml structure:
|
|
20
20
|
# brand_name: "JohnAI"
|
|
21
|
+
# distribution_name: "JohnAI Distribution"
|
|
22
|
+
# product_name: "JohnAI Pro"
|
|
23
|
+
# logo_url: "https://example.com/logo.png"
|
|
24
|
+
# support_contact: "support@johnai.com"
|
|
21
25
|
# license_key: "0000002A-00000007-DEADBEEF-CAFEBABE-A1B2C3D4"
|
|
22
26
|
# license_activated_at: "2025-03-01T00:00:00Z"
|
|
23
27
|
# license_expires_at: "2026-03-01T00:00:00Z"
|
|
@@ -38,11 +42,16 @@ module Clacky
|
|
|
38
42
|
|
|
39
43
|
attr_reader :brand_name, :license_key, :license_activated_at,
|
|
40
44
|
:license_expires_at, :license_last_heartbeat, :device_id,
|
|
41
|
-
:brand_command
|
|
45
|
+
:brand_command, :distribution_name, :product_name,
|
|
46
|
+
:logo_url, :support_contact
|
|
42
47
|
|
|
43
48
|
def initialize(attrs = {})
|
|
44
49
|
@brand_name = attrs["brand_name"]
|
|
45
50
|
@brand_command = attrs["brand_command"]
|
|
51
|
+
@distribution_name = attrs["distribution_name"]
|
|
52
|
+
@product_name = attrs["product_name"]
|
|
53
|
+
@logo_url = attrs["logo_url"]
|
|
54
|
+
@support_contact = attrs["support_contact"]
|
|
46
55
|
@license_key = attrs["license_key"]
|
|
47
56
|
@license_activated_at = parse_time(attrs["license_activated_at"])
|
|
48
57
|
@license_expires_at = parse_time(attrs["license_expires_at"])
|
|
@@ -131,6 +140,7 @@ module Clacky
|
|
|
131
140
|
@license_expires_at = parse_time(data["expires_at"])
|
|
132
141
|
# Use brand_name returned by the API; fall back to any existing value
|
|
133
142
|
@brand_name = data["brand_name"] if data["brand_name"] && !data["brand_name"].to_s.strip.empty?
|
|
143
|
+
apply_distribution(data["distribution"])
|
|
134
144
|
save
|
|
135
145
|
{ success: true, message: "License activated successfully!", brand_name: @brand_name, data: data }
|
|
136
146
|
else
|
|
@@ -175,12 +185,14 @@ module Clacky
|
|
|
175
185
|
return { success: false, message: "License not activated" } unless activated?
|
|
176
186
|
|
|
177
187
|
user_id = parse_user_id_from_key(@license_key)
|
|
188
|
+
key_hash = Digest::SHA256.hexdigest(@license_key)
|
|
178
189
|
ts = Time.now.utc.to_i.to_s
|
|
179
190
|
nonce = SecureRandom.hex(16)
|
|
180
191
|
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
181
192
|
signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
|
|
182
193
|
|
|
183
194
|
payload = {
|
|
195
|
+
key_hash: key_hash,
|
|
184
196
|
user_id: user_id.to_s,
|
|
185
197
|
device_id: @device_id,
|
|
186
198
|
timestamp: ts,
|
|
@@ -193,6 +205,7 @@ module Clacky
|
|
|
193
205
|
if response[:success]
|
|
194
206
|
@license_last_heartbeat = Time.now.utc
|
|
195
207
|
@license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
|
|
208
|
+
apply_distribution(response[:data]["distribution"])
|
|
196
209
|
save
|
|
197
210
|
{ success: true, message: "Heartbeat OK" }
|
|
198
211
|
else
|
|
@@ -206,12 +219,14 @@ module Clacky
|
|
|
206
219
|
return { success: false, error: "License not activated", skills: [] } unless activated?
|
|
207
220
|
|
|
208
221
|
user_id = parse_user_id_from_key(@license_key)
|
|
222
|
+
key_hash = Digest::SHA256.hexdigest(@license_key)
|
|
209
223
|
ts = Time.now.utc.to_i.to_s
|
|
210
224
|
nonce = SecureRandom.hex(16)
|
|
211
225
|
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
212
226
|
signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
|
|
213
227
|
|
|
214
228
|
payload = {
|
|
229
|
+
key_hash: key_hash,
|
|
215
230
|
user_id: user_id.to_s,
|
|
216
231
|
device_id: @device_id,
|
|
217
232
|
timestamp: ts,
|
|
@@ -252,13 +267,9 @@ module Clacky
|
|
|
252
267
|
|
|
253
268
|
return { success: false, error: "Missing slug" } if slug.empty?
|
|
254
269
|
|
|
255
|
-
# When download_url is nil (e.g. mock/test mode), skip the download and
|
|
256
|
-
# just record the installed version so the UI reflects a successful install.
|
|
257
270
|
if url.nil?
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
record_installed_skill(slug, version, skill_info["name"])
|
|
261
|
-
return { success: true, slug: slug, version: version }
|
|
271
|
+
FileUtils.mkdir_p(File.join(brand_skills_dir, slug))
|
|
272
|
+
return { success: false, error: "No download URL" }
|
|
262
273
|
end
|
|
263
274
|
|
|
264
275
|
require "zip"
|
|
@@ -270,17 +281,28 @@ module Clacky
|
|
|
270
281
|
tmp_zip = File.join(brand_skills_dir, "#{slug}.zip")
|
|
271
282
|
download_file(url, tmp_zip)
|
|
272
283
|
|
|
273
|
-
# Extract into dest_dir (overwrite existing files)
|
|
284
|
+
# Extract into dest_dir (overwrite existing files).
|
|
285
|
+
# Auto-detect whether the zip has a single root folder to strip.
|
|
286
|
+
# Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
|
|
287
|
+
# path-safety restrictions on absolute destination paths.
|
|
274
288
|
Zip::File.open(tmp_zip) do |zip|
|
|
275
|
-
zip.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
entries = zip.entries.reject(&:directory?)
|
|
290
|
+
top_dirs = entries.map { |e| e.name.split("/").first }.uniq
|
|
291
|
+
has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
|
|
292
|
+
|
|
293
|
+
entries.each do |entry|
|
|
294
|
+
rel_path = if has_root
|
|
295
|
+
parts = entry.name.split("/")
|
|
296
|
+
parts[1..].join("/")
|
|
297
|
+
else
|
|
298
|
+
entry.name
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
next if rel_path.nil? || rel_path.empty?
|
|
280
302
|
|
|
281
303
|
out = File.join(dest_dir, rel_path)
|
|
282
304
|
FileUtils.mkdir_p(File.dirname(out))
|
|
283
|
-
|
|
305
|
+
File.open(out, "wb") { |f| f.write(entry.get_input_stream.read) }
|
|
284
306
|
end
|
|
285
307
|
end
|
|
286
308
|
|
|
@@ -290,7 +312,7 @@ module Clacky
|
|
|
290
312
|
record_installed_skill(slug, version, skill_info["name"])
|
|
291
313
|
|
|
292
314
|
{ success: true, slug: slug, version: version }
|
|
293
|
-
rescue StandardError => e
|
|
315
|
+
rescue StandardError, ScriptError => e
|
|
294
316
|
{ success: false, error: e.message }
|
|
295
317
|
end
|
|
296
318
|
|
|
@@ -437,13 +459,46 @@ module Clacky
|
|
|
437
459
|
raise "Brand skill encrypted file not found: #{encrypted_path}"
|
|
438
460
|
end
|
|
439
461
|
|
|
440
|
-
# Read the local brand_skills.json metadata
|
|
462
|
+
# Read the local brand_skills.json metadata, cross-validated against the
|
|
463
|
+
# actual file system. A skill is only considered installed when:
|
|
464
|
+
# 1. It has an entry in brand_skills.json, AND
|
|
465
|
+
# 2. Its skill directory exists under brand_skills_dir, AND
|
|
466
|
+
# 3. That directory contains at least one file (SKILL.md or SKILL.md.enc).
|
|
467
|
+
#
|
|
468
|
+
# If the JSON record exists but the directory is missing or empty the entry
|
|
469
|
+
# is silently dropped from the result and the JSON file is cleaned up so
|
|
470
|
+
# subsequent installs start from a clean state.
|
|
471
|
+
#
|
|
441
472
|
# Returns a hash keyed by slug: { "version" => "1.0.0", "name" => "..." }
|
|
442
473
|
def installed_brand_skills
|
|
443
474
|
path = File.join(brand_skills_dir, "brand_skills.json")
|
|
444
475
|
return {} unless File.exist?(path)
|
|
445
476
|
|
|
446
|
-
JSON.parse(File.read(path))
|
|
477
|
+
raw = JSON.parse(File.read(path))
|
|
478
|
+
|
|
479
|
+
# Validate each entry against the actual file system.
|
|
480
|
+
valid = {}
|
|
481
|
+
changed = false
|
|
482
|
+
|
|
483
|
+
raw.each do |slug, meta|
|
|
484
|
+
skill_dir = File.join(brand_skills_dir, slug)
|
|
485
|
+
has_files = Dir.exist?(skill_dir) &&
|
|
486
|
+
Dir.glob(File.join(skill_dir, "SKILL.md{,.enc}")).any?
|
|
487
|
+
|
|
488
|
+
if has_files
|
|
489
|
+
valid[slug] = meta
|
|
490
|
+
else
|
|
491
|
+
# JSON record exists but files are missing — mark for cleanup.
|
|
492
|
+
changed = true
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Persist the cleaned-up JSON so stale records don't accumulate.
|
|
497
|
+
if changed
|
|
498
|
+
File.write(path, JSON.generate(valid))
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
valid
|
|
447
502
|
rescue StandardError
|
|
448
503
|
{}
|
|
449
504
|
end
|
|
@@ -453,6 +508,10 @@ module Clacky
|
|
|
453
508
|
{
|
|
454
509
|
brand_name: @brand_name,
|
|
455
510
|
brand_command: @brand_command,
|
|
511
|
+
distribution_name: @distribution_name,
|
|
512
|
+
product_name: @product_name,
|
|
513
|
+
logo_url: @logo_url,
|
|
514
|
+
support_contact: @support_contact,
|
|
456
515
|
branded: branded?,
|
|
457
516
|
activated: activated?,
|
|
458
517
|
expired: expired?,
|
|
@@ -466,6 +525,10 @@ module Clacky
|
|
|
466
525
|
data = {}
|
|
467
526
|
data["brand_name"] = @brand_name if @brand_name
|
|
468
527
|
data["brand_command"] = @brand_command if @brand_command
|
|
528
|
+
data["distribution_name"] = @distribution_name if @distribution_name
|
|
529
|
+
data["product_name"] = @product_name if @product_name
|
|
530
|
+
data["logo_url"] = @logo_url if @logo_url
|
|
531
|
+
data["support_contact"] = @support_contact if @support_contact
|
|
469
532
|
data["license_key"] = @license_key if @license_key
|
|
470
533
|
data["license_activated_at"] = @license_activated_at.iso8601 if @license_activated_at
|
|
471
534
|
data["license_expires_at"] = @license_expires_at.iso8601 if @license_expires_at
|
|
@@ -474,20 +537,44 @@ module Clacky
|
|
|
474
537
|
YAML.dump(data)
|
|
475
538
|
end
|
|
476
539
|
|
|
540
|
+
# Apply distribution fields from API response.
|
|
541
|
+
# Updates name, product_name, logo_url, support_contact from the distribution hash.
|
|
542
|
+
private def apply_distribution(dist)
|
|
543
|
+
return unless dist.is_a?(Hash)
|
|
544
|
+
|
|
545
|
+
@distribution_name = dist["name"] if dist["name"].to_s.strip != ""
|
|
546
|
+
@product_name = dist["product_name"] if dist["product_name"].to_s.strip != ""
|
|
547
|
+
@logo_url = dist["logo_url"] if dist["logo_url"].to_s.strip != ""
|
|
548
|
+
@support_contact = dist["support_contact"] if dist["support_contact"].to_s.strip != ""
|
|
549
|
+
end
|
|
550
|
+
|
|
477
551
|
# Download a remote URL to a local file path.
|
|
478
|
-
private def download_file(url, dest)
|
|
552
|
+
private def download_file(url, dest, max_redirects: 10)
|
|
479
553
|
require "net/http"
|
|
480
554
|
require "uri"
|
|
481
555
|
|
|
482
556
|
uri = URI.parse(url)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
557
|
+
max_redirects.times do
|
|
558
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
559
|
+
open_timeout: 15, read_timeout: 60) do |http|
|
|
560
|
+
http.request_get(uri.request_uri) do |resp|
|
|
561
|
+
case resp.code.to_i
|
|
562
|
+
when 200
|
|
563
|
+
File.open(dest, "wb") { |f| resp.read_body { |chunk| f.write(chunk) } }
|
|
564
|
+
return
|
|
565
|
+
when 301, 302, 303, 307, 308
|
|
566
|
+
location = resp["location"]
|
|
567
|
+
raise "Redirect with no Location header" if location.nil? || location.empty?
|
|
568
|
+
|
|
569
|
+
uri = URI.parse(location)
|
|
570
|
+
break # break out of Net::HTTP.start, re-enter loop with new uri
|
|
571
|
+
else
|
|
572
|
+
raise "HTTP #{resp.code}"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
489
575
|
end
|
|
490
576
|
end
|
|
577
|
+
raise "Too many redirects"
|
|
491
578
|
end
|
|
492
579
|
|
|
493
580
|
# Persist installed skill metadata to brand_skills.json.
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -647,7 +647,7 @@ module Clacky
|
|
|
647
647
|
# Clear output area
|
|
648
648
|
ui_controller.layout.clear_output
|
|
649
649
|
# Clear session by creating a new agent
|
|
650
|
-
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: ui_controller)
|
|
650
|
+
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: ui_controller, profile: agent.agent_profile.name)
|
|
651
651
|
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
652
652
|
# Update session bar with reset values
|
|
653
653
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
@@ -20,7 +20,7 @@ Send a short, warm welcome message (2–3 sentences). Detect the user's language
|
|
|
20
20
|
text they've already typed; default to English. Do NOT ask any questions yet.
|
|
21
21
|
|
|
22
22
|
Example (English):
|
|
23
|
-
> Hi! I'm
|
|
23
|
+
> Hi! I'm your personal assistant ⚡
|
|
24
24
|
> Let's take 30 seconds to personalize your experience — I'll ask just a couple of quick things.
|
|
25
25
|
|
|
26
26
|
### 2. Collect AI personality (card)
|
|
@@ -97,7 +97,7 @@ Template:
|
|
|
97
97
|
# [AI Name] — Soul
|
|
98
98
|
|
|
99
99
|
## Identity
|
|
100
|
-
I am [AI Name],
|
|
100
|
+
I am [AI Name], a personal assistant and technical co-founder.
|
|
101
101
|
[1–2 sentences reflecting the chosen personality.]
|
|
102
102
|
|
|
103
103
|
## Personality & Tone
|
|
@@ -105,7 +105,7 @@ module Clacky
|
|
|
105
105
|
session_registry: @registry,
|
|
106
106
|
session_builder: method(:build_session)
|
|
107
107
|
)
|
|
108
|
-
@skill_loader = Clacky::SkillLoader.new(nil, brand_config: Clacky::BrandConfig.load)
|
|
108
|
+
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.load)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
def start
|
|
@@ -163,6 +163,7 @@ module Clacky
|
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
puts "🌐 Clacky Web UI running at http://#{@host}:#{@port}"
|
|
166
|
+
puts " Version: #{Clacky::VERSION}"
|
|
166
167
|
puts " Press Ctrl-C to stop."
|
|
167
168
|
|
|
168
169
|
# Auto-create a default session on startup
|
|
@@ -385,7 +386,7 @@ module Clacky
|
|
|
385
386
|
if result[:success]
|
|
386
387
|
# Refresh skill_loader with the now-activated brand config so brand
|
|
387
388
|
# skills are loadable from this point forward (e.g. after sync).
|
|
388
|
-
@skill_loader = Clacky::SkillLoader.new(nil, brand_config: brand)
|
|
389
|
+
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
|
|
389
390
|
json_response(res, 200, { ok: true, brand_name: result[:brand_name] || brand.brand_name })
|
|
390
391
|
else
|
|
391
392
|
json_response(res, 422, { ok: false, error: result[:message] })
|
|
@@ -459,6 +460,8 @@ module Clacky
|
|
|
459
460
|
else
|
|
460
461
|
json_response(res, 422, { ok: false, error: result[:error] })
|
|
461
462
|
end
|
|
463
|
+
rescue StandardError, ScriptError => e
|
|
464
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
462
465
|
end
|
|
463
466
|
|
|
464
467
|
# GET /api/brand
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Clacky
|
|
|
27
27
|
# @param working_dir [String] Current working directory for project-level discovery
|
|
28
28
|
# @param brand_config [Clacky::BrandConfig, nil] Optional brand config used to
|
|
29
29
|
# decrypt brand skills. When nil, brand skills are silently skipped.
|
|
30
|
-
def initialize(working_dir
|
|
30
|
+
def initialize(working_dir:, brand_config:)
|
|
31
31
|
@working_dir = working_dir || Dir.pwd
|
|
32
32
|
@brand_config = brand_config
|
|
33
33
|
@skills = {} # Map identifier -> Skill
|
|
@@ -45,7 +45,7 @@ module Clacky
|
|
|
45
45
|
def load_all
|
|
46
46
|
# Clear existing skills to ensure idempotent reloading
|
|
47
47
|
clear
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
load_default_skills
|
|
50
50
|
load_global_claude_skills
|
|
51
51
|
load_global_clacky_skills
|
|
@@ -56,9 +56,9 @@ module Clacky
|
|
|
56
56
|
all_skills
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Load
|
|
60
|
-
#
|
|
61
|
-
#
|
|
59
|
+
# Load brand skills from ~/.clacky/brand_skills/
|
|
60
|
+
# Supports both encrypted (SKILL.md.enc) and plain (SKILL.md) brand skills.
|
|
61
|
+
# Encrypted skills require a BrandConfig with an activated license to decrypt.
|
|
62
62
|
# @return [Array<Skill>]
|
|
63
63
|
def load_brand_skills
|
|
64
64
|
return [] unless @brand_config&.activated?
|
|
@@ -70,11 +70,13 @@ module Clacky
|
|
|
70
70
|
|
|
71
71
|
skills = []
|
|
72
72
|
brand_skills_dir.children.select(&:directory?).each do |skill_dir|
|
|
73
|
-
#
|
|
74
|
-
|
|
73
|
+
# Support both encrypted (.enc) and plain brand skills
|
|
74
|
+
encrypted = skill_dir.join("SKILL.md.enc").exist?
|
|
75
|
+
plain = skill_dir.join("SKILL.md").exist?
|
|
76
|
+
next unless encrypted || plain
|
|
75
77
|
|
|
76
78
|
skill_name = skill_dir.basename.to_s
|
|
77
|
-
skill = load_single_brand_skill(skill_dir, skill_name)
|
|
79
|
+
skill = load_single_brand_skill(skill_dir, skill_name, encrypted: encrypted)
|
|
78
80
|
skills << skill if skill
|
|
79
81
|
end
|
|
80
82
|
skills
|
|
@@ -312,16 +314,18 @@ module Clacky
|
|
|
312
314
|
skills
|
|
313
315
|
end
|
|
314
316
|
|
|
315
|
-
# Load a single
|
|
316
|
-
#
|
|
317
|
+
# Load a single brand skill directory.
|
|
318
|
+
# Supports encrypted (SKILL.md.enc) and plain (SKILL.md) brand skills.
|
|
319
|
+
# @param skill_dir [Pathname] Directory containing the skill file
|
|
317
320
|
# @param skill_name [String] Directory basename used as fallback identifier
|
|
321
|
+
# @param encrypted [Boolean] Whether to treat this as an encrypted brand skill
|
|
318
322
|
# @return [Skill, nil]
|
|
319
|
-
private def load_single_brand_skill(skill_dir, skill_name)
|
|
323
|
+
private def load_single_brand_skill(skill_dir, skill_name, encrypted: true)
|
|
320
324
|
skill = Skill.new(
|
|
321
325
|
skill_dir,
|
|
322
326
|
source_path: skill_dir,
|
|
323
|
-
brand_skill:
|
|
324
|
-
brand_config: @brand_config
|
|
327
|
+
brand_skill: encrypted,
|
|
328
|
+
brand_config: encrypted ? @brand_config : nil
|
|
325
329
|
)
|
|
326
330
|
|
|
327
331
|
existing = @skills[skill.identifier]
|
|
@@ -416,22 +420,22 @@ module Clacky
|
|
|
416
420
|
# Get the gem's lib directory
|
|
417
421
|
gem_lib_dir = File.expand_path("../", __dir__)
|
|
418
422
|
default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
|
|
419
|
-
|
|
423
|
+
|
|
420
424
|
return unless Dir.exist?(default_skills_dir)
|
|
421
|
-
|
|
425
|
+
|
|
422
426
|
# Load each skill directory
|
|
423
427
|
Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
|
|
424
428
|
skill_dir = File.dirname(skill_file)
|
|
425
429
|
skill_name = File.basename(skill_dir)
|
|
426
|
-
|
|
430
|
+
|
|
427
431
|
begin
|
|
428
432
|
skill = Skill.new(Pathname.new(skill_dir))
|
|
429
|
-
|
|
433
|
+
|
|
430
434
|
# Check for duplicates (higher priority skills override)
|
|
431
435
|
if @skills.key?(skill.identifier)
|
|
432
436
|
next # Skip if already loaded from higher priority location
|
|
433
437
|
end
|
|
434
|
-
|
|
438
|
+
|
|
435
439
|
# Register skill
|
|
436
440
|
@skills[skill.identifier] = skill
|
|
437
441
|
@skills_by_command[skill.slash_command] = skill
|
|
@@ -233,6 +233,11 @@ module Clacky
|
|
|
233
233
|
def append_output(content)
|
|
234
234
|
return if content.nil?
|
|
235
235
|
|
|
236
|
+
# Scrub any invalid byte sequences before they reach the render pipeline.
|
|
237
|
+
# wrap_long_line calls each_char which raises ArgumentError on invalid UTF-8.
|
|
238
|
+
content = content.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: '') \
|
|
239
|
+
unless content.valid_encoding?
|
|
240
|
+
|
|
236
241
|
@render_mutex.synchronize do
|
|
237
242
|
lines = content.split("\n", -1) # -1 to keep trailing empty strings
|
|
238
243
|
|
|
@@ -15,6 +15,10 @@ module Clacky
|
|
|
15
15
|
@buffer = []
|
|
16
16
|
@last_input_time = nil
|
|
17
17
|
@rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
|
|
18
|
+
|
|
19
|
+
# Keep stdin in UTF-8 mode so getc returns complete multi-byte characters (e.g. CJK).
|
|
20
|
+
# Switching to BINARY would cause getc to return one byte at a time, breaking Chinese input.
|
|
21
|
+
$stdin.set_encoding('UTF-8')
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
# Move cursor to specific position (0-indexed)
|
|
@@ -138,8 +142,6 @@ module Clacky
|
|
|
138
142
|
# @param timeout [Float] Timeout in seconds
|
|
139
143
|
# @return [Symbol, String, Hash, nil] Key symbol, character, or { type: :rapid_input, text: String }
|
|
140
144
|
def read_key(timeout: nil)
|
|
141
|
-
$stdin.set_encoding('UTF-8')
|
|
142
|
-
|
|
143
145
|
current_time = Time.now.to_f
|
|
144
146
|
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
145
147
|
@last_input_time = current_time
|
|
@@ -147,8 +149,9 @@ module Clacky
|
|
|
147
149
|
char = read_char(timeout: timeout)
|
|
148
150
|
return nil unless char
|
|
149
151
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
+
# Convert raw BINARY bytes to valid UTF-8. Invalid/undefined bytes are dropped
|
|
153
|
+
# rather than raising ArgumentError (which would crash the input loop).
|
|
154
|
+
char = safe_to_utf8(char) if char.is_a?(String)
|
|
152
155
|
|
|
153
156
|
# Handle escape sequences for special keys
|
|
154
157
|
if char == "\e"
|
|
@@ -179,7 +182,6 @@ module Clacky
|
|
|
179
182
|
# If this is rapid input or there are more characters available
|
|
180
183
|
if is_rapid_input || has_more_input
|
|
181
184
|
buffer = char.to_s.dup
|
|
182
|
-
buffer.force_encoding('UTF-8')
|
|
183
185
|
|
|
184
186
|
# Keep reading available characters
|
|
185
187
|
loop_count = 0
|
|
@@ -190,10 +192,10 @@ module Clacky
|
|
|
190
192
|
has_data = IO.select([$stdin], nil, nil, 0)
|
|
191
193
|
|
|
192
194
|
if has_data
|
|
193
|
-
next_char = $stdin.getc
|
|
195
|
+
next_char = $stdin.getc rescue nil
|
|
194
196
|
break unless next_char
|
|
195
197
|
|
|
196
|
-
next_char = next_char
|
|
198
|
+
next_char = safe_to_utf8(next_char)
|
|
197
199
|
buffer << next_char
|
|
198
200
|
loop_count += 1
|
|
199
201
|
empty_checks = 0 # Reset empty check counter
|
|
@@ -213,6 +215,8 @@ module Clacky
|
|
|
213
215
|
|
|
214
216
|
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
215
217
|
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
218
|
+
# Ensure the accumulated buffer is valid UTF-8 before regex operations
|
|
219
|
+
buffer = safe_to_utf8(buffer)
|
|
216
220
|
# Remove any trailing \r or \n from rapid input buffer
|
|
217
221
|
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
218
222
|
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
@@ -251,6 +255,19 @@ module Clacky
|
|
|
251
255
|
end
|
|
252
256
|
|
|
253
257
|
private
|
|
258
|
+
|
|
259
|
+
# Ensure a string is valid UTF-8.
|
|
260
|
+
# stdin stays in UTF-8 mode so getc returns complete characters (including CJK).
|
|
261
|
+
# This method handles the rare case where an invalid byte slips through
|
|
262
|
+
# (e.g. a stray terminal escape or a partial sequence) by scrubbing it out
|
|
263
|
+
# rather than letting ArgumentError crash the input loop.
|
|
264
|
+
# @param str [String] String from getc (UTF-8 encoded, but may have invalid bytes)
|
|
265
|
+
# @return [String] Valid UTF-8 string
|
|
266
|
+
private def safe_to_utf8(str)
|
|
267
|
+
return str if str.valid_encoding?
|
|
268
|
+
|
|
269
|
+
str.encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
270
|
+
end
|
|
254
271
|
end
|
|
255
272
|
end
|
|
256
273
|
end
|