openclacky 1.3.6 → 1.3.7

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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45a64960de249e34ee18e67f0ce38888b510a886988d545c58aca558d942bbdb
4
- data.tar.gz: '008d7b7bec8fd7edf43cb9848949637d23783d2ad3a8a2b6199010f9b8ed660d'
3
+ metadata.gz: 48e94ccf83645b5e18be8d5d33582dc128b06824ab226dd314540658b9cc7e4a
4
+ data.tar.gz: 1112ed443505eca99e745838377ebbf4b6f1e96a7c7f83c7bf1cff844b5ddea9
5
5
  SHA512:
6
- metadata.gz: 2f0e9c3fb21691cf9bbfdd6820faaaed86086bebb820278b6104356f92fec4c0e53584a8c3ca40bfd42fb7f82e1f3a484ed404c2b693a47467e9c43d517ed9e8
7
- data.tar.gz: 83cb1fa9ac5a3ae4834bf5c02c344da9db09a5863a4970857f2d233656c66d9b20fde131f0af4b04d23e204a09a978968ae7d2310ae1c3c3ceb8bcb6d721e3c2
6
+ metadata.gz: b0e4b2664bb7990ec91dd7c5598cfb561ce6f931b1f006a962b6cbf5516ee424b3a6f605fd70c3996ad4d0e0bf1a20dfd626d6466020393cc8cf18832008a68a
7
+ data.tar.gz: 2b74eea0a552e5d26545779da2f0a2b1f88812063398ffbf15be913e396d6efe7131d8205bff766939ef252c4ca3b8075ce8e86c855de59774226e3e03bdbfde
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.7] - 2026-07-06
9
+
10
+ ### Added
11
+ - Extension ecosystem: install, manage, and publish extensions with Creator Studio
12
+ - Brand extension and brand badge support for extension agents
13
+ - Hook/profile extension type for intercepting and customizing agent behavior
14
+ - Extension upload CLI for publishing extensions from the command line
15
+ - New session page with improved layout and discoverability
16
+ - Session auto-cleanup hint, skeleton loading, and type filter in trash panel
17
+ - Renew button on brand warning bar for expiring subscriptions
18
+ - i18n tooltips for header theme toggle and trash toolbar buttons
19
+ - Refresh button in header to reload sessions
20
+ - xhigh reasoning effort level for supported models
21
+ - i18n file upload error messages
22
+
23
+ ### Improved
24
+ - Merge Creator Center into ext-studio as a unified extension hub
25
+ - Extension developer experience: scaffold template improvements, badge and author metadata support
26
+ - Share modal UI polish — theme dots on hover, close button hover effect, layout refinement
27
+ - Unify Clacky.* JS namespace and single-segment extension architecture
28
+ - Migrate default agents to default extensions with unified panel visibility
29
+
30
+ ### Fixed
31
+ - Ctrl+C now works properly in TUI auto-answer mode
32
+ - Show-system-skills toggle persists correctly and uses theme accent color
33
+ - Cron display handles every-N-hours with weekdays pattern correctly
34
+ - New-session dropdown arrow button responds to both click and keyboard
35
+ - New cron virtual entry inserts at the correct position relative to pinned sessions
36
+ - File upload error messages use i18n consistently
37
+
8
38
  ## [1.3.6] - 2026-06-30
9
39
 
10
40
  ### Added
data/lib/clacky/agent.rb CHANGED
@@ -51,7 +51,7 @@ module Clacky
51
51
  attr_accessor :pinned
52
52
  attr_accessor :channel_info
53
53
 
54
- REASONING_EFFORTS = %w[low medium high].freeze
54
+ REASONING_EFFORTS = %w[low medium high xhigh].freeze
55
55
 
56
56
  def permission_mode
57
57
  @config&.permission_mode&.to_s || ""
@@ -136,6 +136,8 @@ module Clacky
136
136
  # Free-mode counterpart: branded but not activated → fetch unencrypted skills
137
137
  # via the public endpoint so users get a working install with no serial number.
138
138
  @brand_config.sync_free_skills_async!
139
+ # Brand extensions bundled into the activated license's distribution.
140
+ @brand_config.sync_brand_extensions_async!
139
141
 
