openclacky 0.8.2 → 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: 534d725368218baaf47a39be9b5ce86005b53ebec0f3a2c81d01d754232b56d6
4
- data.tar.gz: 0d4cfa47f16a72ddc2cc35e4c7a714ef3bee1d961be5d135aa110804b692e9f1
3
+ metadata.gz: e9d125c88f92f71da5a1e3699c8c466886ef7077e8578b7abbf2a6e3c9c966c2
4
+ data.tar.gz: 852e04561b92ceaa5af0359996533bbfaf63a14559e528e7f58b48af6b85206d
5
5
  SHA512:
6
- metadata.gz: 2d4250858cbb68fd49f0e25cadde03352ffd40566f05957d7de23b386e0e4a13668e02ae00858a0039ce3346eeff69a6bf2cba40da9e8074f3f954c22cc564c8
7
- data.tar.gz: 4a794d9b85a3a0a6f64e70e18e047aadc4d69b64780965f97a5c827403fff75536068937e7b7f824064f4bb0efbd6e793abcf4eb0975a1523d3e21fe27633624
6
+ metadata.gz: bfe068e23d38dd526ef634b940b6f4b4cb02297266b371d64294acd82ca40fac428fef16fec80e726907e1e4170f7d02b3515f723ac5da07ad888b32f621604d
7
+ data.tar.gz: 8ebdf651e254793b5e18f933167ee80abce8fea3405fe969e789adf85f3689c819eebe691f5783263c9581bf82dbaddeea514e5959d079370b28d843e2e022ab
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ 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
+
31
+ ## [0.8.3] - 2026-03-09
32
+
33
+ ### Added
34
+ - **Slash command skill injection**: skill content is now injected as an assistant message for all `/skill-name` commands, giving the agent full context of the skill instructions at invocation time
35
+ - **Collapsible `<think>` blocks** in web UI: model reasoning enclosed in `<think>…</think>` tags is rendered as a collapsible "Thinking…" section instead of raw text
36
+
37
+ ### Improved
38
+ - **Web UI settings panel**: refined layout and styles for the settings modal
39
+ - **Session state restored on page refresh**: "Thinking…" progress indicator and error messages are now restored from session status after a page reload instead of disappearing
40
+
41
+ ### Fixed
42
+ - **AgentConfig shallow-copy bug**: switching models in Settings no longer pollutes existing sessions — `deep_copy` (JSON round-trip) is now used everywhere instead of `dup` to prevent shared `@models` hash mutation across sessions
43
+
10
44
  ## [0.8.2] - 2026-03-09
11
45
 
12
46
  ### Added
@@ -52,6 +52,11 @@ module Clacky
52
52
  })
53
53
  end
54
54
  end
55
+
56
+ # Rebuild and refresh the system prompt so any newly installed skills
57
+ # (or other configuration changes since the session was saved) are
58
+ # reflected immediately — without requiring the user to create a new session.
59
+ refresh_system_prompt
55
60
  end
56
61
 
57
62
  # Generate session data for saving
@@ -174,6 +179,10 @@ module Clacky
174
179
  ui.show_user_message(display_text, created_at: msg[:created_at])
175
180
 
176
181
  round[:events].each do |ev|
182
+ # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
183
+ # — they are internal scaffolding and must not be shown to the user.
184
+ next if ev[:system_injected]
185
+
177
186
  case ev[:role].to_s
178
187
  when "assistant"
179
188
  # Text content
@@ -210,6 +219,28 @@ module Clacky
210
219
 
211
220
  private
212
221
 
222
+ # Replace the system message in @messages with a freshly built system prompt.
223
+ # Called after restore_session so newly installed skills and any other
224
+ # configuration changes since the session was saved take effect immediately.
225
+ # If no system message exists yet (shouldn't happen in practice), a new one
226
+ # is prepended so the conversation stays well-formed.
227
+ def refresh_system_prompt
228
+ # Reload skills from disk to pick up anything installed since the session was saved
229
+ @skill_loader.load_all
230
+
231
+ fresh_prompt = build_system_prompt
232
+ system_index = @messages.index { |m| m[:role] == "system" }
233
+
234
+ if system_index
235
+ @messages[system_index] = { role: "system", content: fresh_prompt }
236
+ else
237
+ @messages.unshift({ role: "system", content: fresh_prompt })
238
+ end
239
+ rescue StandardError => e
240
+ # Log and continue — a stale system prompt is better than a broken restore
241
+ Clacky::Logger.warn("refresh_system_prompt failed during session restore: #{e.message}")
242
+ end
243
+
213
244
  # Extract text from message content (handles string and array formats)
