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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "open-uri"
|
|
6
|
+
require "zip"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
# Packs a local extension container into a distributable zip, and installs a
|
|
10
|
+
# zip (local path or URL) into the `installed` layer.
|
|
11
|
+
#
|
|
12
|
+
# These are the local halves of the publish/marketplace flow: `pack` produces
|
|
13
|
+
# the artifact that later gets uploaded, `install` is what a download performs.
|
|
14
|
+
# Both are pure filesystem operations with no network dependency beyond the
|
|
15
|
+
# optional URL fetch in `install`.
|
|
16
|
+
module ExtensionPackager
|
|
17
|
+
MANIFEST = "ext.yml"
|
|
18
|
+
MAX_ZIP_SIZE = 50 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
Result = Struct.new(:ext_id, :path, :units, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Zip up a single container from the local layer into `out_dir`.
|
|
26
|
+
# Runs verify first (blocking on errors) and refuses protected/encrypted
|
|
27
|
+
# units — those are produced by the platform's encryption pipeline, not
|
|
28
|
+
# hand-packed. Returns a Result with the produced zip path.
|
|
29
|
+
def pack(ext_id, source_dir: Clacky::ExtensionLoader::LOCAL_DIR, out_dir: Dir.pwd)
|
|
30
|
+
slug = ext_id.to_s.strip
|
|
31
|
+
raise Error, "invalid extension id: #{ext_id.inspect}" if slug.empty?
|
|
32
|
+
|
|
33
|
+
container_dir = File.join(source_dir, slug)
|
|
34
|
+
unless File.directory?(container_dir) && File.file?(File.join(container_dir, MANIFEST))
|
|
35
|
+
raise Error, "no container found at #{container_dir} (expected #{MANIFEST})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
verify_container!(slug, container_dir)
|
|
39
|
+
refuse_protected!(slug, container_dir)
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(out_dir)
|
|
42
|
+
zip_path = File.join(out_dir, "#{slug}.zip")
|
|
43
|
+
File.delete(zip_path) if File.exist?(zip_path)
|
|
44
|
+
|
|
45
|
+
write_zip(container_dir, zip_path)
|
|
46
|
+
|
|
47
|
+
Result.new(ext_id: slug, path: zip_path, units: nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Install a packed container into the `installed` layer. `source` is a
|
|
51
|
+
# local zip path or an http(s) URL. Validates the archive (single top
|
|
52
|
+
# container with an ext.yml, no path traversal), extracts it, then runs
|
|
53
|
+
# verify on the resolved installed layer.
|
|
54
|
+
def install(source, installed_dir: Clacky::ExtensionLoader::INSTALLED_DIR, force: false)
|
|
55
|
+
Dir.mktmpdir("clacky-ext-install") do |tmp|
|
|
56
|
+
zip_path = local_zip_for(source, tmp)
|
|
57
|
+
extract_root = File.join(tmp, "unpacked")
|
|
58
|
+
FileUtils.mkdir_p(extract_root)
|
|
59
|
+
|
|
60
|
+
extract_zip(zip_path, extract_root)
|
|
61
|
+
|
|
62
|
+
ext_id, container_src = locate_container(extract_root)
|
|
63
|
+
|
|
64
|
+
target = File.join(installed_dir, ext_id)
|
|
65
|
+
if Dir.exist?(target) && !force
|
|
66
|
+
raise Error, "extension #{ext_id.inspect} already installed at #{target} (pass force: true to overwrite)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
FileUtils.mkdir_p(installed_dir)
|
|
70
|
+
FileUtils.rm_rf(target) if Dir.exist?(target)
|
|
71
|
+
FileUtils.cp_r(container_src, target)
|
|
72
|
+
|
|
73
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
74
|
+
units = verify_installed(ext_id, installed_dir)
|
|
75
|
+
|
|
76
|
+
Result.new(ext_id: ext_id, path: target, units: units)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Verify only the target container in isolation: symlink it into a temp
|
|
81
|
+
# root so sibling containers in the real local dir don't pollute the run.
|
|
82
|
+
private def verify_container!(slug, container_dir)
|
|
83
|
+
Dir.mktmpdir("clacky-ext-verify") do |root|
|
|
84
|
+
FileUtils.ln_s(File.expand_path(container_dir), File.join(root, slug))
|
|
85
|
+
result = Clacky::ExtensionLoader.load_all(layers: { local: root }, force: true)
|
|
86
|
+
issues = Clacky::ExtensionVerifier.verify(result)
|
|
87
|
+
errors = issues.select { |i| i.level == :error && i.ext == slug }
|
|
88
|
+
next if errors.empty?
|
|
89
|
+
|
|
90
|
+
detail = errors.map { |e| " - #{e.code}: #{e.message}" }.join("\n")
|
|
91
|
+
raise Error, "cannot pack #{slug}: verify found errors:\n#{detail}"
|
|
92
|
+
end
|
|
93
|
+
ensure
|
|
94
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Packing is for authored (open) containers. Anything already protected or
|
|
98
|
+
# carrying encrypted skills is produced by the platform pipeline; refuse
|
|
99
|
+
# to re-pack it so we never smuggle marketplace artifacts out of band.
|
|
100
|
+
private def refuse_protected!(slug, container_dir)
|
|
101
|
+
manifest = YAML.safe_load(File.read(File.join(container_dir, MANIFEST)), permitted_classes: [Symbol]) || {}
|
|
102
|
+
origin = (manifest["origin"] || "self").to_s
|
|
103
|
+
if origin == "marketplace"
|
|
104
|
+
raise Error, "refusing to pack #{slug}: origin=marketplace is produced by the platform, not hand-packed"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
enc = Dir.glob(File.join(container_dir, "**", "SKILL.md.enc"))
|
|
108
|
+
return if enc.empty?
|
|
109
|
+
|
|
110
|
+
raise Error, "refusing to pack #{slug}: contains encrypted skill(s): #{enc.map { |p| relative(container_dir, p) }.join(', ')}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private def write_zip(container_dir, zip_path)
|
|
114
|
+
Zip::File.open(zip_path, create: true) do |zip|
|
|
115
|
+
files = Dir.glob(File.join(container_dir, "**", "*"), File::FNM_DOTMATCH)
|
|
116
|
+
files.each do |abs|
|
|
117
|
+
base = File.basename(abs)
|
|
118
|
+
next if base == "." || base == ".."
|
|
119
|
+
|
|
120
|
+
rel = relative(container_dir, abs)
|
|
121
|
+
entry = File.join(File.basename(container_dir), rel)
|
|
122
|
+
if File.directory?(abs)
|
|
123
|
+
zip.mkdir(entry) unless zip.find_entry(entry)
|
|
124
|
+
else
|
|
125
|
+
zip.add(entry, abs)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private def local_zip_for(source, tmp)
|
|
132
|
+
src = source.to_s
|
|
133
|
+
if src.match?(%r{\Ahttps?://})
|
|
134
|
+
dest = File.join(tmp, "download.zip")
|
|
135
|
+
download(src, dest)
|
|
136
|
+
dest
|
|
137
|
+
else
|
|
138
|
+
path = File.expand_path(src)
|
|
139
|
+
raise Error, "zip not found: #{path}" unless File.file?(path)
|
|
140
|
+
path
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private def download(url, dest)
|
|
145
|
+
total = 0
|
|
146
|
+
URI.open(url, "rb") do |io| # rubocop:disable Security/Open
|
|
147
|
+
File.open(dest, "wb") do |out|
|
|
148
|
+
while (chunk = io.read(64 * 1024))
|
|
149
|
+
total += chunk.bytesize
|
|
150
|
+
raise Error, "download exceeds #{MAX_ZIP_SIZE} bytes" if total > MAX_ZIP_SIZE
|
|
151
|
+
out.write(chunk)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
rescue OpenURI::HTTPError, SocketError => e
|
|
156
|
+
raise Error, "failed to download #{url}: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private def extract_zip(zip_path, dest_root)
|
|
160
|
+
total = 0
|
|
161
|
+
Zip::File.open(zip_path) do |zip|
|
|
162
|
+
zip.each do |entry|
|
|
163
|
+
safe = safe_join(dest_root, entry.name)
|
|
164
|
+
total += entry.size.to_i
|
|
165
|
+
raise Error, "archive expands beyond #{MAX_ZIP_SIZE} bytes" if total > MAX_ZIP_SIZE
|
|
166
|
+
|
|
167
|
+
if entry.directory?
|
|
168
|
+
FileUtils.mkdir_p(safe)
|
|
169
|
+
else
|
|
170
|
+
FileUtils.mkdir_p(File.dirname(safe))
|
|
171
|
+
File.binwrite(safe, entry.get_input_stream.read)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Reject zip-slip: the resolved path must stay inside dest_root.
|
|
178
|
+
private def safe_join(dest_root, name)
|
|
179
|
+
path = File.expand_path(File.join(dest_root, name))
|
|
180
|
+
root = File.expand_path(dest_root)
|
|
181
|
+
unless path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
182
|
+
raise Error, "unsafe path in archive: #{name.inspect}"
|
|
183
|
+
end
|
|
184
|
+
path
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A valid archive holds exactly one top-level directory that is the
|
|
188
|
+
# container (has ext.yml). Returns [ext_id, absolute_container_dir].
|
|
189
|
+
private def locate_container(root)
|
|
190
|
+
children = Dir.children(root).reject { |c| c.start_with?(".") }
|
|
191
|
+
dirs = children.select { |c| File.directory?(File.join(root, c)) }
|
|
192
|
+
|
|
193
|
+
if File.file?(File.join(root, MANIFEST))
|
|
194
|
+
return [File.basename(File.expand_path(root)), root]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if dirs.size == 1 && File.file?(File.join(root, dirs.first, MANIFEST))
|
|
198
|
+
return [dirs.first, File.join(root, dirs.first)]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
raise Error, "archive does not contain a single container with #{MANIFEST}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private def verify_installed(ext_id, installed_dir)
|
|
205
|
+
layers =
|
|
206
|
+
if installed_dir == Clacky::ExtensionLoader::INSTALLED_DIR
|
|
207
|
+
Clacky::ExtensionLoader.default_layers
|
|
208
|
+
else
|
|
209
|
+
{ installed: installed_dir }
|
|
210
|
+
end
|
|
211
|
+
result = Clacky::ExtensionLoader.load_all(layers: layers, force: true)
|
|
212
|
+
issues = Clacky::ExtensionVerifier.verify(result)
|
|
213
|
+
errors = issues.select { |i| i.level == :error && i.ext == ext_id }
|
|
214
|
+
unless errors.empty?
|
|
215
|
+
detail = errors.map { |e| " - #{e.code}: #{e.message}" }.join("\n")
|
|
216
|
+
raise Error, "installed #{ext_id} but verify found errors:\n#{detail}"
|
|
217
|
+
end
|
|
218
|
+
result.units.select { |u| u.ext_id == ext_id }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private def relative(base, abs)
|
|
222
|
+
Pathname.new(abs).relative_path_from(Pathname.new(base)).to_s
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -53,10 +53,55 @@ module Clacky
|
|
|
53
53
|
apply_one(patch_dir, meta_path, result)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
|
+
load_extension_patches(result)
|
|
56
57
|
@last_result = result
|
|
57
58
|
result
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# Apply patches contributed by ext.yml containers (contributes.patches).
|
|
62
|
+
# ext.yml-driven patches use a flat shape (target + file [+ fingerprint])
|
|
63
|
+
# rather than the per-dir meta.yml + patch.rb layout, since the container
|
|
64
|
+
# author already owns the upgrade lifecycle. If `fingerprint` is given in
|
|
65
|
+
# ext.yml we still verify it; if absent we trust the author and require
|
|
66
|
+
# the file directly. Errors are isolated to that single patch.
|
|
67
|
+
def load_extension_patches(result)
|
|
68
|
+
units = Array(Clacky::ExtensionLoader.last_result&.patches)
|
|
69
|
+
units.each do |unit|
|
|
70
|
+
target = unit.spec["target"]
|
|
71
|
+
file_abs = unit.spec["file_abs"]
|
|
72
|
+
recorded = unit.spec["fingerprint"].to_s
|
|
73
|
+
id = "#{unit.ext_id}/#{File.basename(unit.spec['file'], '.rb')}"
|
|
74
|
+
|
|
75
|
+
if recorded.empty?
|
|
76
|
+
require file_abs
|
|
77
|
+
result.applied << id
|
|
78
|
+
log(:info, id, "applied → #{target} (no fingerprint, trusted)")
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
current = fingerprint(target)
|
|
83
|
+
if current != recorded
|
|
84
|
+
reason = "fingerprint mismatch — upstream code for #{target} changed"
|
|
85
|
+
if unit.spec["on_mismatch"] == "warn"
|
|
86
|
+
result.skipped << [id, "#{reason} (kept, not applied)"]
|
|
87
|
+
log(:warn, id, result.skipped.last[1])
|
|
88
|
+
else
|
|
89
|
+
result.skipped << [id, "#{reason} — disabled"]
|
|
90
|
+
log(:warn, id, result.skipped.last[1])
|
|
91
|
+
end
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
require file_abs
|
|
96
|
+
result.applied << id
|
|
97
|
+
log(:info, id, "applied → #{target}")
|
|
98
|
+
rescue StandardError, ScriptError => e
|
|
99
|
+
fallback_id = "#{unit&.ext_id}/#{File.basename(unit&.spec&.[]('file').to_s, '.rb')}"
|
|
100
|
+
result.skipped << [fallback_id, e.message]
|
|
101
|
+
log(:warn, fallback_id, e.message)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
60
105
|
def last_result
|
|
61
106
|
@last_result || load_all
|
|
62
107
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "ostruct"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module ExtensionScaffold
|
|
9
|
+
# Copy a template directory into `target`, rendering `.erb` files with
|
|
10
|
+
# ERB and substituting `__key__` placeholders in path segments. Non-erb
|
|
11
|
+
# files are copied byte-for-byte.
|
|
12
|
+
#
|
|
13
|
+
# Placeholders in filenames use the `__slug__` form (double underscores
|
|
14
|
+
# around the key) — any locals key can be referenced this way.
|
|
15
|
+
class TemplateRenderer
|
|
16
|
+
PATH_PLACEHOLDER = /__([a-z_][a-z0-9_]*)__/.freeze
|
|
17
|
+
|
|
18
|
+
def self.render(template_dir:, target:, locals: {})
|
|
19
|
+
new(template_dir, target, locals).render
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(template_dir, target, locals)
|
|
23
|
+
@template_dir = File.expand_path(template_dir)
|
|
24
|
+
@target = target
|
|
25
|
+
@locals = locals
|
|
26
|
+
@binding = OpenStruct.new(locals).instance_eval { binding }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render
|
|
30
|
+
raise ArgumentError, "template dir not found: #{@template_dir}" unless Dir.exist?(@template_dir)
|
|
31
|
+
|
|
32
|
+
Dir.glob(File.join(@template_dir, "**", "{*,.[!.]*}"), File::FNM_DOTMATCH).sort.each do |src|
|
|
33
|
+
next if [".", ".."].include?(File.basename(src))
|
|
34
|
+
|
|
35
|
+
rel = src.sub(/\A#{Regexp.escape(@template_dir)}\/?/, "")
|
|
36
|
+
next if rel.empty?
|
|
37
|
+
|
|
38
|
+
dest = File.join(@target, substitute_path(rel))
|
|
39
|
+
|
|
40
|
+
if File.directory?(src)
|
|
41
|
+
FileUtils.mkdir_p(dest)
|
|
42
|
+
elsif File.extname(src) == ".erb"
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
44
|
+
File.write(dest.sub(/\.erb\z/, ""), ERB.new(File.read(src), trim_mode: "-").result(@binding))
|
|
45
|
+
else
|
|
46
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
47
|
+
FileUtils.cp(src, dest)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@target
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def substitute_path(rel)
|
|
55
|
+
rel.gsub(PATH_PLACEHOLDER) do
|
|
56
|
+
key = Regexp.last_match(1).to_sym
|
|
57
|
+
raise ArgumentError, "unknown path placeholder: __#{key}__" unless @locals.key?(key)
|
|
58
|
+
|
|
59
|
+
@locals[key].to_s
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# <%= slug %>
|
|
2
|
+
|
|
3
|
+
Kitchen-sink reference extension generated by `clacky ext new --full`.
|
|
4
|
+
It exercises every `contributes` type at once so you can copy-paste
|
|
5
|
+
the parts you actually need into a real extension.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- `panels/dashboard/view.js` — WebUI panel. Also demonstrates every UI hook at once:
|
|
10
|
+
a full-page workspace opened from three sidebar slots
|
|
11
|
+
(`sidebar.nav.top` above Sessions, `sidebar.nav` between
|
|
12
|
+
Sessions and Config, `sidebar.nav.bottom` at the end of the
|
|
13
|
+
rail — hash `#ext/<%= slug %>`), plus `session.banner`,
|
|
14
|
+
`session.aside`, and `session.composer` (quick-action
|
|
15
|
+
buttons that submit slash commands).
|
|
16
|
+
- `api/handler.rb` — HTTP backend mounted at `/api/ext/<%= slug %>/`
|
|
17
|
+
(routes `/dashboard`, `/stats`)
|
|
18
|
+
- `skills/<%= slug %>-skill/SKILL.md` — a skill contributed to the agent
|
|
19
|
+
- `agents/designer.md` — a custom agent (`--agent designer`) that owns the panel + skill
|
|
20
|
+
- `channels/noop.rb` — a no-op IM channel adapter (`:<%= slug %>_noop`)
|
|
21
|
+
- `patches/audit.rb` — a monkey-patch on `Clacky::Tools::Terminal#execute`
|
|
22
|
+
- `hooks/audit.rb` — a `before_tool_use` hook callback
|
|
23
|
+
|
|
24
|
+
## Verify
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
clacky ext verify # schema + reference integrity check
|
|
28
|
+
clacky ext list # see all 7 units listed under <%= slug %>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Try it
|
|
32
|
+
|
|
33
|
+
- Reload the WebUI page → three sidebar entries appear:
|
|
34
|
+
**<%= slug %> console** at the top, **<%= slug %> menu (middle)** between
|
|
35
|
+
Sessions and Config, **<%= slug %> footer link** at the very bottom
|
|
36
|
+
(all global; every agent sees them)
|
|
37
|
+
- Click any of them → main area switches to the full-page workspace at `#ext/<%= slug %>` (Load stats works)
|
|
38
|
+
- Switch to the `designer` agent → see the **<%= slug %>** tab in the aside, plus a banner and quick-action buttons above the input
|
|
39
|
+
- Click a quick-action button → it fills `#user-input` with `/<%= slug %>-skill …` and submits
|
|
40
|
+
- `clacky --agent designer "say hi"` — uses the contributed skill
|
|
41
|
+
- Run any tool from chat → see `[ext-hook] before_tool_use` in the log
|
|
42
|
+
- Run a shell tool → see `[ext-audit] terminal.execute` from the patch
|
|
43
|
+
|
|
44
|
+
## Trim it down
|
|
45
|
+
|
|
46
|
+
When you know which contributes types you actually want, delete the
|
|
47
|
+
others (and remove their entries from `ext.yml`). `clacky ext verify`
|
|
48
|
+
will tell you if anything is left dangling.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# API backend for "<%= slug %>". Mounted at /api/ext/<%= slug %>/.
|
|
4
|
+
# Group routes by feature (panel, standalone endpoint, webhook, ...) —
|
|
5
|
+
# the mount point is shared, but paths keep them cleanly separated.
|
|
6
|
+
class <%= const_prefix %>Ext < Clacky::ApiExtension
|
|
7
|
+
# ── Dashboard panel backend — /api/ext/<%= slug %>/dashboard/*
|
|
8
|
+
get "/dashboard" do
|
|
9
|
+
json(panel: "dashboard", at: Time.now.utc.iso8601)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# ── Standalone stats endpoint — /api/ext/<%= slug %>/stats
|
|
13
|
+
get "/stats" do
|
|
14
|
+
json(uptime: Process.clock_gettime(Process::CLOCK_MONOTONIC).round(2))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Demo channel adapter for `:<%= slug %>_noop`. It doesn't actually
|
|
4
|
+
# connect anywhere — it exists to show the wiring. Real adapters
|
|
5
|
+
# (see the built-in Feishu / WeCom / Telegram / Discord ones) open
|
|
6
|
+
# a long-poll or webhook and translate messages both ways.
|
|
7
|
+
module Clacky
|
|
8
|
+
module Channel
|
|
9
|
+
module Adapters
|
|
10
|
+
class <%= const_prefix %>NoopAdapter < Base
|
|
11
|
+
def self.platform_id
|
|
12
|
+
:<%= slug %>_noop
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.platform_config(_data)
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start(&_on_message)
|
|
24
|
+
# TODO: connect to your platform and yield events.
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stop
|
|
28
|
+
# TODO: tear down connections.
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_text(_chat_id, text, reply_to: nil)
|
|
32
|
+
Clacky::Logger.info("[<%= slug %>_noop] would send: #{text}")
|
|
33
|
+
{ message_id: "noop-#{Time.now.to_i}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
register(<%= const_prefix %>NoopAdapter.platform_id, <%= const_prefix %>NoopAdapter)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
id: <%= slug %>
|
|
2
|
+
name: <%= slug %>
|
|
3
|
+
description: Kitchen-sink reference container — exercises all 7 contributes types.
|
|
4
|
+
version: "0.1.0"
|
|
5
|
+
author: (your name) # shown as the credit on the New Session card
|
|
6
|
+
origin: self
|
|
7
|
+
contributes:
|
|
8
|
+
api: api/handler.rb # single mount at /api/ext/<%= slug %>/
|
|
9
|
+
panels:
|
|
10
|
+
- id: dashboard
|
|
11
|
+
title: Dashboard
|
|
12
|
+
view: panels/dashboard/view.js
|
|
13
|
+
# No `attach:` — visibility is driven by the `designer` agent below,
|
|
14
|
+
# which references it via `panels: [dashboard]`. Set
|
|
15
|
+
# `attach: [<agent-id>]` (or `["*"]`) if you want the panel author to
|
|
16
|
+
# suggest a default mount without requiring agent-side references.
|
|
17
|
+
skills:
|
|
18
|
+
- id: <%= slug %>-skill # SKILL.md under skills/<%= slug %>-skill/
|
|
19
|
+
agents:
|
|
20
|
+
- id: designer
|
|
21
|
+
title: Designer
|
|
22
|
+
title_zh: 设计师
|
|
23
|
+
description: A demo agent that owns the dashboard panel and the <%= slug %>-skill skill.
|
|
24
|
+
description_zh: 这是一个演示 agent,绑定 dashboard 面板和 <%= slug %>-skill 技能。
|
|
25
|
+
prompt: agents/designer.md
|
|
26
|
+
panels: [dashboard]
|
|
27
|
+
skills: [<%= slug %>-skill]
|
|
28
|
+
channels:
|
|
29
|
+
- id: noop
|
|
30
|
+
adapter: channels/noop.rb
|
|
31
|
+
patches:
|
|
32
|
+
# Patches monkey-patch real classes. The example target is a
|
|
33
|
+
# real, live method — Terminal#execute is called for every
|
|
34
|
+
# shell tool invocation. Omit `fingerprint:` to trust the patch
|
|
35
|
+
# (loader will require the file directly); provide one to have
|
|
36
|
+
# the loader disable/warn if upstream source drifts.
|
|
37
|
+
- target: "Clacky::Tools::Terminal#execute"
|
|
38
|
+
file: patches/audit.rb
|
|
39
|
+
hooks:
|
|
40
|
+
- event: before_tool_use
|
|
41
|
+
file: hooks/audit.rb
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hook callbacks contributed by this extension. The block is copied
|
|
4
|
+
# onto each agent's HookManager at agent init time. The event name
|
|
5
|
+
# comes from ext.yml — no need to repeat it here.
|
|
6
|
+
Clacky::ExtensionHookRegistry.add do |tool_name, args|
|
|
7
|
+
Clacky::Logger.debug("[ext-hook] before_tool_use", tool: tool_name)
|
|
8
|
+
{ action: :allow }
|
|
9
|
+
end
|