140
142
  # Initialize Time Machine
141
143
  init_time_machine
@@ -146,6 +148,11 @@ module Clacky
146
148
  # Load declarative shell hooks from ~/.clacky/hooks.yml
147
149
  ShellHookLoader.load_into(@hooks)
148
150
 
151
+ # Copy ext.yml-contributed hook callbacks (contributes.hooks) onto this
152
+ # agent's hook manager. The callbacks were registered process-wide at
153
+ # boot via ExtensionHookLoader.
154
+ ExtensionHookRegistry.apply_to(@hooks)
155
+
149
156
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
150
157
  Utils::ParserManager.setup!
151
158
 
@@ -6,107 +6,175 @@ module Clacky
6
6
  # Loads and represents an agent profile (system prompt + skill whitelist).
7
7
  #
8
8
  # Lookup order for a profile named "coding":
9
- # 1. ~/.clacky/agents/coding/ (user override)
10
- # 2. <gem>/lib/clacky/default_agents/coding/ (built-in default)
9
+ # 1. ~/.clacky/agents/coding/ (user override, physical dir)
10
+ # 2. extension agent unit with id == "coding" (ext.yml contributes.agents)
11
11
  #
12
- # Each profile directory must contain:
12
+ # Each user profile directory (opt-in override) contains:
13
13
  # - profile.yml — name, description, skills whitelist
14
14
  # - system_prompt.md — agent-specific system prompt content
15
15
  #
16
- # Global files (shared across all agents), also with user-override support:
17
- # - SOUL.md — agent personality/values
18
- # - USER.md — user profile information
19
- # - base_prompt.md universal behavioral rules (todo manager, tool usage, etc.)
16
+ # Global files (shared across all agents) are user-only overrides:
17
+ # - ~/.clacky/agents/SOUL.md — agent personality/values (else DEFAULT_SOUL)
18
+ # - ~/.clacky/agents/USER.md — user profile info (else DEFAULT_USER)
19
+ # The universal behavioural rules (todo manager, tool usage, response style,
20
+ # etc.) live in a bundled resource file at lib/clacky/prompts/base.md.
20
21
  class AgentProfile
21
- DEFAULT_AGENTS_DIR = File.expand_path("../default_agents", __FILE__).freeze
22
22
  USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
23
+ BASE_PROMPT_PATH = File.expand_path("../prompts/base.md", __FILE__).freeze
24
+
25
+ DEFAULT_SOUL = <<~MD.freeze
26
+ You are calm, precise, and helpful. You communicate clearly and concisely.
27
+ You are honest about uncertainty and ask for clarification when needed.
28
+ You take initiative but respect the user's preferences and decisions.
29
+ MD
30
+
31
+ DEFAULT_USER = "(No user profile configured yet. To personalize, create ~/.clacky/agents/USER.md)"
23
32
 
24
33
  attr_reader :name, :description
25
34
 
26
35
  def initialize(name)
27
36
  @name = name.to_s
37
+ result = ExtensionLoader.last_result
38
+ @ext_unit = result&.agents&.find { |u| u.id == @name }
39
+ if @ext_unit.nil?
40
+ result = ExtensionLoader.load_all(force: true)
41
+ @ext_unit = result&.agents&.find { |u| u.id == @name }
42
+ end
28
43
  profile_data = load_profile_yml
29
44
  @description = profile_data["description"] || ""
30
45
  @system_prompt_content = load_agent_file("system_prompt.md")
31
46
  end
32
47
 
33
- # Load a named profile. Raises ArgumentError if profile directory not found.
34
48
  # @param name [String, Symbol] profile name (e.g. "coding", "general")
35
49
  # @return [AgentProfile]
36
50
  def self.load(name)
37
51
  new(name)
38
52
  end
39
53
 