214
245
  # @param content [String, Array, Object] Message content
215
246
  # @return [String] Extracted text
@@ -128,6 +128,65 @@ module Clacky
128
128
  context
129
129
  end
130
130
 
131
+ # Inject a synthetic assistant message containing the skill content for slash
132
+ # commands (e.g. /pptx, /onboard).
133
+ #
134
+ # When a user types "/skill-name [arguments]", we immediately expand the skill
135
+ # content and inject it as an assistant message so the LLM receives the full
136
+ # instructions and acts on them — no waiting for the LLM to discover and call
137
+ # invoke_skill on its own.
138
+ #
139
+ # Message structure after injection:
140
+ # user: "/pptx write a deck about X"
141
+ # assistant: "[full skill content]" <- injected here
142
+ # (LLM continues from here)
143
+ #
144
+ # Fires when:
145
+ # 1. Input starts with "/"
146
+ # 2. The named skill exists and is user-invocable
147
+ #
148
+ # @param user_input [String] Raw user input
149
+ # @param task_id [Integer] Current task ID (for message tagging)
150
+ # @return [void]
151
+ def inject_skill_command_as_assistant_message(user_input, task_id)
152
+ parsed = parse_skill_command(user_input)
153
+ return unless parsed
154
+
155
+ skill = parsed[:skill]
156
+ arguments = parsed[:arguments]
157
+
158
+ # Expand skill content (substitutes $ARGUMENTS if present)
159
+ expanded_content = skill.process_content(arguments, template_context: build_template_context)
160
+
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)
173
+ @messages << {
174
+ role: "assistant",
175
+ content: expanded_content,
176
+ task_id: task_id,
177
+ system_injected: true
178
+ }
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
+
187
+ @ui&.log("Injected skill content for /#{skill.identifier}", level: :info)
188
+ end
189
+
131
190
  private
132
191
 
133
192
  # Filter skills by the agent profile name using the skill's own `agent:` field.
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.
@@ -177,6 +177,11 @@ module Clacky
177
177
  @messages << { role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f }
178
178
  @total_tasks += 1
179
179
 
180
+ # If the user typed a slash command targeting a skill with disable-model-invocation: true,
181
+ # inject the skill content as a synthetic assistant message so the LLM can act on it.
182
+ # Skills already in the system prompt (model_invocation_allowed?) are skipped.
183
+ inject_skill_command_as_assistant_message(user_input, task_id)
184
+
180
185
  @hooks.trigger(:on_start, user_input)
181
186
 
182
187
  begin
@@ -671,7 +676,7 @@ module Clacky
671
676
  # @return [Agent] New subagent instance
672
677
  def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil)
673
678
  # Clone config to avoid affecting parent
674
- subagent_config = @config.dup
679
+ subagent_config = @config.deep_copy
675
680
 
676
681
  # Switch to specified model if provided
677
682
  if model
@@ -226,6 +226,16 @@ module Clacky
226
226
  end
227
227
 
228
228
  # Save configuration to file
229
+ # Deep copy — models array contains mutable Hashes, so a shallow dup would
230
+ # let the copy share the same Hash objects with the original, causing
231
+ # Settings changes to silently mutate already-running session configs.
232
+ # JSON round-trip is the cleanest approach since @models is pure JSON-able data.
233
+ def deep_copy
234
+ copy = dup
235
+ copy.instance_variable_set(:@models, JSON.parse(JSON.generate(@models)))
236
+ copy
237
+ end
238
+
229
239
  def save(config_file = CONFIG_FILE)
230
240
  config_dir = File.dirname(config_file)
231
241
  FileUtils.mkdir_p(config_dir)
@@ -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
@@ -1115,7 +1118,7 @@ module Clacky
1115
1118
  session_id = @registry.create(name: name, working_dir: working_dir)
1116
1119
 
1117
1120
  client = @client_factory.call
1118
- config = @agent_config.dup
1121
+ config = @agent_config.deep_copy
1119
1122
  config.permission_mode = permission_mode
1120
1123
  broadcaster = method(:broadcast)
1121
1124
  ui = WebUIController.new(session_id, broadcaster)
@@ -1142,7 +1145,7 @@ module Clacky
1142
1145
  session_id: original_id)
1143
1146
 
1144
1147
  client = @client_factory.call
1145
- config = @agent_config.dup
1148
+ config = @agent_config.deep_copy
1146
1149
  broadcaster = method(:broadcast)
1147
1150
  ui = WebUIController.new(session_id, broadcaster)
1148
1151
  agent = Clacky::Agent.from_session(client, config, session_data, ui: ui, profile: "general")
@@ -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