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 +4 -4
- data/CHANGELOG.md +34 -0
- data/lib/clacky/agent/session_serializer.rb +31 -0
- data/lib/clacky/agent/skill_manager.rb +59 -0
- data/lib/clacky/agent.rb +7 -2
- data/lib/clacky/agent_config.rb +10 -0
- 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 +7 -4
- 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 +947 -337
- 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/sessions.js +78 -3
- data/lib/clacky/web/settings.js +179 -26
- 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,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.
|
|
679
|
+
subagent_config = @config.deep_copy
|
|
675
680
|
|
|
676
681
|
# Switch to specified model if provided
|
|
677
682
|
if model
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -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)
|
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
|
|
@@ -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.
|
|
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.
|
|
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")
|
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
|
|