54
+ # List all available agent profiles across user + extension layers.
55
+ # Precedence on id collision: user override → extension unit.
56
+ # @return [Array<Hash>] each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author: }
57
+ def self.all
58
+ out = {}
59
+
60
+ add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author|
61
+ next if id.nil? || id.empty?
62
+ out[id] = {
63
+ id: id,
64
+ title: title,
65
+ title_zh: title_zh,
66
+ description: description,
67
+ description_zh: description_zh,
68
+ source: source,
69
+ order: order,
70
+ layer: layer,
71
+ author: author,
72
+ }
73
+ end
74
+
75
+ ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
76
+ ext_result&.agents&.each do |unit|
77
+ spec = unit.spec || {}
78
+ title = spec["title"].to_s
79
+ title = unit.id if title.empty?
80
+ add.call(
81
+ unit.id, title, spec["title_zh"].to_s,
82
+ spec["description"].to_s, spec["description_zh"].to_s,
83
+ "extension", spec["order"], unit.layer.to_s,
84
+ spec["author"].to_s
85
+ )
86
+ end
87
+
88
+ Dir.glob(File.join(USER_AGENTS_DIR, "*")).sort.each do |path|
89
+ next unless File.directory?(path)
90
+ id = File.basename(path)
91
+ next if id.start_with?("_")
92
+ next unless File.file?(File.join(path, "profile.yml"))
93
+ meta = read_profile_yml(File.join(path, "profile.yml"))
94
+ add.call(
95
+ id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
96
+ meta["description"].to_s, meta["description_zh"].to_s,
97
+ "user", meta["order"], "user",
98
+ meta["author"].to_s.empty? ? "You" : meta["author"].to_s
99
+ )
100
+ end
101
+
102
+ source_rank = { "user" => 0, "extension" => 1 }
103
+ out.values.sort_by { |a| [source_rank[a[:source]] || 9, a[:order] || 999, a[:id]] }
104
+ end
105
+
106
+ private_class_method def self.read_profile_yml(path)
107
+ return {} unless File.file?(path)
108
+ YAML.safe_load(File.read(path)) || {}
109
+ rescue StandardError
110
+ {}
111
+ end
112
+
40
113
  # @return [String] agent-specific system prompt content
41
114
  def system_prompt
42
115
  @system_prompt_content
43
116
  end
44
117
 
45
- # @return [String] base prompt shared by all agents
118
+ # @return [String] base prompt shared by all agents (bundled resource)
46
119
  def base_prompt
47
- load_global_file("base_prompt.md")
120
+ return "" unless File.file?(BASE_PROMPT_PATH)
121
+ File.read(BASE_PROMPT_PATH).strip
48
122
  end
49
123
 
50
- # @return [String] soul content (user override built-in default)
124
+ # @return [String] soul content (user override, else default)
51
125
  def soul
52
- load_global_file("SOUL.md")
126
+ user_path = File.join(USER_AGENTS_DIR, "SOUL.md")
127
+ if File.exist?(user_path) && !File.zero?(user_path)
128
+ File.read(user_path).strip
129
+ else
130
+ DEFAULT_SOUL.strip
131
+ end
53
132
  end
54
133
 
55
- # @return [String] user profile content (user override built-in default)
134
+ # @return [String] user profile content (user override, else default)
56
135
  def user_profile
57
- load_global_file("USER.md")
136
+ user_path = File.join(USER_AGENTS_DIR, "USER.md")
137
+ if File.exist?(user_path) && !File.zero?(user_path)
138
+ File.read(user_path).strip
139
+ else
140
+ DEFAULT_USER
141
+ end
58
142
  end
59
143
 
60
144
  private def load_profile_yml
61
- path = find_agent_file("profile.yml")
62
- raise ArgumentError, "Agent profile '#{@name}' not found. " \
63
- "Looked in #{user_agent_dir} and #{default_agent_dir}" unless path
64
-
65
- YAML.safe_load(File.read(path)) || {}
66
- end
67
-
68
- # Load a file from the agent-specific directory (user override → built-in)
69
- private def load_agent_file(filename)
70
- path = find_agent_file(filename)
71
- return "" unless path
72
-
73
- File.read(path).strip
74
- end
75
-
76
- # Load a global file shared across all agents (user override → built-in)
77
- private def load_global_file(filename)
78
- user_path = File.join(USER_AGENTS_DIR, filename)
79
- default_path = File.join(DEFAULT_AGENTS_DIR, filename)
80
-
81
- path = if File.exist?(user_path) && !File.zero?(user_path)
82
- user_path
83
- elsif File.exist?(default_path)
84
- default_path
85
- end
145
+ user_yml = File.join(user_agent_dir, "profile.yml")
146
+ if File.file?(user_yml)
147
+ return YAML.safe_load(File.read(user_yml)) || {}
148
+ end
86
149
 
