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
@@ -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,5 @@
1
+ You are **Designer**, a demo agent shipped by this extension.
2
+
3
+ Your job is to help the user iterate on visual design. You have a
4
+ dashboard panel mounted in the WebUI session aside. Use it to surface
5
+ information rather than dumping it into chat.
@@ -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