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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 473dceae1c35bc7eeb4095df2f35ea1403f6013168a3daf3318a709df4ce40ec
4
- data.tar.gz: 9b7b97d917e13bf43ffff8fe50fda4f1eade74cd530e45959af46d9dbd313043
3
+ metadata.gz: e9d125c88f92f71da5a1e3699c8c466886ef7077e8578b7abbf2a6e3c9c966c2
4
+ data.tar.gz: 852e04561b92ceaa5af0359996533bbfaf63a14559e528e7f58b48af6b85206d
5
5
  SHA512:
6
- metadata.gz: 6717eccea955e82d4dfa4381cb99707785d6ac36c3875ac622d51e854007d2997b16155af3e48d2b644aae3635d3989152a03e6f069bf525d8a8630049b6dfb0
7
- data.tar.gz: 58cd3940c885363ae315e0430a318bb181713cdd0e5d1d4f7308343ce357f44075fd06cb8c4882f75824f857211c85c231b3d8b0cb2babe71ef6c76b5f21371c
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.
@@ -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
- dest_dir = File.join(brand_skills_dir, slug)
259
- FileUtils.mkdir_p(dest_dir)
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.each do |entry|
276
- # Strip leading component (the archive root folder) if present
277
- parts = entry.name.split("/")
278
- rel_path = parts.length > 1 ? parts[1..].join("/") : parts[0]
279
- next if rel_path.empty?
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
- entry.extract(out) { true } # overwrite
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
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
484
- open_timeout: 15, read_timeout: 60) do |http|
485
- http.request_get(uri.request_uri) do |resp|
486
- raise "HTTP #{resp.code}" unless resp.code.to_i == 200
487
-
488
- File.open(dest, "wb") { |f| resp.read_body { |chunk| f.write(chunk) } }
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 Clacky, your AI coding assistant ⚡
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], an AI coding assistant and technical co-founder.
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
@@ -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 = nil, brand_config: nil)
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 encrypted brand skills from ~/.clacky/brand_skills/
60
- # Each skill directory must contain a SKILL.md.enc file.
61
- # Requires a BrandConfig with an activated license to decrypt.
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
- # Only load directories that contain an encrypted skill file
74
- next unless skill_dir.join("SKILL.md.enc").exist?
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 encrypted brand skill directory.
316
- # @param skill_dir [Pathname] Directory containing SKILL.md.enc
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: true,
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
- # Ensure character is UTF-8 encoded
151
- char = char.force_encoding('UTF-8') if char.is_a?(String) && char.encoding != Encoding::UTF_8
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.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
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