87
- return "" unless path
150
+ if @ext_unit
151
+ return {
152
+ "name" => @name,
153
+ "description" => @ext_unit.spec["description"],
154
+ "panels" => @ext_unit.spec["panels"],
155
+ "skills" => @ext_unit.spec["skills"],
156
+ }
157
+ end
88
158
 
89
- File.read(path).strip
159
+ raise ArgumentError, "Agent profile '#{@name}' not found. " \
160
+ "Looked in #{user_agent_dir} and extension registry."
90
161
  end
91
162
 
92
- # Find a file in user override dir first, then built-in default dir
93
- private def find_agent_file(filename)
163
+ # Agent-specific file lookup: user override extension prompt (system_prompt.md only).
164
+ private def load_agent_file(filename)
94
165
  user_path = File.join(user_agent_dir, filename)
95
- default_path = File.join(default_agent_dir, filename)
166
+ return File.read(user_path).strip if File.exist?(user_path) && !File.zero?(user_path)
96
167
 
97
- if File.exist?(user_path) && !File.zero?(user_path)
98
- user_path
99
- elsif File.exist?(default_path)
100
- default_path
168
+ if @ext_unit && filename == "system_prompt.md"
169
+ prompt_abs = @ext_unit.spec["prompt_abs"]
170
+ return File.read(prompt_abs).strip if prompt_abs && File.file?(prompt_abs)
101
171
  end
172
+
173
+ ""
102
174
  end
103
175
 
104
176
  private def user_agent_dir
105
177
  File.join(USER_AGENTS_DIR, @name)
106
178
  end
107
-
108
- private def default_agent_dir
109
- File.join(DEFAULT_AGENTS_DIR, @name)
110
- end
111
179
  end
112
180
  end
@@ -583,13 +583,279 @@ module Clacky
583
583
  { success: false, error: "Network error: #{e.message}" }
584
584
  end
585
585
 
