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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent.rb +8 -1
- data/lib/clacky/agent_profile.rb +121 -53
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -57
- data/lib/clacky/default_extensions/coding/ext.yml +18 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
- data/lib/clacky/default_extensions/general/ext.yml +18 -0
- data/lib/clacky/default_extensions/git/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
- data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
- data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
- data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
- data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
- data/lib/clacky/extension/api_loader.rb +136 -0
- data/lib/clacky/extension/cli_commands.rb +226 -0
- data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
- data/lib/clacky/extension/hook_loader.rb +77 -0
- data/lib/clacky/extension/loader.rb +483 -0
- data/lib/clacky/extension/packager.rb +226 -0
- data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
- data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
- data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
- data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
- data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
- data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
- data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
- data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
- data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
- data/lib/clacky/extension/scaffold.rb +55 -0
- data/lib/clacky/extension/verifier.rb +196 -0
- data/lib/clacky/identity.rb +70 -0
- data/lib/clacky/locales/en.rb +19 -1
- data/lib/clacky/locales/zh.rb +19 -1
- data/lib/clacky/platform_http_client.rb +18 -15
- data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
- data/lib/clacky/server/channel.rb +6 -0
- data/lib/clacky/server/http_server.rb +313 -144
- data/lib/clacky/server/session_registry.rb +9 -2
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/ui_controller.rb +9 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +748 -573
- data/lib/clacky/web/app.js +63 -15
- data/lib/clacky/web/auth.js +2 -0
- data/lib/clacky/web/components/notify.js +2 -0
- data/lib/clacky/web/components/sidebar.js +1 -5
- data/lib/clacky/web/core/ext.js +316 -79
- data/lib/clacky/web/features/backup/store.js +1 -0
- data/lib/clacky/web/features/backup/view.js +0 -1
- data/lib/clacky/web/features/brand/view.js +13 -5
- data/lib/clacky/web/features/extensions/store.js +184 -0
- data/lib/clacky/web/features/extensions/view.js +389 -0
- data/lib/clacky/web/features/new-session/store.js +186 -0
- data/lib/clacky/web/features/new-session/view.js +319 -0
- data/lib/clacky/web/features/share/view.js +63 -14
- data/lib/clacky/web/features/skills/store.js +3 -1
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +12 -0
- data/lib/clacky/web/features/trash/store.js +24 -6
- data/lib/clacky/web/features/trash/view.js +46 -9
- data/lib/clacky/web/features/workspace/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +0 -1
- data/lib/clacky/web/i18n.js +118 -106
- data/lib/clacky/web/index.html +138 -188
- data/lib/clacky/web/sessions.js +64 -269
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +2 -0
- data/lib/clacky/web/theme.js +2 -0
- data/lib/clacky/web/ws-dispatcher.js +1 -0
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +55 -24
- data/benchmark/runner.rb +0 -441
- data/lib/clacky/api_extension_loader.rb +0 -168
- data/lib/clacky/default_agents/SOUL.md +0 -3
- data/lib/clacky/default_agents/USER.md +0 -1
- data/lib/clacky/default_agents/coding/profile.yml +0 -5
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_agents/general/profile.yml +0 -2
- data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
- data/lib/clacky/web/features/creator/store.js +0 -81
- data/lib/clacky/web/features/creator/view.js +0 -380
- /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48e94ccf83645b5e18be8d5d33582dc128b06824ab226dd314540658b9cc7e4a
|
|
4
|
+
data.tar.gz: 1112ed443505eca99e745838377ebbf4b6f1e96a7c7f83c7bf1cff844b5ddea9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/clacky/agent_profile.rb
CHANGED
|
@@ -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/
|
|
10
|
-
# 2.
|
|
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
|
|
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)
|
|
17
|
-
# - SOUL.md — agent personality/values
|
|
18
|
-
# - USER.md — user profile
|
|
19
|
-
#
|
|
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
|
-
|
|
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
|
|
124
|
+
# @return [String] soul content (user override, else default)
|
|
51
125
|
def soul
|
|
52
|
-
|
|
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
|
|
134
|
+
# @return [String] user profile content (user override, else default)
|
|
56
135
|
def user_profile
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
+
raise ArgumentError, "Agent profile '#{@name}' not found. " \
|
|
160
|
+
"Looked in #{user_agent_dir} and extension registry."
|
|
90
161
|
end
|
|
91
162
|
|
|
92
|
-
#
|
|
93
|
-
private def
|
|
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
|
-
|
|
166
|
+
return File.read(user_path).strip if File.exist?(user_path) && !File.zero?(user_path)
|
|
96
167
|
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
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
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -583,13 +583,279 @@ module Clacky
|
|
|
583
583
|
{ success: false, error: "Network error: #{e.message}" }
|
|
584
584
|
end
|
|
585
585
|
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
590
|
-
#
|
|
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
|
-
#
|
|
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)
|