openclacky 0.9.27 → 0.9.28
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 +16 -0
- data/lib/clacky/agent/llm_caller.rb +110 -10
- data/lib/clacky/agent.rb +2 -2
- data/lib/clacky/agent_config.rb +75 -0
- data/lib/clacky/brand_config.rb +146 -23
- data/lib/clacky/default_skills/browser-setup/SKILL.md +296 -71
- data/lib/clacky/platform_http_client.rb +11 -1
- data/lib/clacky/providers.rb +22 -2
- data/lib/clacky/server/browser_manager.rb +66 -5
- data/lib/clacky/server/http_server.rb +114 -11
- data/lib/clacky/skill.rb +10 -7
- data/lib/clacky/skill_loader.rb +37 -5
- data/lib/clacky/tools/browser.rb +0 -38
- data/lib/clacky/utils/browser_detector.rb +73 -27
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +464 -4
- data/lib/clacky/web/app.js +89 -15
- data/lib/clacky/web/brand.js +66 -16
- data/lib/clacky/web/creator.js +418 -0
- data/lib/clacky/web/i18n.js +80 -0
- data/lib/clacky/web/index.html +79 -0
- data/lib/clacky/web/sessions.js +81 -7
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/skills.js +36 -175
- data/lib/clacky/web/ws.js +0 -1
- data/lib/clacky.rb +1 -0
- metadata +5 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e9d5f6bf142facc899ab2087b5f245e1e3b7aa5135548a42455fea73e685def
|
|
4
|
+
data.tar.gz: f3d3251db1a6245ff9bdbe37433c9e70409c1975c718f18a68b7af82d17a38a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a300098516c8081374fa6d55a21f4884f8d70b43c12b1283643a86e03fdb81d96f765a81bc14e703b6e4584bbe3fec1a7b08eadb5488ff5de2223fda922ca9e8
|
|
7
|
+
data.tar.gz: 64d73189f3852cf6fc064a61d065e762c6ba2e32d4d254736aebdcaff2106be2b55ea3ad349a1898778460e17142a06977250d8053f7db6bfe5525093f701b55
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.28] - 2026-04-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Creator menu**: new creator-focused UI for managing brand skills and customizations
|
|
14
|
+
- **Provider fallback system**: automatic fallback to secondary AI providers when primary provider fails
|
|
15
|
+
- **Chinese localization**: full UI translation for skill descriptions and session lists
|
|
16
|
+
- **Session scroll improvements**: better session navigation and scrolling behavior in Web UI
|
|
17
|
+
- **Brand logo support**: custom logos and icons for white-label deployments
|
|
18
|
+
|
|
19
|
+
### Improved
|
|
20
|
+
- **Browser setup skill**: enhanced browser-setup SKILL with more detailed instructions and error handling
|
|
21
|
+
- **Browser port detection**: more robust detection logic for Chrome/Edge debugging port
|
|
22
|
+
|
|
23
|
+
### More
|
|
24
|
+
- Test suite improvements and fixes
|
|
25
|
+
|
|
10
26
|
## [0.9.27] - 2026-04-07
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -3,17 +3,46 @@
|
|
|
3
3
|
module Clacky
|
|
4
4
|
class Agent
|
|
5
5
|
# LLM API call management
|
|
6
|
-
# Handles API calls with retry logic and progress indication
|
|
6
|
+
# Handles API calls with retry logic, fallback model support, and progress indication
|
|
7
7
|
module LlmCaller
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# Number of consecutive RetryableError failures (503/429/5xx) before switching to fallback.
|
|
9
|
+
# Network-level errors (connection failures, timeouts) do NOT trigger fallback — they are
|
|
10
|
+
# retried on the primary model for the full max_retries budget, since they are likely
|
|
11
|
+
# transient infrastructure blips rather than a model-level outage.
|
|
12
|
+
RETRIES_BEFORE_FALLBACK = 3
|
|
13
|
+
|
|
14
|
+
# After switching to the fallback model, allow this many retries before giving up.
|
|
15
|
+
# Kept lower than max_retries (10) because we have already exhausted the primary model.
|
|
16
|
+
MAX_RETRIES_ON_FALLBACK = 5
|
|
17
|
+
|
|
18
|
+
# Execute LLM API call with progress indicator, retry logic, and cost tracking.
|
|
19
|
+
#
|
|
20
|
+
# Fallback / probing state machine (driven by AgentConfig):
|
|
21
|
+
#
|
|
22
|
+
# :primary_ok (nil)
|
|
23
|
+
# Normal operation — use the configured model.
|
|
24
|
+
# After RETRIES_BEFORE_FALLBACK consecutive failures → :fallback_active
|
|
25
|
+
#
|
|
26
|
+
# :fallback_active
|
|
27
|
+
# Use fallback model. After FALLBACK_COOLING_OFF_SECONDS (30 min) the
|
|
28
|
+
# config transitions to :probing on the next call_llm entry.
|
|
29
|
+
#
|
|
30
|
+
# :probing
|
|
31
|
+
# Silently attempt the primary model once.
|
|
32
|
+
# Success → config transitions back to :primary_ok, user notified.
|
|
33
|
+
# Failure → renew cooling-off clock, back to :fallback_active, then
|
|
34
|
+
# retry the *same* request with the fallback model so the
|
|
35
|
+
# user experiences no extra delay.
|
|
36
|
+
#
|
|
10
37
|
# @return [Hash] API response with :content, :tool_calls, :usage, etc.
|
|
11
38
|
private def call_llm
|
|
39
|
+
# Transition :fallback_active → :probing if cooling-off has expired.
|
|
40
|
+
@config.maybe_start_probing
|
|
41
|
+
|
|
12
42
|
@ui&.show_progress
|
|
13
43
|
|
|
14
44
|
tools_to_send = @tool_registry.all_definitions
|
|
15
45
|
|
|
16
|
-
# Retry logic for network failures
|
|
17
46
|
max_retries = 10
|
|
18
47
|
retry_delay = 5
|
|
19
48
|
retries = 0
|
|
@@ -34,9 +63,23 @@ module Clacky
|
|
|
34
63
|
max_tokens: @config.max_tokens,
|
|
35
64
|
enable_caching: @config.enable_prompt_caching
|
|
36
65
|
)
|
|
66
|
+
|
|
67
|
+
# Successful response — if we were probing, confirm primary is healthy.
|
|
68
|
+
handle_probe_success if @config.probing?
|
|
69
|
+
|
|
37
70
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
38
71
|
@ui&.clear_progress
|
|
39
72
|
retries += 1
|
|
73
|
+
|
|
74
|
+
# Probing failure: primary still down — renew cooling-off and retry with fallback.
|
|
75
|
+
if @config.probing?
|
|
76
|
+
handle_probe_failure
|
|
77
|
+
retry
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Network-level errors (timeouts, connection failures) are likely transient
|
|
81
|
+
# infrastructure blips — do NOT trigger fallback. Just retry on the current
|
|
82
|
+
# model (primary or already-active fallback) up to max_retries.
|
|
40
83
|
if retries <= max_retries
|
|
41
84
|
@ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
|
|
42
85
|
sleep retry_delay
|
|
@@ -45,29 +88,86 @@ module Clacky
|
|
|
45
88
|
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
46
89
|
raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
47
90
|
end
|
|
91
|
+
|
|
48
92
|
rescue RetryableError => e
|
|
49
93
|
@ui&.clear_progress
|
|
50
94
|
retries += 1
|
|
51
|
-
|
|
52
|
-
|
|
95
|
+
|
|
96
|
+
# Probing failure: primary still down — renew cooling-off and retry with fallback.
|
|
97
|
+
if @config.probing?
|
|
98
|
+
handle_probe_failure
|
|
99
|
+
retry
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# RetryableError (503/429/5xx/ThrottlingException) signals a service-level outage.
|
|
103
|
+
# After RETRIES_BEFORE_FALLBACK attempts, switch to the fallback model and reset the
|
|
104
|
+
# retry counter — but cap fallback retries at MAX_RETRIES_ON_FALLBACK (< max_retries)
|
|
105
|
+
# since we have already confirmed the primary is struggling.
|
|
106
|
+
current_max = @config.fallback_active? ? MAX_RETRIES_ON_FALLBACK : max_retries
|
|
107
|
+
|
|
108
|
+
if retries <= current_max
|
|
109
|
+
if retries == RETRIES_BEFORE_FALLBACK && !@config.fallback_active?
|
|
110
|
+
if try_activate_fallback(current_model)
|
|
111
|
+
retries = 0
|
|
112
|
+
retry
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
@ui&.show_warning("#{e.message} (#{retries}/#{current_max})")
|
|
53
116
|
sleep retry_delay
|
|
54
117
|
retry
|
|
55
118
|
else
|
|
56
|
-
@ui&.show_error("LLM service unavailable after #{
|
|
57
|
-
raise AgentError, "LLM service unavailable after #{
|
|
119
|
+
@ui&.show_error("LLM service unavailable after #{current_max} retries. Please try again later.")
|
|
120
|
+
raise AgentError, "LLM service unavailable after #{current_max} retries"
|
|
58
121
|
end
|
|
122
|
+
|
|
59
123
|
ensure
|
|
60
124
|
@ui&.clear_progress
|
|
61
125
|
end
|
|
62
126
|
|
|
63
127
|
# Track cost and collect token usage data.
|
|
64
|
-
# token_data is returned to the caller so it can be displayed
|
|
65
|
-
# after show_assistant_message (ensuring correct ordering in WebUI).
|
|
66
128
|
token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
67
129
|
response[:token_usage] = token_data
|
|
68
130
|
|
|
69
131
|
response
|
|
70
132
|
end
|
|
133
|
+
|
|
134
|
+
# Attempt to activate the provider fallback model for the given primary model.
|
|
135
|
+
# Shows a user-visible warning when switching. Returns true if a fallback was found
|
|
136
|
+
# and activated, false if no fallback is configured.
|
|
137
|
+
# @param failed_model [String] the model name that is currently failing
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
private def try_activate_fallback(failed_model)
|
|
140
|
+
fallback = @config.fallback_model_for(failed_model)
|
|
141
|
+
return false unless fallback
|
|
142
|
+
|
|
143
|
+
@config.activate_fallback!(fallback)
|
|
144
|
+
@ui&.show_warning(
|
|
145
|
+
"Model #{failed_model} appears unavailable. " \
|
|
146
|
+
"Automatically switching to fallback model: #{fallback}"
|
|
147
|
+
)
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Called when a probe attempt (testing primary after cooling-off) succeeds.
|
|
152
|
+
# Resets the state machine to :primary_ok and notifies the user.
|
|
153
|
+
private def handle_probe_success
|
|
154
|
+
primary = @config.model_name
|
|
155
|
+
@config.confirm_fallback_ok!
|
|
156
|
+
@ui&.show_warning("Primary model #{primary} is healthy again. Switched back automatically.")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Called when a probe attempt fails.
|
|
160
|
+
# Renews the cooling-off clock (back to :fallback_active) so the *same*
|
|
161
|
+
# request is immediately retried with the fallback model — no extra delay.
|
|
162
|
+
private def handle_probe_failure
|
|
163
|
+
fallback = @config.instance_variable_get(:@fallback_model)
|
|
164
|
+
primary = @config.model_name
|
|
165
|
+
@config.activate_fallback!(fallback) # renews @fallback_since
|
|
166
|
+
@ui&.show_warning(
|
|
167
|
+
"Primary model #{primary} still unavailable. " \
|
|
168
|
+
"Continuing with fallback model: #{fallback}"
|
|
169
|
+
)
|
|
170
|
+
end
|
|
71
171
|
end
|
|
72
172
|
end
|
|
73
173
|
end
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -159,9 +159,9 @@ module Clacky
|
|
|
159
159
|
}
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
# Get current model name
|
|
162
|
+
# Get current model name (respects any active fallback override)
|
|
163
163
|
private def current_model
|
|
164
|
-
@config.
|
|
164
|
+
@config.effective_model_name
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
# Rename this session. Called by auto-naming (first message) or user explicit rename.
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -398,6 +398,81 @@ module Clacky
|
|
|
398
398
|
find_model_by_type("lite")
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
+
# How long to stay on the fallback model before probing the primary again.
|
|
402
|
+
FALLBACK_COOLING_OFF_SECONDS = 30 * 60 # 30 minutes
|
|
403
|
+
|
|
404
|
+
# Look up the fallback model name for the given model name.
|
|
405
|
+
# Uses the provider preset's fallback_models table.
|
|
406
|
+
# Returns nil if no fallback is configured for this model.
|
|
407
|
+
# @param model_name [String] the primary model name (e.g. "abs-claude-sonnet-4-6")
|
|
408
|
+
# @return [String, nil]
|
|
409
|
+
def fallback_model_for(model_name)
|
|
410
|
+
m = current_model
|
|
411
|
+
return nil unless m
|
|
412
|
+
|
|
413
|
+
provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
|
|
414
|
+
return nil unless provider_id
|
|
415
|
+
|
|
416
|
+
Clacky::Providers.fallback_model(provider_id, model_name)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Switch to fallback model and start the cooling-off clock.
|
|
420
|
+
# Idempotent — calling again while already in :fallback_active renews the timestamp.
|
|
421
|
+
# @param fallback_model_name [String] the fallback model to use
|
|
422
|
+
def activate_fallback!(fallback_model_name)
|
|
423
|
+
@fallback_state = :fallback_active
|
|
424
|
+
@fallback_since = Time.now
|
|
425
|
+
@fallback_model = fallback_model_name
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Called at the start of every call_llm.
|
|
429
|
+
# If cooling-off has expired, transition from :fallback_active → :probing
|
|
430
|
+
# so the next request will silently test the primary model.
|
|
431
|
+
# No-op in any other state.
|
|
432
|
+
def maybe_start_probing
|
|
433
|
+
return unless @fallback_state == :fallback_active
|
|
434
|
+
return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS
|
|
435
|
+
|
|
436
|
+
@fallback_state = :probing
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Called when a successful API response is received.
|
|
440
|
+
# If we were :probing (testing primary after cooling-off), this confirms
|
|
441
|
+
# the primary model is healthy again and resets everything.
|
|
442
|
+
# No-op in :primary_ok or :fallback_active states.
|
|
443
|
+
def confirm_fallback_ok!
|
|
444
|
+
return unless @fallback_state == :probing
|
|
445
|
+
|
|
446
|
+
@fallback_state = nil
|
|
447
|
+
@fallback_since = nil
|
|
448
|
+
@fallback_model = nil
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Returns true when a fallback model is currently being used
|
|
452
|
+
# (:fallback_active or :probing states).
|
|
453
|
+
def fallback_active?
|
|
454
|
+
@fallback_state == :fallback_active || @fallback_state == :probing
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Returns true only when we are silently probing the primary model.
|
|
458
|
+
def probing?
|
|
459
|
+
@fallback_state == :probing
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# The effective model name to use for API calls.
|
|
463
|
+
# - :primary_ok / nil → configured model_name (primary)
|
|
464
|
+
# - :fallback_active → fallback model
|
|
465
|
+
# - :probing → configured model_name (trying primary silently)
|
|
466
|
+
def effective_model_name
|
|
467
|
+
case @fallback_state
|
|
468
|
+
when :fallback_active
|
|
469
|
+
@fallback_model || model_name
|
|
470
|
+
else
|
|
471
|
+
# :primary_ok (nil) and :probing both use the primary model
|
|
472
|
+
model_name
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
401
476
|
# Get current model configuration
|
|
402
477
|
# Looks for type: default first, falls back to current_model_index
|
|
403
478
|
def current_model
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -165,18 +165,23 @@ module Clacky
|
|
|
165
165
|
@license_activated_at = Time.now.utc
|
|
166
166
|
@license_last_heartbeat = Time.now.utc
|
|
167
167
|
@license_expires_at = parse_time(data["expires_at"])
|
|
168
|
-
# product_name is applied via apply_distribution; no top-level product_name field expected at this level
|
|
169
|
-
# Save owner_user_id returned by the server when the license is bound to a specific user.
|
|
170
|
-
# Server returns "owner_user_id" for system licenses; plan-based licenses return nil.
|
|
171
|
-
owner_uid = data["owner_user_id"]
|
|
172
|
-
@license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
|
|
173
|
-
# Pin the device_id used in this activation request so that future API calls
|
|
174
|
-
# (e.g. skill_keys, heartbeat) always send the exact same device_id that was
|
|
175
|
-
# recorded in activated_devices on the server side.
|
|
176
|
-
# If the server echoes back device_id in the response, prefer that value;
|
|
177
|
-
# otherwise keep the one we just sent (@device_id is already set above).
|
|
178
168
|
server_device_id = data["device_id"].to_s.strip
|
|
179
169
|
@device_id = server_device_id unless server_device_id.empty?
|
|
170
|
+
# Clear ALL stale fields first, then apply fresh values from the new key.
|
|
171
|
+
# Order matters: reset everything before re-assigning so no old value lingers.
|
|
172
|
+
@product_name = nil
|
|
173
|
+
@package_name = nil
|
|
174
|
+
@logo_url = nil
|
|
175
|
+
@support_contact = nil
|
|
176
|
+
@support_qr_url = nil
|
|
177
|
+
@theme_color = nil
|
|
178
|
+
@homepage_url = nil
|
|
179
|
+
@license_user_id = nil
|
|
180
|
+
# Re-apply owner_user_id from the new activation response.
|
|
181
|
+
# Only system (creator) licenses return a non-nil owner_user_id.
|
|
182
|
+
# Brand-consumer keys return nil → @license_user_id stays nil → user_licensed? = false.
|
|
183
|
+
owner_uid = data["owner_user_id"]
|
|
184
|
+
@license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
|
|
180
185
|
apply_distribution(data["distribution"])
|
|
181
186
|
# Clear previously installed brand skills before saving the new license.
|
|
182
187
|
# Skills from the old brand are encrypted with that brand's keys — they
|
|
@@ -365,6 +370,41 @@ module Clacky
|
|
|
365
370
|
# (not filtered by the authenticated user's own skills).
|
|
366
371
|
# Returns { success: bool, skills: [], error: }.
|
|
367
372
|
#
|
|
373
|
+
# Fetch the creator's own published skills from the platform API.
|
|
374
|
+
# Uses GET /api/v1/client/skills (HMAC-signed, system license only).
|
|
375
|
+
# Returns { success: bool, skills: [], error: }.
|
|
376
|
+
def fetch_my_skills!
|
|
377
|
+
return { success: false, error: "License not activated", skills: [] } unless activated?
|
|
378
|
+
return { success: false, error: "User license required", skills: [] } unless user_licensed?
|
|
379
|
+
|
|
380
|
+
user_id = @license_user_id.to_s
|
|
381
|
+
key_hash = Digest::SHA256.hexdigest(@license_key)
|
|
382
|
+
ts = Time.now.utc.to_i.to_s
|
|
383
|
+
nonce = SecureRandom.hex(16)
|
|
384
|
+
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
385
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
|
|
386
|
+
|
|
387
|
+
query = URI.encode_www_form(
|
|
388
|
+
key_hash: key_hash,
|
|
389
|
+
user_id: user_id,
|
|
390
|
+
device_id: @device_id,
|
|
391
|
+
timestamp: ts,
|
|
392
|
+
nonce: nonce,
|
|
393
|
+
signature: signature
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
response = platform_client.get("/api/v1/client/skills?#{query}")
|
|
397
|
+
|
|
398
|
+
if response[:success]
|
|
399
|
+
skills = response[:data]["skills"] || []
|
|
400
|
+
{ success: true, skills: skills }
|
|
401
|
+
else
|
|
402
|
+
{ success: false, error: response[:error] || "Failed to fetch skills", skills: [] }
|
|
403
|
+
end
|
|
404
|
+
rescue StandardError => e
|
|
405
|
+
{ success: false, error: "Network error: #{e.message}", skills: [] }
|
|
406
|
+
end
|
|
407
|
+
|
|
368
408
|
# Each skill in the returned array is a hash with at minimum:
|
|
369
409
|
# "name", "description", "icon", "repo"
|
|
370
410
|
def fetch_store_skills!
|
|
@@ -520,7 +560,7 @@ module Clacky
|
|
|
520
560
|
# Record installed version in brand_skills.json (including description for
|
|
521
561
|
# offline display when the remote API is unreachable).
|
|
522
562
|
# encrypted: true because the ZIP contains MANIFEST.enc.json + AES-256-GCM encrypted files.
|
|
523
|
-
record_installed_skill(slug, version, skill_info["description"], encrypted: true)
|
|
563
|
+
record_installed_skill(slug, version, skill_info["description"], encrypted: true, description_zh: skill_info["description_zh"])
|
|
524
564
|
|
|
525
565
|
{ success: true, name: slug, version: version }
|
|
526
566
|
rescue StandardError, ScriptError => e
|
|
@@ -543,11 +583,12 @@ module Clacky
|
|
|
543
583
|
# optionally "version" and "emoji".
|
|
544
584
|
# @return [Hash] { success: bool, name:, version: }
|
|
545
585
|
def install_mock_brand_skill!(skill_info)
|
|
546
|
-
slug
|
|
547
|
-
version
|
|
548
|
-
name
|
|
549
|
-
description
|
|
550
|
-
|
|
586
|
+
slug = skill_info["name"].to_s.strip
|
|
587
|
+
version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] || "1.0.0"
|
|
588
|
+
name = slug
|
|
589
|
+
description = skill_info["description"] || "A private brand skill."
|
|
590
|
+
description_zh = skill_info["description_zh"] || "私有品牌技能。"
|
|
591
|
+
emoji = skill_info["emoji"] || "⭐"
|
|
551
592
|
|
|
552
593
|
return { success: false, error: "Missing skill name" } if slug.empty?
|
|
553
594
|
|
|
@@ -582,7 +623,7 @@ module Clacky
|
|
|
582
623
|
File.binwrite(enc_path, mock_content.encode("UTF-8"))
|
|
583
624
|
|
|
584
625
|
# encrypted: false — mock skills store plain bytes in .enc, no MANIFEST needed.
|
|
585
|
-
record_installed_skill(slug, version, description, encrypted: false)
|
|
626
|
+
record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh)
|
|
586
627
|
{ success: true, name: slug, version: version }
|
|
587
628
|
rescue StandardError => e
|
|
588
629
|
{ success: false, error: e.message }
|
|
@@ -611,6 +652,14 @@ module Clacky
|
|
|
611
652
|
result = fetch_brand_skills!
|
|
612
653
|
next unless result[:success]
|
|
613
654
|
|
|
655
|
+
# Remove locally installed skills that have been deleted on the remote.
|
|
656
|
+
# Compare the set of remote skill names against what is installed locally
|
|
657
|
+
# and delete any skill that no longer exists in the remote catalogue.
|
|
658
|
+
remote_skill_names = result[:skills].map { |s| s["name"] }
|
|
659
|
+
installed_brand_skills.each_key do |local_name|
|
|
660
|
+
delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name)
|
|
661
|
+
end
|
|
662
|
+
|
|
614
663
|
# Auto-sync is intentionally limited to skills the user has already
|
|
615
664
|
# installed and that have a newer version available.
|
|
616
665
|
# New skills are never auto-installed — the user must click Install/Update
|
|
@@ -646,6 +695,43 @@ module Clacky
|
|
|
646
695
|
@decryption_keys.clear if @decryption_keys
|
|
647
696
|
end
|
|
648
697
|
|
|
698
|
+
# Remove a single locally installed brand skill by name.
|
|
699
|
+
#
|
|
700
|
+
# Deletes the skill's directory from disk and removes its entry from
|
|
701
|
+
# brand_skills.json. Also evicts any cached decryption key for that skill
|
|
702
|
+
# so no stale key survives in memory.
|
|
703
|
+
#
|
|
704
|
+
# This is called during background sync when a skill that was previously
|
|
705
|
+
# installed is no longer present in the remote catalogue (i.e. the brand
|
|
706
|
+
# administrator deleted it on the platform side).
|
|
707
|
+
#
|
|
708
|
+
# @param skill_name [String] The slug/name of the skill to remove.
|
|
709
|
+
# @return [void]
|
|
710
|
+
private def delete_brand_skill!(skill_name)
|
|
711
|
+
# Remove files from disk.
|
|
712
|
+
skill_dir = File.join(brand_skills_dir, skill_name)
|
|
713
|
+
FileUtils.rm_rf(skill_dir) if Dir.exist?(skill_dir)
|
|
714
|
+
|
|
715
|
+
# Remove entry from brand_skills.json.
|
|
716
|
+
json_path = File.join(brand_skills_dir, "brand_skills.json")
|
|
717
|
+
if File.exist?(json_path)
|
|
718
|
+
registry = JSON.parse(File.read(json_path))
|
|
719
|
+
registry.delete(skill_name)
|
|
720
|
+
File.write(json_path, JSON.generate(registry))
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Evict cached decryption key (keyed by skill_version_id strings).
|
|
724
|
+
# We don't know the exact version id here, but we can drop any key whose
|
|
725
|
+
# associated manifest lives inside the now-deleted directory (they are
|
|
726
|
+
# already gone from disk). The simplest safe approach: clear the whole
|
|
727
|
+
# in-memory cache — keys will be re-fetched on next access for surviving
|
|
728
|
+
# skills.
|
|
729
|
+
@decryption_keys&.clear
|
|
730
|
+
rescue StandardError
|
|
731
|
+
# Deletion errors are non-fatal — a stale skill directory is harmless
|
|
732
|
+
# compared to aborting the entire sync operation.
|
|
733
|
+
end
|
|
734
|
+
|
|
649
735
|
# Decrypt an encrypted brand skill file and return its content in memory.
|
|
650
736
|
#
|
|
651
737
|
# Security model:
|
|
@@ -829,6 +915,42 @@ module Clacky
|
|
|
829
915
|
{}
|
|
830
916
|
end
|
|
831
917
|
|
|
918
|
+
# Path to the upload_meta.json file that tracks which local skills have been
|
|
919
|
+
# published to the platform and what version they were uploaded as.
|
|
920
|
+
#
|
|
921
|
+
# Format:
|
|
922
|
+
# {
|
|
923
|
+
# "commit" => { "platform_version" => "1.2.0", "uploaded_at" => "2026-04-09T..." },
|
|
924
|
+
# "nss-upload" => { "platform_version" => "1.0.0", "uploaded_at" => "..." }
|
|
925
|
+
# }
|
|
926
|
+
UPLOAD_META_FILE = File.join(Dir.home, ".clacky", "skills", "upload_meta.json").freeze
|
|
927
|
+
|
|
928
|
+
# Load upload metadata for all published local skills.
|
|
929
|
+
# @return [Hash{String => Hash}]
|
|
930
|
+
def self.load_upload_meta
|
|
931
|
+
return {} unless File.exist?(UPLOAD_META_FILE)
|
|
932
|
+
|
|
933
|
+
JSON.parse(File.read(UPLOAD_META_FILE))
|
|
934
|
+
rescue StandardError
|
|
935
|
+
{}
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Persist a single skill's upload record.
|
|
939
|
+
# @param skill_name [String]
|
|
940
|
+
# @param platform_version [String]
|
|
941
|
+
def self.record_upload!(skill_name, platform_version)
|
|
942
|
+
meta = load_upload_meta
|
|
943
|
+
meta[skill_name] = {
|
|
944
|
+
"platform_version" => platform_version,
|
|
945
|
+
"uploaded_at" => Time.now.utc.iso8601
|
|
946
|
+
}
|
|
947
|
+
dir = File.dirname(UPLOAD_META_FILE)
|
|
948
|
+
FileUtils.mkdir_p(dir)
|
|
949
|
+
File.write(UPLOAD_META_FILE, JSON.generate(meta))
|
|
950
|
+
rescue StandardError
|
|
951
|
+
# Non-fatal — metadata write failure should not break the upload flow
|
|
952
|
+
end
|
|
953
|
+
|
|
832
954
|
# Returns a hash representation for JSON serialization (e.g. /api/brand).
|
|
833
955
|
def to_h
|
|
834
956
|
{
|
|
@@ -947,18 +1069,19 @@ module Clacky
|
|
|
947
1069
|
# 1. name already valid → use name as-is
|
|
948
1070
|
# 2. name invalid — sanitize → downcase, spaces→hyphens, strip illegal chars
|
|
949
1071
|
# 3. still invalid after sanitize → raise, caller gets { success: false }
|
|
950
|
-
private def record_installed_skill(name, version, description = nil, encrypted: true)
|
|
1072
|
+
private def record_installed_skill(name, version, description = nil, encrypted: true, description_zh: nil)
|
|
951
1073
|
safe_name = sanitize_skill_name(name)
|
|
952
1074
|
|
|
953
1075
|
FileUtils.mkdir_p(brand_skills_dir)
|
|
954
1076
|
path = File.join(brand_skills_dir, "brand_skills.json")
|
|
955
1077
|
installed = installed_brand_skills
|
|
956
1078
|
installed[safe_name] = {
|
|
957
|
-
"version"
|
|
958
|
-
"name"
|
|
959
|
-
"description"
|
|
960
|
-
"
|
|
961
|
-
"
|
|
1079
|
+
"version" => version,
|
|
1080
|
+
"name" => safe_name,
|
|
1081
|
+
"description" => description.to_s,
|
|
1082
|
+
"description_zh" => description_zh.to_s,
|
|
1083
|
+
"encrypted" => encrypted,
|
|
1084
|
+
"installed_at" => Time.now.utc.iso8601
|
|
962
1085
|
}
|
|
963
1086
|
File.write(path, JSON.generate(installed))
|
|
964
1087
|
end
|