586
- # Fetch the public store skills list from the OpenClacky Cloud API.
587
- # Requires an activated license for HMAC authentication.
588
- # Passes scope: "store" to retrieve platform-wide published public skills
589
- # (not filtered by the authenticated user's own skills).
590
- # Returns { success: bool, skills: [], error: }.
586
+ # ── Extension marketplace (creator publishing via device token) ──
587
+ # Extensions authenticate with the device token stored in identity.yml,
588
+ # which binds this device to a platform account. This is independent of any
589
+ # license publishing only requires the owning user to be a contributor.
590
+ # See platform Api::V1::Client::ExtensionsController.
591
+
592
+ # Upload (publish) a packed extension ZIP to the platform.
593
+ # ext_id: extension slug (matches ext.yml id)
594
+ # zip_data: binary ZIP content produced by `clacky ext pack`
595
+ # force: when true, PATCH an existing extension (new version) instead of POST
596
+ #
597
+ # Returns { success: true, extension: {...} } or
598
+ # { success: false, error: "...", already_exists: Boolean }.
599
+ def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil)
600
+ identity = Clacky::Identity.load
601
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
602
+
603
+ path = if force
604
+ "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
605
+ else
606
+ "/api/v1/client/extensions"
607
+ end
608
+
609
+ fields = { "device_token" => identity.device_token }
610
+ fields["status"] = status.to_s if status
611
+ fields["changelog"] = changelog.to_s if changelog
612
+
613
+ body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data)
614
+
615
+ result = if force
616
+ platform_client.multipart_patch(path, body_bytes, boundary, read_timeout: 60)
617
+ else
618
+ platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60)
619
+ end
620
+
621
+ if result[:success]
622
+ { success: true, extension: result[:data]["extension"] }
623
+ else
624
+ body = result[:data] || {}
625
+ code = body["code"] || body["error"]
626
+ errors = body["errors"]&.join(", ")
627
+ msg = result[:error] || [code, errors].compact.join(": ")
628
+ msg = "Publish failed" if msg.to_s.strip.empty?
629
+ already_exists = body["code"].to_s.include?("taken") ||
630
+ body["code"].to_s.include?("already") ||
631
+ result[:error].to_s.include?("HTTP 409")
632
+ { success: false, error: msg, already_exists: already_exists }
633
+ end
634
+ rescue StandardError => e
635
+ { success: false, error: "Network error: #{e.message}" }
636
+ end
637
+
638
+ # Fetch the creator's own published extensions.
639
+ # Uses GET /api/v1/client/extensions (HMAC-signed, system license only).
640
+ # Returns { success: bool, extensions: [], error: }.
641
+ def fetch_my_extensions!
642
+ identity = Clacky::Identity.load
643
+ return { success: false, error: "Device not bound to a platform account", extensions: [] } unless identity.bound?
644
+
645
+ response = platform_client.get(
646
+ "/api/v1/client/extensions",
647
+ headers: { "Authorization" => "Bearer #{identity.device_token}" }
648
+ )
649
+
650
+ if response[:success]
651
+ { success: true, extensions: response[:data]["extensions"] || [] }
652
+ else
653
+ { success: false, error: response[:error] || "Fetch failed", extensions: [] }
654
+ end
655
+ end
656
+
657
+ # Soft-delete (unpublish) one of the creator's extensions by id/slug.
658
+ # Uses DELETE /api/v1/client/extensions/:id. Returns { success:, error: }.
659
+ def delete_extension!(ext_id)
660
+ identity = Clacky::Identity.load
661
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
662
+
663
+ path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
664
+ response = platform_client.delete(
665
+ path,
666
+ headers: { "Authorization" => "Bearer #{identity.device_token}" }
667
+ )
668
+
669
+ if response[:success]
670
+ { success: true }
671
+ else
672
+ { success: false, error: response[:error] || "Delete failed" }
673
+ end
674
+ end
675
+
676
+ # Search the public extension marketplace. Anonymous — no license required.
677
+ # Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
678
+ def search_extensions!(query: nil, sort: nil)
679
+ params = {}
680
+ params["q"] = query if query && !query.to_s.strip.empty?
681
+ params["sort"] = sort if sort && !sort.to_s.strip.empty?
682
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
683
+ response = platform_client.get("/api/v1/extensions#{qs}")
684
+
685
+ if response[:success]
686
+ { success: true, extensions: response[:data]["extensions"] || [] }
687
+ else
688
+ { success: false, error: response[:error] || "Search failed", extensions: [] }
689
+ end
690
+ rescue StandardError => e
691
+ { success: false, error: "Network error: #{e.message}", extensions: [] }
692
+ end
693
+
694
+ # Fetch a single public marketplace extension's detail (contributes +
695
+ # version history). Anonymous, no license required. Returns
696
+ # { success:, extension:, error: }.
697
+ def extension_detail!(id)
698
+ response = platform_client.get("/api/v1/extensions/#{URI.encode_www_form_component(id.to_s)}")
699
+
700
+ if response[:success]
701
+ { success: true, extension: response[:data]["extension"] }
702
+ else
703
+ { success: false, error: response[:error] || "Not found" }
704
+ end
705
+ rescue StandardError => e
706
+ { success: false, error: "Network error: #{e.message}" }
707
+ end
708
+ # Extensions bundled into the activated license's distribution are free and
709
+ # unencrypted. They are fetched over the same license-HMAC scheme as brand
710
+ # skills and installed into the ExtensionLoader `installed` layer.
711
+
712
+ # Fetch the extensions bundled into the activated license's distribution.
713
+ # Requires an activated license. Returns { success:, extensions: [], error: }.
714
+ # Each extension carries name + latest_version.download_url so
715
+ # install_brand_extension! can consume it directly.
716
+ def fetch_brand_extensions!
717
+ return { success: false, error: "License not activated", extensions: [] } unless activated?
718
+
719
+ user_id = parse_user_id_from_key(@license_key)
720
+ ts = Time.now.utc.to_i.to_s
721
+ nonce = SecureRandom.hex(16)
722
+ message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
723
+
724
+ payload = {
725
+ key_hash: Digest::SHA256.hexdigest(@license_key),
726
+ user_id: user_id.to_s,
727
+ device_id: @device_id,
728
+ timestamp: ts,
729
+ nonce: nonce,
730
+ signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
731
+ }
732
+
733
+ response = api_post("/api/v1/licenses/extensions", payload)
734
+
735
+ if response[:success]
736
+ body = response[:data]
737
+ installed = installed_brand_extensions
738
+ extensions = (body["extensions"] || []).map do |ext|
739
+ slug = ext["name"].to_s
740
+ local = installed[slug]
741
+ latest_ver = (ext["latest_version"] || {})["version"] || ext["version"]
742
+ ext.merge(
743
+ "installed_version" => local ? local["version"] : nil,
744
+ "needs_update" => local ? version_older?(local["version"], latest_ver) : true
745
+ )
746
+ end
747
+ { success: true, extensions: extensions, expires_at: body["expires_at"] }
748
+ else
749
+ { success: false, error: response[:error] || "Failed to fetch extensions", extensions: [] }
750
+ end
751
+ end
752
+
753
+ # Install (or update) a single brand extension by downloading its zip into
754
+ # the ExtensionLoader `installed` layer.
755
+ # ext_info: a hash from fetch_brand_extensions! with at least
756
+ # name + latest_version.download_url + version.
757
+ def install_brand_extension!(ext_info)
758
+ slug = ext_info["name"].to_s.strip
759
+ version = (ext_info["latest_version"] || {})["version"] || ext_info["version"]
760
+ url = (ext_info["latest_version"] || {})["download_url"]
761
+
762
+ return { success: false, error: "Missing extension name" } if slug.empty?
763
+ return { success: false, error: "No download URL" } if url.nil? || url.strip.empty?
764
+
765
+ Clacky::ExtensionPackager.install(url, force: true)
766
+ record_installed_extension(slug, version)
767
+ { success: true, name: slug, version: version }
768
+ rescue StandardError => e
769
+ { success: false, error: e.message }
770
+ end
771
+
772
+ # Synchronise brand extensions in the background for activated installs.
773
+ # Mirrors sync_brand_skills_async! but installs into the extension layer.
774
+ # Unlike brand skills, new extensions are auto-installed because a bundled
775
+ # extension is chosen by the brand administrator, not the end user.
591
776
  #
