Class: Clacky::BrandConfig
- Inherits:
-
Object
- Object
- Clacky::BrandConfig
- Defined in:
- lib/clacky/brand_config.rb
Overview
BrandConfig manages white-label branding for the OpenClacky gem.
Brand information is stored separately in ~/.clacky/brand.yml to avoid polluting the main config.yml. When no product_name is configured, the gem behaves exactly like the standard OpenClacky experience.
brand.yml structure:
product_name: "JohnAI"
package_name: "johnai"
logo_url: "https://example.com/logo.png"
support_contact: "support@johnai.com"
support_qr_url: "https://example.com/qr.png"
theme_color: "#3B82F6"
homepage_url: "https://johnai.com"
license_key: "0000002A-00000007-DEADBEEF-CAFEBABE-A1B2C3D4"
license_activated_at: "2025-03-01T00:00:00Z"
license_expires_at: "2026-03-01T00:00:00Z"
license_last_heartbeat: "2025-03-05T00:00:00Z"
device_id: "abc123def456..."
Constant Summary collapse
- CONFIG_DIR =
File.join(Dir.home, ".clacky")
- BRAND_FILE =
File.join(CONFIG_DIR, "brand.yml")
- HEARTBEAT_INTERVAL =
How often to send a heartbeat (seconds) — once per day
86_400- HEARTBEAT_GRACE_PERIOD =
Grace period for offline heartbeat failures (3 days)
3 * 86_400
- UPLOAD_META_FILE =
Path to the upload_meta.json file that tracks which local skills have been published to the platform and what version they were uploaded as.
Format:
{ "commit" => { "platform_version" => "1.2.0", "uploaded_at" => "2026-04-09T..." }, "nss-upload" => { "platform_version" => "1.0.0", "uploaded_at" => "..." } } File.join(Dir.home, ".clacky", "skills", "upload_meta.json").freeze
Instance Attribute Summary collapse
-
#device_id ⇒ Object
readonly
Returns the value of attribute device_id.
-
#homepage_url ⇒ Object
readonly
Returns the value of attribute homepage_url.
-
#license_activated_at ⇒ Object
readonly
Returns the value of attribute license_activated_at.
-
#license_expires_at ⇒ Object
readonly
Returns the value of attribute license_expires_at.
-
#license_key ⇒ Object
readonly
Returns the value of attribute license_key.
-
#license_last_heartbeat ⇒ Object
readonly
Returns the value of attribute license_last_heartbeat.
-
#license_user_id ⇒ Object
readonly
Returns the value of attribute license_user_id.
-
#logo_url ⇒ Object
readonly
Returns the value of attribute logo_url.
-
#package_name ⇒ Object
readonly
Returns the value of attribute package_name.
-
#product_name ⇒ Object
readonly
Returns the value of attribute product_name.
-
#support_contact ⇒ Object
readonly
Returns the value of attribute support_contact.
-
#support_qr_url ⇒ Object
readonly
Returns the value of attribute support_qr_url.
-
#theme_color ⇒ Object
readonly
Returns the value of attribute theme_color.
Class Method Summary collapse
-
.load ⇒ Object
Load brand configuration from ~/.clacky/brand.yml.
-
.load_upload_meta ⇒ Hash{String => Hash}
Load upload metadata for all published local skills.
-
.record_upload!(skill_name, platform_version) ⇒ Object
Persist a single skill’s upload record.
-
.version_older?(installed, latest) ⇒ Boolean
Compare two semver strings.
Instance Method Summary collapse
-
#activate!(license_key) ⇒ Object
Activate the license against the OpenClacky Cloud API using HMAC proof.
-
#activate_mock!(license_key) ⇒ Object
Activate the license locally without calling the remote API.
-
#activated? ⇒ Boolean
Returns true when a license key has been stored (post-activation).
-
#brand_skills_dir ⇒ Object
Path to the directory where brand skills are installed.
-
#branded? ⇒ Boolean
Returns true when this installation has a product name configured.
-
#clear_brand_skills! ⇒ Object
Remove all locally installed brand skills (encrypted files + metadata).
-
#decrypt_all_scripts(skill_dir, dest_dir) ⇒ Array<String>
Decrypt all supporting script files for a skill into a temporary directory.
-
#decrypt_skill_content(encrypted_path) ⇒ String
Decrypt an encrypted brand skill file and return its content in memory.
-
#expired? ⇒ Boolean
Returns true when the license has passed its expiry date.
-
#fetch_brand_skills! ⇒ Object
Fetch the brand skills list from the OpenClacky Cloud API.
-
#fetch_my_skills! ⇒ Object
Fetch the public store skills list from the OpenClacky Cloud API.
-
#fetch_store_skills! ⇒ Object
Each skill in the returned array is a hash with at minimum: “name”, “description”, “icon”, “repo”.
-
#grace_period_exceeded? ⇒ Boolean
Returns true when the grace period for missed heartbeats has expired.
-
#heartbeat! ⇒ Object
Send a heartbeat to the API and update last_heartbeat timestamp.
-
#heartbeat_due? ⇒ Boolean
Returns true when a heartbeat should be sent (interval elapsed).
-
#initialize(attrs = {}) ⇒ BrandConfig
constructor
A new instance of BrandConfig.
-
#install_brand_skill!(skill_info) ⇒ Object
Install (or update) a single brand skill by downloading and extracting its zip.
-
#install_mock_brand_skill!(skill_info) ⇒ Hash
Install a mock brand skill for brand-test mode.
-
#installed_brand_skills ⇒ Object
Read the local brand_skills.json metadata, cross-validated against the actual file system.
-
#save ⇒ Object
Save current state to ~/.clacky/brand.yml.
-
#sync_brand_skills_async!(on_complete: nil) ⇒ Thread?
Synchronise brand skills in the background.
-
#to_h ⇒ Object
Returns a hash representation for JSON serialization (e.g. /api/brand).
- #to_yaml ⇒ Object
-
#upload_skill!(skill_name, zip_data, force: false, version_override: nil) ⇒ Object
Upload (publish) a custom skill ZIP to the OpenClacky Cloud API.
-
#user_licensed? ⇒ Boolean
Returns true when the license is bound to a specific user (user_id present).
Constructor Details
#initialize(attrs = {}) ⇒ BrandConfig
Returns a new instance of BrandConfig.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/clacky/brand_config.rb', line 47 def initialize(attrs = {}) @product_name = attrs["product_name"] @package_name = attrs["package_name"] @logo_url = attrs["logo_url"] @support_contact = attrs["support_contact"] @support_qr_url = attrs["support_qr_url"] @theme_color = attrs["theme_color"] @homepage_url = attrs["homepage_url"] @license_key = attrs["license_key"] @license_activated_at = parse_time(attrs["license_activated_at"]) @license_expires_at = parse_time(attrs["license_expires_at"]) @license_last_heartbeat = parse_time(attrs["license_last_heartbeat"]) @device_id = attrs["device_id"] # user_id returned by the license server when the license is bound to a specific user @license_user_id = attrs["license_user_id"] # In-memory decryption key cache: "skill_id:skill_version_id" => { key:, expires_at: } # Never persisted to disk. Survives across multiple skill invocations within one session. @decryption_keys = {} # Timestamp of last successful server contact (for grace period calculation) @last_server_contact_at = nil end |
Instance Attribute Details
#device_id ⇒ Object (readonly)
Returns the value of attribute device_id.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def device_id @device_id end |
#homepage_url ⇒ Object (readonly)
Returns the value of attribute homepage_url.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def homepage_url @homepage_url end |
#license_activated_at ⇒ Object (readonly)
Returns the value of attribute license_activated_at.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def license_activated_at @license_activated_at end |
#license_expires_at ⇒ Object (readonly)
Returns the value of attribute license_expires_at.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def license_expires_at @license_expires_at end |
#license_key ⇒ Object (readonly)
Returns the value of attribute license_key.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def license_key @license_key end |
#license_last_heartbeat ⇒ Object (readonly)
Returns the value of attribute license_last_heartbeat.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def license_last_heartbeat @license_last_heartbeat end |
#license_user_id ⇒ Object (readonly)
Returns the value of attribute license_user_id.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def license_user_id @license_user_id end |
#logo_url ⇒ Object (readonly)
Returns the value of attribute logo_url.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def logo_url @logo_url end |
#package_name ⇒ Object (readonly)
Returns the value of attribute package_name.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def package_name @package_name end |
#product_name ⇒ Object (readonly)
Returns the value of attribute product_name.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def product_name @product_name end |
#support_contact ⇒ Object (readonly)
Returns the value of attribute support_contact.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def support_contact @support_contact end |
#support_qr_url ⇒ Object (readonly)
Returns the value of attribute support_qr_url.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def support_qr_url @support_qr_url end |
#theme_color ⇒ Object (readonly)
Returns the value of attribute theme_color.
42 43 44 |
# File 'lib/clacky/brand_config.rb', line 42 def theme_color @theme_color end |
Class Method Details
.load ⇒ Object
Load brand configuration from ~/.clacky/brand.yml. Returns an empty BrandConfig (no brand) if the file does not exist.
72 73 74 75 76 77 78 79 |
# File 'lib/clacky/brand_config.rb', line 72 def self.load return new({}) unless File.exist?(BRAND_FILE) data = YAML.safe_load(File.read(BRAND_FILE)) || {} new(data) rescue StandardError new({}) end |
.load_upload_meta ⇒ Hash{String => Hash}
Load upload metadata for all published local skills.
930 931 932 933 934 935 936 |
# File 'lib/clacky/brand_config.rb', line 930 def self. return {} unless File.exist?(UPLOAD_META_FILE) JSON.parse(File.read(UPLOAD_META_FILE)) rescue StandardError {} end |
.record_upload!(skill_name, platform_version) ⇒ Object
Persist a single skill’s upload record.
941 942 943 944 945 946 947 948 949 950 951 952 |
# File 'lib/clacky/brand_config.rb', line 941 def self.record_upload!(skill_name, platform_version) = [skill_name] = { "platform_version" => platform_version, "uploaded_at" => Time.now.utc.iso8601 } dir = File.dirname(UPLOAD_META_FILE) FileUtils.mkdir_p(dir) File.write(UPLOAD_META_FILE, JSON.generate()) rescue StandardError # Non-fatal — metadata write failure should not break the upload flow end |
.version_older?(installed, latest) ⇒ Boolean
Compare two semver strings. Returns true when ‘installed` is strictly older than `latest` (i.e. the server has a newer version available). Returns false when installed >= latest, or when either version is blank/nil, so a local dev build never shows a spurious “Update” badge.
997 998 999 1000 1001 1002 1003 1004 |
# File 'lib/clacky/brand_config.rb', line 997 def self.version_older?(installed, latest) return false if installed.to_s.strip.empty? || latest.to_s.strip.empty? Gem::Version.new(installed.to_s.strip) < Gem::Version.new(latest.to_s.strip) rescue ArgumentError # Unparseable version strings — treat as "not older" to avoid false positives false end |
Instance Method Details
#activate!(license_key) ⇒ Object
Activate the license against the OpenClacky Cloud API using HMAC proof. Returns a result hash: { success: bool, message: String, data: Hash }
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/clacky/brand_config.rb', line 140 def activate!(license_key) @license_key = license_key.strip @device_id ||= generate_device_id user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "activate:#{key_hash}:#{user_id}:#{@device_id}:#{ts}:#{nonce}" proof = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, proof: proof, device_info: device_info } response = api_post("/api/v1/licenses/activate", payload) if response[:success] && response[:data]["status"] == "active" data = response[:data] @license_activated_at = Time.now.utc @license_last_heartbeat = Time.now.utc @license_expires_at = parse_time(data["expires_at"]) server_device_id = data["device_id"].to_s.strip @device_id = server_device_id unless server_device_id.empty? # Clear ALL stale fields first, then apply fresh values from the new key. # Order matters: reset everything before re-assigning so no old value lingers. @product_name = nil @package_name = nil @logo_url = nil @support_contact = nil @support_qr_url = nil @theme_color = nil @homepage_url = nil @license_user_id = nil # Re-apply owner_user_id from the new activation response. # Only system (creator) licenses return a non-nil owner_user_id. # Brand-consumer keys return nil → @license_user_id stays nil → user_licensed? = false. owner_uid = data["owner_user_id"] @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty? apply_distribution(data["distribution"]) # Clear previously installed brand skills before saving the new license. # Skills from the old brand are encrypted with that brand's keys — they # cannot be decrypted with the new license and must be re-downloaded. clear_brand_skills! save { success: true, message: "License activated successfully!", product_name: @product_name, user_id: @license_user_id, data: data } else @license_key = nil { success: false, message: response[:error] || "Activation failed", data: {} } end end |
#activate_mock!(license_key) ⇒ Object
Activate the license locally without calling the remote API. Used in brand-test mode for development and integration testing.
The mock derives a plausible product_name from the key’s first segment (e.g. “0000002A” → user_id 42 → “Brand42”) unless one is already set. A fixed 1-year expiry is written so the UI can display a realistic date.
Returns the same { success:, message:, product_name:, data: } shape as activate!
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/clacky/brand_config.rb', line 207 def activate_mock!(license_key) @license_key = license_key.strip # Pin a stable device_id for this activation. Once set (from a prior load or # a previous call), never regenerate — the same rule as activate!. @device_id ||= generate_device_id # Always derive product_name fresh from the key in mock mode, # so switching keys produces a different brand each time. user_id = parse_user_id_from_key(@license_key) @product_name = "Brand#{user_id}" @license_activated_at = Time.now.utc @license_last_heartbeat = Time.now.utc @license_expires_at = Time.now.utc + (365 * 86_400) # 1 year from now # Clear old brand skills so stale encrypted files from a previous brand don't linger clear_brand_skills! save { success: true, message: "License activated (mock mode).", product_name: @product_name, data: { status: "active", expires_at: @license_expires_at.iso8601 } } end |
#activated? ⇒ Boolean
Returns true when a license key has been stored (post-activation).
87 88 89 |
# File 'lib/clacky/brand_config.rb', line 87 def activated? !@license_key.nil? && !@license_key.strip.empty? end |
#brand_skills_dir ⇒ Object
Path to the directory where brand skills are installed.
681 682 683 |
# File 'lib/clacky/brand_config.rb', line 681 def brand_skills_dir File.join(CONFIG_DIR, "brand_skills") end |
#branded? ⇒ Boolean
Returns true when this installation has a product name configured.
82 83 84 |
# File 'lib/clacky/brand_config.rb', line 82 def branded? !@product_name.nil? && !@product_name.strip.empty? end |
#clear_brand_skills! ⇒ Object
Remove all locally installed brand skills (encrypted files + metadata). Called on license activation so stale skills from a previous brand cannot linger — they are encrypted with that brand’s keys and are inaccessible under the new license anyway.
689 690 691 692 693 694 695 696 |
# File 'lib/clacky/brand_config.rb', line 689 def clear_brand_skills! dir = brand_skills_dir return unless Dir.exist?(dir) FileUtils.rm_rf(dir) # Also clear in-memory decryption key cache so no stale keys survive @decryption_keys.clear if @decryption_keys end |
#decrypt_all_scripts(skill_dir, dest_dir) ⇒ Array<String>
Decrypt all supporting script files for a skill into a temporary directory.
Scans ‘skill_dir` recursively for `*.enc` files, skipping SKILL.md.enc and MANIFEST.enc.json. Each file is decrypted in memory and written to the corresponding relative path under `dest_dir`. The decryption key is fetched once (cached) for all files belonging to the same skill version.
For mock/plain skills (no MANIFEST.enc.json) the raw bytes are used as-is.
816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 |
# File 'lib/clacky/brand_config.rb', line 816 def decrypt_all_scripts(skill_dir, dest_dir) raise "License not activated — cannot decrypt brand skill" unless activated? manifest_path = File.join(skill_dir, "MANIFEST.enc.json") manifest = File.exist?(manifest_path) ? JSON.parse(File.read(manifest_path)) : nil written = [] # Find all .enc files that are not SKILL.md.enc or the manifest itself Dir.glob(File.join(skill_dir, "**", "*.enc")).each do |enc_path| basename = File.basename(enc_path) next if basename == "SKILL.md.enc" next if basename == "MANIFEST.enc.json" # Relative path from skill_dir, stripping the .enc suffix rel_enc = enc_path.sub("#{skill_dir}/", "") # e.g. "scripts/analyze.rb.enc" rel_plain = rel_enc.sub(/\.enc\z/, "") # e.g. "scripts/analyze.rb" plaintext = if manifest # Read manifest entry using the relative plain path = manifest["files"] && manifest["files"][rel_plain] raise "File '#{rel_plain}' not found in MANIFEST.enc.json" unless skill_id = manifest["skill_id"] skill_version_id = manifest["skill_version_id"] key = fetch_decryption_key(skill_id: skill_id, skill_version_id: skill_version_id) ciphertext = File.binread(enc_path) pt = aes_gcm_decrypt(key, ciphertext, ["iv"], ["tag"]) # Integrity check actual = Digest::SHA256.hexdigest(pt) expected = ["original_checksum"] if expected && actual != expected raise "Checksum mismatch for #{rel_plain}: expected #{expected}, got #{actual}" end pt else # Mock/plain skill: raw bytes File.binread(enc_path).force_encoding("UTF-8") end out_path = File.join(dest_dir, rel_plain) FileUtils.mkdir_p(File.dirname(out_path)) File.write(out_path, plaintext) # Preserve executable permission hint from extension File.chmod(0o700, out_path) written << rel_plain end written rescue Errno::ENOENT => e raise "Brand skill file not found: #{e.}" rescue JSON::ParserError => e raise "Invalid MANIFEST.enc.json: #{e.}" end |
#decrypt_skill_content(encrypted_path) ⇒ String
Decrypt an encrypted brand skill file and return its content in memory.
Security model:
- Skill files are AES-256-GCM encrypted. Each skill directory contains a
MANIFEST.enc.json that stores per-file IV, auth tag, checksum, and the
skill_version_id needed to request the decryption key from the server.
- Decryption keys are requested from the server once and cached in memory
(never written to disk). Subsequent calls for the same skill version are
served entirely from cache without network I/O.
- Decrypted content exists only in memory and is never written to disk.
Fallback for mock/plain skills:
When no MANIFEST.enc.json exists in the skill directory, the method falls
back to reading the .enc file as raw UTF-8 bytes (mock/dev mode).
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 |
# File 'lib/clacky/brand_config.rb', line 753 def decrypt_skill_content(encrypted_path) raise "License not activated — cannot decrypt brand skill" unless activated? skill_dir = File.dirname(encrypted_path) manifest_path = File.join(skill_dir, "MANIFEST.enc.json") # Fall back to plain-bytes mode when no MANIFEST present (mock skills). unless File.exist?(manifest_path) raw = File.binread(encrypted_path) return raw.force_encoding("UTF-8") end # Read and parse the manifest manifest = JSON.parse(File.read(manifest_path)) skill_id = manifest["skill_id"] skill_version_id = manifest["skill_version_id"] raise "MANIFEST.enc.json missing skill_id" unless skill_id raise "MANIFEST.enc.json missing skill_version_id" unless skill_version_id # Derive the relative file path (e.g. "SKILL.md") from the .enc filename enc_basename = File.basename(encrypted_path) # "SKILL.md.enc" file_path = enc_basename.sub(/\.enc\z/, "") # "SKILL.md" = manifest["files"] && manifest["files"][file_path] raise "File '#{file_path}' not found in MANIFEST.enc.json" unless # Fetch decryption key — served from in-memory cache when available key = fetch_decryption_key(skill_id: skill_id, skill_version_id: skill_version_id) # Decrypt using AES-256-GCM ciphertext = File.binread(encrypted_path) plaintext = aes_gcm_decrypt(key, ciphertext, ["iv"], ["tag"]) # Integrity check actual = Digest::SHA256.hexdigest(plaintext) expected = ["original_checksum"] if expected && actual != expected raise "Checksum mismatch for #{file_path}: " \ "expected #{expected}, got #{actual}" end plaintext rescue Errno::ENOENT => e raise "Brand skill file not found: #{e.}" rescue JSON::ParserError => e raise "Invalid MANIFEST.enc.json: #{e.}" end |
#expired? ⇒ Boolean
Returns true when the license has passed its expiry date.
92 93 94 95 96 |
# File 'lib/clacky/brand_config.rb', line 92 def expired? return false if @license_expires_at.nil? Time.now.utc > @license_expires_at end |
#fetch_brand_skills! ⇒ Object
Fetch the brand skills list from the OpenClacky Cloud API. Requires an activated license. Returns { success: bool, skills: [], error: }.
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 |
# File 'lib/clacky/brand_config.rb', line 443 def fetch_brand_skills! return { success: false, error: "License not activated", skills: [] } unless activated? user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature } response = api_post("/api/v1/licenses/skills", payload) if response[:success] body = response[:data] # Merge local installed version info into each skill installed = installed_brand_skills skills = (body["skills"] || []).map do |skill| # Normalize name to valid skill name format; prefer the matching local installed dir name normalized = skill["name"].to_s.downcase.gsub(/[\s_]+/, "-").gsub(/[^a-z0-9-]/, "").gsub(/-+/, "-") name = installed.keys.find { |k| k == normalized } || normalized local = installed[name] # The authoritative "latest" version lives in latest_version.version when present, # falling back to the top-level version field for older API responses. latest_ver = (skill["latest_version"] || {})["version"] || skill["version"] # Only flag needs_update when the server has a strictly newer version than local. # If local >= latest (e.g. a dev build), suppress the update badge. needs_update = local ? version_older?(local["version"], latest_ver) : false skill.merge( "name" => name, "installed_version" => local ? local["version"] : nil, "needs_update" => needs_update ) end { success: true, skills: skills, expires_at: body["expires_at"] } else { success: false, error: response[:error] || "Failed to fetch skills", skills: [] } end end |
#fetch_my_skills! ⇒ Object
Fetch the public store skills list from the OpenClacky Cloud API. Requires an activated license for HMAC authentication. Passes scope: “store” to retrieve platform-wide published public skills (not filtered by the authenticated user’s own skills). Returns { success: bool, skills: [], error: }.
Fetch the creator’s own published skills from the platform API. Uses GET /api/v1/client/skills (HMAC-signed, system license only). Returns { success: bool, skills: [], error: }.
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/clacky/brand_config.rb', line 376 def fetch_my_skills! return { success: false, error: "License not activated", skills: [] } unless activated? return { success: false, error: "User license required", skills: [] } unless user_licensed? user_id = @license_user_id.to_s key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) query = URI.encode_www_form( key_hash: key_hash, user_id: user_id, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature ) response = platform_client.get("/api/v1/client/skills?#{query}") if response[:success] skills = response[:data]["skills"] || [] { success: true, skills: skills } else { success: false, error: response[:error] || "Failed to fetch skills", skills: [] } end rescue StandardError => e { success: false, error: "Network error: #{e.}", skills: [] } end |
#fetch_store_skills! ⇒ Object
Each skill in the returned array is a hash with at minimum:
"name", "description", "icon", "repo"
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 |
# File 'lib/clacky/brand_config.rb', line 410 def fetch_store_skills! return { success: false, error: "License not activated", skills: [] } unless activated? user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature, scope: "store" } response = api_post("/api/v1/licenses/skills", payload) if response[:success] body = response[:data] skills = body["skills"] || [] { success: true, skills: skills } else { success: false, error: response[:error] || "Failed to fetch store skills", skills: [] } end end |
#grace_period_exceeded? ⇒ Boolean
Returns true when the grace period for missed heartbeats has expired.
112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/clacky/brand_config.rb', line 112 def grace_period_exceeded? if @license_last_heartbeat.nil? Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)") return false end elapsed = Time.now.utc - @license_last_heartbeat exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}") exceeded end |
#heartbeat! ⇒ Object
Send a heartbeat to the API and update last_heartbeat timestamp. Returns a result hash: { success: bool, message: String }
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/clacky/brand_config.rb', line 235 def heartbeat! unless activated? Clacky::Logger.debug("[Brand] heartbeat! skipped — license not activated") return { success: false, message: "License not activated" } end Clacky::Logger.info("[Brand] heartbeat! sending — last_heartbeat=#{@license_last_heartbeat&.iso8601 || "nil"} expires_at=#{@license_expires_at&.iso8601 || "nil"}") user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature } response = api_post("/api/v1/licenses/heartbeat", payload) if response[:success] @license_last_heartbeat = Time.now.utc @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"] apply_distribution(response[:data]["distribution"]) save Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}") { success: true, message: "Heartbeat OK" } else Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}") { success: false, message: response[:error] || "Heartbeat failed" } end end |
#heartbeat_due? ⇒ Boolean
Returns true when a heartbeat should be sent (interval elapsed).
99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/clacky/brand_config.rb', line 99 def heartbeat_due? if @license_last_heartbeat.nil? Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)") return true end elapsed = Time.now.utc - @license_last_heartbeat due = elapsed >= HEARTBEAT_INTERVAL Clacky::Logger.debug("[Brand] heartbeat_due? elapsed=#{elapsed.to_i}s interval=#{HEARTBEAT_INTERVAL}s => #{due}") due end |
#install_brand_skill!(skill_info) ⇒ Object
Install (or update) a single brand skill by downloading and extracting its zip. skill_info: a hash from fetch_brand_skills! with at least name + latest_version.download_url + version
493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 |
# File 'lib/clacky/brand_config.rb', line 493 def install_brand_skill!(skill_info) require "net/http" require "uri" slug = skill_info["name"].to_s.strip version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] url = (skill_info["latest_version"] || {})["download_url"] return { success: false, error: "Missing skill name" } if slug.empty? if url.nil? FileUtils.mkdir_p(File.join(brand_skills_dir, slug)) return { success: false, error: "No download URL" } end require "zip" dest_dir = File.join(brand_skills_dir, slug) FileUtils.mkdir_p(dest_dir) # Download the zip file to a temp path tmp_zip = File.join(brand_skills_dir, "#{slug}.zip") download_file(url, tmp_zip) # Extract into dest_dir (overwrite existing files). # Auto-detect whether the zip has a single root folder to strip. # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x # path-safety restrictions on absolute destination paths. # Uses chunked read + size verification for robustness. Zip::File.open(tmp_zip) do |zip| entries = zip.entries.reject(&:directory?) top_dirs = entries.map { |e| e.name.split("/").first }.uniq has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") } entries.each do |entry| rel_path = if has_root parts = entry.name.split("/") parts[1..].join("/") else entry.name end next if rel_path.nil? || rel_path.empty? out = File.join(dest_dir, rel_path) FileUtils.mkdir_p(File.dirname(out)) # Chunked copy with size verification written = 0 File.open(out, "wb") do |f| entry.get_input_stream do |input| while (chunk = input.read(65536)) f.write(chunk) written += chunk.bytesize end end end # Verify file size matches ZIP entry declaration if written != entry.size raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}" end end end FileUtils.rm_f(tmp_zip) # Record installed version in brand_skills.json (including description for # offline display when the remote API is unreachable). # encrypted: true because the ZIP contains MANIFEST.enc.json + AES-256-GCM encrypted files. record_installed_skill(slug, version, skill_info["description"], encrypted: true, description_zh: skill_info["description_zh"], name_zh: skill_info["name_zh"]) { success: true, name: slug, version: version } rescue StandardError, ScriptError => e { success: false, error: e. } end |
#install_mock_brand_skill!(skill_info) ⇒ Hash
Install a mock brand skill for brand-test mode.
Writes a realistic (but unencrypted) SKILL.md.enc file to the brand skills directory so the full load → decrypt → invoke code-path can be exercised without a real server. The file format intentionally mirrors what the production server will deliver: a binary blob stored with a .enc extension.
In the current mock implementation the “encryption” is an identity transformation (plain UTF-8 bytes) because BrandConfig#decrypt_skill_content is also mocked. Both sides will be replaced together during backend integration.
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 |
# File 'lib/clacky/brand_config.rb', line 585 def install_mock_brand_skill!(skill_info) slug = skill_info["name"].to_s.strip version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] || "1.0.0" name = slug description = skill_info["description"] || "A private brand skill." description_zh = skill_info["description_zh"] || "私有品牌技能。" emoji = skill_info["emoji"] || "⭐" return { success: false, error: "Missing skill name" } if slug.empty? dest_dir = File.join(brand_skills_dir, slug) FileUtils.mkdir_p(dest_dir) # Build a realistic SKILL.md that exercises argument substitution and # the privacy-protection code path. mock_content = <<~SKILL --- name: #{slug} description: "#{description}" --- # #{emoji} #{name} > This is a proprietary brand skill. Its contents are confidential. You are an expert assistant specialising in: **#{name}**. ## Instructions When the user asks you to use this skill, follow these steps: 1. Understand the user's request: $ARGUMENTS 2. Apply your expertise to deliver a high-quality result. 3. Summarise what you did and ask if the user needs adjustments. SKILL # Write as .enc (mock: plain bytes — real encryption added post-backend) enc_path = File.join(dest_dir, "SKILL.md.enc") File.binwrite(enc_path, mock_content.encode("UTF-8")) # encrypted: false — mock skills store plain bytes in .enc, no MANIFEST needed. record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh, name_zh: skill_info["name_zh"]) { success: true, name: slug, version: version } rescue StandardError => e { success: false, error: e. } end |
#installed_brand_skills ⇒ Object
Read the local brand_skills.json metadata, cross-validated against the actual file system. A skill is only considered installed when:
1. It has an entry in brand_skills.json, AND
2. Its skill directory exists under brand_skills_dir, AND
3. That directory contains at least one file (SKILL.md or SKILL.md.enc).
If the JSON record exists but the directory is missing or empty the entry is silently dropped from the result and the JSON file is cleaned up so subsequent installs start from a clean state.
Returns a hash keyed by name: { “version” => “1.0.0”, “name” => “…” }
885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 |
# File 'lib/clacky/brand_config.rb', line 885 def installed_brand_skills path = File.join(brand_skills_dir, "brand_skills.json") return {} unless File.exist?(path) raw = JSON.parse(File.read(path)) # Validate each entry against the actual file system. valid = {} changed = false raw.each do |name, | skill_dir = File.join(brand_skills_dir, name) has_files = Dir.exist?(skill_dir) && Dir.glob(File.join(skill_dir, "SKILL.md{,.enc}")).any? if has_files valid[name] = else # JSON record exists but files are missing — mark for cleanup. changed = true end end # Persist the cleaned-up JSON so stale records don't accumulate. if changed File.write(path, JSON.generate(valid)) end valid rescue StandardError {} end |
#save ⇒ Object
Save current state to ~/.clacky/brand.yml
132 133 134 135 136 |
# File 'lib/clacky/brand_config.rb', line 132 def save FileUtils.mkdir_p(CONFIG_DIR) File.write(BRAND_FILE, to_yaml) FileUtils.chmod(0o600, BRAND_FILE) end |
#sync_brand_skills_async!(on_complete: nil) ⇒ Thread?
Synchronise brand skills in the background.
Fetches the remote skills list and installs any skill whose remote version differs from the locally installed version. The work runs in a daemon Thread so it never blocks the caller (typically Agent startup).
If the license is not activated the method returns immediately without spawning a thread.
644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 |
# File 'lib/clacky/brand_config.rb', line 644 def sync_brand_skills_async!(on_complete: nil) return nil unless activated? return nil if ENV["CLACKY_TEST"] == "1" Thread.new do Thread.current.abort_on_exception = false begin result = fetch_brand_skills! next unless result[:success] # Remove locally installed skills that have been deleted on the remote. # Compare the set of remote skill names against what is installed locally # and delete any skill that no longer exists in the remote catalogue. remote_skill_names = result[:skills].map { |s| s["name"] } installed_brand_skills.each_key do |local_name| delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name) end # Auto-sync is intentionally limited to skills the user has already # installed and that have a newer version available. # New skills are never auto-installed — the user must click Install/Update # explicitly from the Brand Skills panel. skills_needing_update = result[:skills].select { |s| s["needs_update"] } results = skills_needing_update.map do |skill_info| install_brand_skill!(skill_info) end on_complete&.call(results) rescue StandardError # Background sync failures are intentionally swallowed — the agent # continues to work with whatever skills are already installed. end end end |
#to_h ⇒ Object
Returns a hash representation for JSON serialization (e.g. /api/brand).
955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 |
# File 'lib/clacky/brand_config.rb', line 955 def to_h { product_name: @product_name, package_name: @package_name, logo_url: @logo_url, support_contact: @support_contact, support_qr_url: @support_qr_url, theme_color: @theme_color, homepage_url: @homepage_url, branded: branded?, activated: activated?, expired: expired?, license_expires_at: @license_expires_at&.iso8601, user_licensed: user_licensed?, license_user_id: @license_user_id } end |
#to_yaml ⇒ Object
974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 |
# File 'lib/clacky/brand_config.rb', line 974 def to_yaml data = {} data["product_name"] = @product_name if @product_name data["package_name"] = @package_name if @package_name data["logo_url"] = @logo_url if @logo_url data["support_contact"] = @support_contact if @support_contact data["support_qr_url"] = @support_qr_url if @support_qr_url data["theme_color"] = @theme_color if @theme_color data["homepage_url"] = @homepage_url if @homepage_url data["license_key"] = @license_key if @license_key data["license_activated_at"] = @license_activated_at.iso8601 if @license_activated_at data["license_expires_at"] = @license_expires_at.iso8601 if @license_expires_at data["license_last_heartbeat"] = @license_last_heartbeat.iso8601 if @license_last_heartbeat data["device_id"] = @device_id if @device_id # Persist user_id so user-licensed features remain available across restarts data["license_user_id"] = @license_user_id if @license_user_id && !@license_user_id.strip.empty? YAML.dump(data) end |
#upload_skill!(skill_name, zip_data, force: false, version_override: nil) ⇒ Object
Upload (publish) a custom skill ZIP to the OpenClacky Cloud API. Calls POST /api/v1/client/skills (system-license endpoint). zip_data is the raw binary content of the ZIP file. Returns { success: bool, error: String }. Upload a skill ZIP to the OpenClacky cloud. skill_name: skill name string (slug format) zip_data: binary ZIP content force: when true, use PATCH to overwrite an existing skill instead of POST
Returns { success: true, skill: … } or { success: false, error: “…”, already_exists: true/false }
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/clacky/brand_config.rb', line 284 def upload_skill!(skill_name, zip_data, force: false, version_override: nil) return { success: false, error: "License not activated" } unless activated? return { success: false, error: "User license required to upload skills" } unless user_licensed? # The client skills API uses @license_user_id (the platform owner user id), # NOT the user_id embedded in the license key structure. user_id = @license_user_id.to_s key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) # POST /api/v1/client/skills → create (first upload) # PATCH /api/v1/client/skills/:name → update (force overwrite) path = if force "/api/v1/client/skills/#{URI.encode_www_form_component(skill_name)}" else "/api/v1/client/skills" end boundary = "----ClackySkillUpload#{SecureRandom.hex(8)}" crlf = "\r\n" # Build multipart body as a binary string so that null bytes in the ZIP # data are preserved. All parts are joined as binary before sending. parts = [] fields = { "key_hash" => key_hash, "user_id" => user_id, "device_id" => @device_id, "timestamp" => ts, "nonce" => nonce, "signature" => signature, "name" => skill_name.to_s } # Include version override when bumping an existing skill version fields["version"] = version_override.to_s if version_override fields.each do |field, value| parts << "--#{boundary}#{crlf}" parts << "Content-Disposition: form-data; name=\"#{field}\"#{crlf}#{crlf}" parts << value.to_s parts << crlf end # Binary file part parts << "--#{boundary}#{crlf}" parts << "Content-Disposition: form-data; name=\"skill_zip\"; filename=\"#{skill_name}.zip\"#{crlf}" parts << "Content-Type: application/zip#{crlf}#{crlf}" parts << zip_data.b parts << "#{crlf}--#{boundary}--#{crlf}" body_bytes = parts.map(&:b).join # Delegate sending (with retry + failover) to PlatformHttpClient. # Uploads can be slow so we allow a generous 60-second read timeout. result = if force platform_client.multipart_patch(path, body_bytes, boundary, read_timeout: 60) else platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60) end if result[:success] parsed = result[:data] { success: true, skill: parsed["skill"] } else # Propagate structured error from PlatformHttpClient body = result[:data] || {} code = body["code"] || body["error"] errors = body["errors"]&.join(", ") msg = result[:error] || [code, errors].compact.join(": ") msg = "Upload failed" if msg.to_s.strip.empty? # Detect "already exists" conflicts so the caller can offer an overwrite option. already_exists = body["code"].to_s.include?("name_taken") || body["code"].to_s.include?("already") || result[:error].to_s.include?("HTTP 409") { success: false, error: msg, already_exists: already_exists } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#user_licensed? ⇒ Boolean
Returns true when the license is bound to a specific user (user_id present). User-licensed installations gain additional capabilities such as the ability to upload custom skills via the web UI.
127 128 129 |
# File 'lib/clacky/brand_config.rb', line 127 def user_licensed? activated? && !@license_user_id.nil? && !@license_user_id.to_s.strip.empty? end |