592
- # Fetch the creator's own published skills from the platform API.
777
+ # @return [Thread, nil]
778
+ def sync_brand_extensions_async!(on_complete: nil)
779
+ return nil unless activated?
780
+ return nil if ENV["CLACKY_TEST"] == "1"
781
+
782
+ Thread.new do
783
+ Thread.current.abort_on_exception = false
784
+
785
+ begin
786
+ result = fetch_brand_extensions!
787
+ next unless result[:success]
788
+
789
+ remote_names = result[:extensions].map { |e| e["name"] }
790
+ installed_brand_extensions.each_key do |local_name|
791
+ delete_brand_extension!(local_name) unless remote_names.include?(local_name)
792
+ end
793
+
794
+ to_install = result[:extensions].select { |e| e["needs_update"] }
795
+ results = to_install.map { |ext_info| install_brand_extension!(ext_info) }
796
+
797
+ Clacky::ExtensionLoader.invalidate_cache! unless results.empty?
798
+ on_complete&.call(results)
799
+ rescue StandardError
800
+ # Background sync failures are intentionally swallowed.
801
+ end
802
+ end
803
+ end
804
+
805
+ # Path to the JSON registry tracking installed brand extension versions.
806
+ def brand_extensions_registry_path
807
+ File.join(File.expand_path(Clacky::ExtensionLoader::INSTALLED_DIR), "brand_extensions.json")
808
+ end
809
+
810
+ # Installed brand extensions keyed by ext_id => { "version" => "..." }.
811
+ # Entries whose on-disk container no longer exists are pruned.
812
+ def installed_brand_extensions
813
+ path = brand_extensions_registry_path
814
+ return {} unless File.exist?(path)
815
+
816
+ raw = JSON.parse(File.read(path))
817
+ valid = {}
818
+ changed = false
819
+ raw.each do |name, meta|
820
+ if Dir.exist?(File.join(File.dirname(path), name))
821
+ valid[name] = meta
822
+ else
823
+ changed = true
824
+ end
825
+ end
826
+ File.write(path, JSON.generate(valid)) if changed
827
+ valid
828
+ rescue StandardError
829
+ {}
830
+ end
831
+
832
+ # Remove a single installed brand extension by id (files + registry entry).
833
+ def delete_brand_extension!(ext_id)
834
+ ext_dir = File.join(File.expand_path(Clacky::ExtensionLoader::INSTALLED_DIR), ext_id)
835
+ FileUtils.rm_rf(ext_dir) if Dir.exist?(ext_dir)
836
+
837
+ path = brand_extensions_registry_path
838
+ if File.exist?(path)
839
+ registry = JSON.parse(File.read(path))
840
+ registry.delete(ext_id)
841
+ File.write(path, JSON.generate(registry))
842
+ end
843
+ Clacky::ExtensionLoader.invalidate_cache!
844
+ rescue StandardError
845
+ # Deletion errors are non-fatal.
846
+ end
847
+
848
+ private def record_installed_extension(ext_id, version)
849
+ path = brand_extensions_registry_path
850
+ FileUtils.mkdir_p(File.dirname(path))
851
+ registry = File.exist?(path) ? (JSON.parse(File.read(path)) rescue {}) : {}
852
+ registry[ext_id] = { "version" => version.to_s }
853
+ File.write(path, JSON.generate(registry))
854
+ end
855
+
856
+ public
857
+
858
+ # Fetch the public store skills list from the OpenClacky Cloud API.
593
859
  # Uses GET /api/v1/client/skills (HMAC-signed, system license only).
594
860
  # Returns { success: bool, skills: [], error: }.
595
861
  def fetch_my_skills!
@@ -1267,6 +1533,43 @@ module Clacky
1267
1533
  false
1268
1534
  end
1269
1535
 
1536
+ # Build the shared HMAC-signed field set for client API calls (skills,
1537
+ # extensions). Proves creator identity via the system user license.
1538
+ private def client_signed_fields
1539
+ user_id = @license_user_id.to_s
1540
+ ts = Time.now.utc.to_i.to_s
1541
+ nonce = SecureRandom.hex(16)
1542
+ message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
1543
+ {
1544
+ "key_hash" => Digest::SHA256.hexdigest(@license_key),
1545
+ "user_id" => user_id,
1546
+ "device_id" => @device_id,
1547
+ "timestamp" => ts,
1548
+ "nonce" => nonce,
1549
+ "signature" => OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
1550
+ }
1551
+ end
1552
+
1553
+ # Assemble a binary multipart/form-data body: text fields + one file part.
1554
+ # Kept binary-safe so null bytes in the ZIP survive. Returns [body, boundary].
1555
+ private def build_multipart(fields, file_field, filename, file_bytes)
1556
+ boundary = "----ClackyMultipart#{SecureRandom.hex(8)}"
1557
+ crlf = "\r\n"
1558
+ parts = []
1559
+ fields.each do |field, value|
1560
+ parts << "--#{boundary}#{crlf}"
1561
+ parts << "Content-Disposition: form-data; name=\"#{field}\"#{crlf}#{crlf}"
1562
+ parts << value.to_s
1563
+ parts << crlf
1564
+ end
1565
+ parts << "--#{boundary}#{crlf}"
1566
+ parts << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"#{crlf}"
1567
+ parts << "Content-Type: application/zip#{crlf}#{crlf}"
1568
+ parts << file_bytes.b
1569
+ parts << "#{crlf}--#{boundary}--#{crlf}"
1570
+ [parts.map(&:b).join, boundary]
1571
+ end
1572
+
1270
1573
  # Instance-level delegate so fetch_brand_skills! can call version_older? directly.
1271
1574
  private def version_older?(installed, latest)
1272
1575
  self.class.version_older?(installed, latest)