openclacky 1.2.7 → 1.2.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent.rb +3 -0
  4. data/lib/clacky/agent_config.rb +91 -7
  5. data/lib/clacky/billing/billing_store.rb +107 -3
  6. data/lib/clacky/cli.rb +105 -0
  7. data/lib/clacky/client.rb +38 -5
  8. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  9. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  10. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  11. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/media/base.rb +68 -0
  14. data/lib/clacky/media/gemini.rb +36 -0
  15. data/lib/clacky/media/generator.rb +78 -0
  16. data/lib/clacky/media/openai_compat.rb +168 -0
  17. data/lib/clacky/patch_loader.rb +282 -0
  18. data/lib/clacky/providers.rb +82 -0
  19. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  21. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  22. data/lib/clacky/server/channel.rb +5 -0
  23. data/lib/clacky/server/http_server.rb +236 -25
  24. data/lib/clacky/server/scheduler.rb +1 -4
  25. data/lib/clacky/shell_hook_loader.rb +181 -0
  26. data/lib/clacky/telemetry.rb +11 -5
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +326 -24
  29. data/lib/clacky/web/billing.js +117 -22
  30. data/lib/clacky/web/i18n.js +84 -6
  31. data/lib/clacky/web/index.html +14 -2
  32. data/lib/clacky/web/model-tester.js +58 -0
  33. data/lib/clacky/web/onboard.js +17 -30
  34. data/lib/clacky/web/settings.js +322 -97
  35. data/lib/clacky.rb +9 -0
  36. data/scripts/build/lib/network.sh +61 -30
  37. data/scripts/install.sh +61 -30
  38. data/scripts/install_browser.sh +61 -30
  39. data/scripts/install_full.sh +61 -30
  40. data/scripts/install_rails_deps.sh +61 -30
  41. data/scripts/install_system_deps.sh +61 -30
  42. metadata +12 -3
  43. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ begin
8
+ require "prism"
9
+ rescue LoadError
10
+ # Prism is a stdlib on Ruby 3.3+. On older Rubies we fall back to
11
+ # RubyVM::AbstractSyntaxTree (available since 2.6).
12
+ end
13
+
14
+ module Clacky
15
+ # Runtime patch layer. Loads user/AI-authored patches from ~/.clacky/patches/
16
+ # that override existing methods via Module#prepend, WITHOUT touching the
17
+ # installed gem source (so `gem update` never loses them).
18
+ #
19
+ # Each patch lives in its own directory:
20
+ # ~/.clacky/patches/<id>/
21
+ # meta.yml declares target + a fingerprint of the original method source
22
+ # patch.rb a prepend module that overrides the target method
23
+ #
24
+ # Safety — fingerprint drift:
25
+ # meta.yml records a SHA256 of the targeted method's source at authoring time.
26
+ # Before applying, the loader recomputes the fingerprint of the method as it
27
+ # exists in the CURRENTLY installed gem. If they differ, the upstream code has
28
+ # changed and the patch may no longer be valid, so by default the patch is
29
+ # DISABLED (moved to _disabled/) rather than applied — a stale patch must never
30
+ # silently corrupt behavior.
31
+ #
32
+ # meta.yml:
33
+ # id: fix-web-search-timeout
34
+ # description: bump default timeout to 30s
35
+ # target: "Clacky::Tools::WebSearch#execute" # '#' = instance, '.' = class method
36
+ # fingerprint: "a3f8c…"
37
+ # gem_version: "0.7.0"
38
+ # on_mismatch: disable # disable | warn (default disable)
39
+ module PatchLoader
40
+ DEFAULT_DIR = File.expand_path("~/.clacky/patches")
41
+ DISABLED_DIR = "_disabled"
42
+
43
+ Result = Struct.new(:applied, :disabled, :skipped, keyword_init: true)
44
+
45
+ class << self
46
+ def load_all(dir: DEFAULT_DIR)
47
+ result = Result.new(applied: [], disabled: [], skipped: [])
48
+ if Dir.exist?(dir)
49
+ Dir.glob(File.join(dir, "*", "meta.yml")).sort.each do |meta_path|
50
+ patch_dir = File.dirname(meta_path)
51
+ next if File.basename(File.dirname(patch_dir)) == DISABLED_DIR
52
+
53
+ apply_one(patch_dir, meta_path, result)
54
+ end
55
+ end
56
+ @last_result = result
57
+ result
58
+ end
59
+
60
+ def last_result
61
+ @last_result || load_all
62
+ end
63
+
64
+ # Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method.
65
+ # Computes the current fingerprint automatically so the author never does it
66
+ # by hand. The patch.rb skeleton prepends a module that overrides the method
67
+ # and calls super by default.
68
+ # @param target [String] "Const::Path#method" or "Const::Path.method"
69
+ # @return [String] path to the new patch directory
70
+ def scaffold(id, target, description: "", dir: DEFAULT_DIR)
71
+ slug = id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
72
+ raise ArgumentError, "invalid patch id: #{id.inspect}" if slug.empty?
73
+
74
+ fp = fingerprint(target) # also validates the target resolves
75
+
76
+ patch_dir = File.join(dir, slug)
77
+ raise ArgumentError, "patch already exists: #{patch_dir}" if Dir.exist?(patch_dir)
78
+
79
+ FileUtils.mkdir_p(patch_dir)
80
+ File.write(File.join(patch_dir, "meta.yml"), <<~YAML)
81
+ id: #{slug}
82
+ description: #{description.to_s.empty? ? "(describe what this fixes)" : description}
83
+ target: "#{target}"
84
+ fingerprint: "#{fp}"
85
+ gem_version: "#{Clacky::VERSION}"
86
+ on_mismatch: disable
87
+ YAML
88
+ File.write(File.join(patch_dir, "patch.rb"), patch_skeleton(slug, target))
89
+ patch_dir
90
+ end
91
+
92
+ def patch_skeleton(slug, target)
93
+ const_name, sep, method_name = target.partition(/[#.]/)
94
+ mod_const = "Patch_#{slug.gsub(/[^a-zA-Z0-9_]/, "_")}"
95
+ prepend_target = sep == "#" ? const_name : "#{const_name}.singleton_class"
96
+
97
+ <<~RUBY
98
+ # frozen_string_literal: true
99
+
100
+ # Patch for #{target}
101
+ # Only edit the method body below. Call `super` to keep the original behavior.
102
+ module #{mod_const}
103
+ def #{method_name}(*args, **kwargs, &blk)
104
+ # TODO: your fix here. Examples:
105
+ # result = super
106
+ # result
107
+ super
108
+ end
109
+ end
110
+
111
+ #{prepend_target}.prepend(#{mod_const})
112
+ RUBY
113
+ end
114
+
115
+ # Recompute the fingerprint of a target's method as currently installed.
116
+ # @param target [String] "Const::Path#instance_method" or "Const::Path.class_method"
117
+ # @return [String] SHA256 hex of the method's source
118
+ # @raise [RuntimeError] if the target can't be resolved
119
+ def fingerprint(target)
120
+ meth = original_method(resolve_method(target))
121
+ file, lineno = meth.source_location
122
+ raise "no source location for #{target} (defined in C or eval?)" unless file && lineno
123
+
124
+ first, last = method_line_range(file, lineno, meth.name, meth)
125
+ raise "cannot locate source for #{target} in #{file}:#{lineno}" unless first && last
126
+
127
+ lines = File.readlines(file)[(first - 1)...last]
128
+ Digest::SHA256.hexdigest(lines.join)
129
+ end
130
+
131
+ def method_line_range(file, lineno, name, meth)
132
+ if defined?(Prism)
133
+ range = prism_line_range(file, lineno, name)
134
+ return range if range
135
+ end
136
+
137
+ ast_line_range(meth)
138
+ end
139
+
140
+ def prism_line_range(file, lineno, name)
141
+ result = Prism.parse_file(file)
142
+ return nil unless result.success?
143
+
144
+ node = find_def_at(result.value, lineno, name.to_sym)
145
+ return nil unless node
146
+
147
+ loc = node.location
148
+ [loc.start_line, loc.end_line]
149
+ end
150
+
151
+ def find_def_at(node, lineno, name)
152
+ return nil unless node
153
+
154
+ if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == lineno
155
+ return node
156
+ end
157
+
158
+ node.compact_child_nodes.each do |child|
159
+ found = find_def_at(child, lineno, name)
160
+ return found if found
161
+ end
162
+ nil
163
+ end
164
+
165
+ def ast_line_range(meth)
166
+ return nil unless defined?(RubyVM::AbstractSyntaxTree)
167
+
168
+ node = RubyVM::AbstractSyntaxTree.of(meth)
169
+ return nil unless node
170
+
171
+ [node.first_lineno, node.last_lineno]
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ # Walk past any methods introduced by our own patches (files under the
177
+ # patches dir) so the fingerprint always reflects the original upstream
178
+ # definition, even after a prepend has already been applied.
179
+ def original_method(meth)
180
+ current = meth
181
+ while current
182
+ file, = current.source_location
183
+ break if file.nil? || !file.start_with?(DEFAULT_DIR)
184
+
185
+ nxt = current.super_method
186
+ break if nxt.nil?
187
+
188
+ current = nxt
189
+ end
190
+ current
191
+ end
192
+
193
+ def resolve_method(target)
194
+ if target.include?("#")
195
+ const_name, method_name = target.split("#", 2)
196
+ const = resolve_const(const_name)
197
+ const.instance_method(method_name.to_sym)
198
+ elsif target.include?(".")
199
+ const_name, method_name = target.split(".", 2)
200
+ const = resolve_const(const_name)
201
+ const.method(method_name.to_sym)
202
+ else
203
+ raise "invalid target (need '#' or '.'): #{target}"
204
+ end
205
+ end
206
+
207
+ def apply_one(patch_dir, meta_path, result)
208
+ id = File.basename(patch_dir)
209
+ meta = YAMLCompat.load_file(meta_path) || {}
210
+ target = meta["target"].to_s
211
+ recorded = meta["fingerprint"].to_s
212
+
213
+ if target.empty? || recorded.empty?
214
+ result.skipped << [id, "meta.yml missing target or fingerprint"]
215
+ log(:warn, id, result.skipped.last[1])
216
+ return
217
+ end
218
+
219
+ current = begin
220
+ fingerprint(target)
221
+ rescue StandardError => e
222
+ result.skipped << [id, "cannot fingerprint #{target}: #{e.message}"]
223
+ log(:warn, id, result.skipped.last[1])
224
+ return
225
+ end
226
+
227
+ if current != recorded
228
+ handle_mismatch(patch_dir, id, meta, result)
229
+ return
230
+ end
231
+
232
+ patch_rb = File.join(patch_dir, "patch.rb")
233
+ unless File.exist?(patch_rb)
234
+ result.skipped << [id, "patch.rb not found"]
235
+ log(:warn, id, result.skipped.last[1])
236
+ return
237
+ end
238
+
239
+ require patch_rb
240
+ result.applied << id
241
+ log(:info, id, "applied → #{target}")
242
+ rescue StandardError, ScriptError => e
243
+ result.skipped << [id, e.message]
244
+ log(:warn, id, e.message)
245
+ end
246
+
247
+ def handle_mismatch(patch_dir, id, meta, result)
248
+ reason = "fingerprint mismatch — upstream code for #{meta["target"]} changed"
249
+ if meta["on_mismatch"].to_s == "warn"
250
+ result.skipped << [id, "#{reason} (kept, not applied)"]
251
+ log(:warn, id, result.skipped.last[1])
252
+ return
253
+ end
254
+
255
+ disable!(patch_dir, id)
256
+ result.disabled << [id, reason]
257
+ log(:warn, id, "#{reason} — disabled")
258
+ end
259
+
260
+ def disable!(patch_dir, id)
261
+ base = File.dirname(patch_dir)
262
+ dest_root = File.join(base, DISABLED_DIR)
263
+ FileUtils.mkdir_p(dest_root)
264
+ dest = File.join(dest_root, id)
265
+ FileUtils.rm_rf(dest)
266
+ FileUtils.mv(patch_dir, dest)
267
+ rescue StandardError => e
268
+ log(:error, id, "failed to disable: #{e.message}")
269
+ end
270
+
271
+ def resolve_const(name)
272
+ name.split("::").reject(&:empty?).inject(Object) do |mod, part|
273
+ mod.const_get(part)
274
+ end
275
+ end
276
+
277
+ def log(level, id, msg)
278
+ Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}")
279
+ end
280
+ end
281
+ end
282
+ end
@@ -41,6 +41,17 @@ module Clacky
41
41
  "dsk-deepseek-v4-flash",
42
42
  "or-gemini-3-1-pro"
43
43
  ],
44
+ # Image generation models served by the openclacky platform
45
+ # gateway. The gateway exposes a standard OpenAI-compatible
46
+ # /v1/images/generations endpoint, so the same OpenAICompat
47
+ # provider class handles them. `or-` prefix mirrors the chat
48
+ # model naming — these are routed through the OpenRouter
49
+ # backend by the platform.
50
+ "image_models" => [
51
+ "or-gemini-3-pro-image",
52
+ "or-gpt-image-2"
53
+ ],
54
+ "default_image_model" => "or-gpt-image-2",
44
55
  # Provider-level default: the Claude family served here is vision-capable.
45
56
  "capabilities" => { "vision" => true }.freeze,
46
57
  # Model-level overrides: DeepSeek models routed through this provider
@@ -123,6 +134,13 @@ module Clacky
123
134
  /\Aanthropic\// => "anthropic-messages",
124
135
  /\Aclaude[-.]/ => "anthropic-messages"
125
136
  }.freeze,
137
+ # Image generation via OpenRouter is currently routed through the
138
+ # openclacky platform gateway (see "openclacky" provider above) which
139
+ # handles the OpenRouter chat-completions + modalities translation.
140
+ # Direct OpenRouter image config is not exposed here — leave empty
141
+ # until we ship a dedicated client-side adapter for that protocol.
142
+ "image_models" => [],
143
+ "default_image_model" => nil,
126
144
  "website_url" => "https://openrouter.ai/keys"
127
145
  }.freeze,
128
146
 
@@ -305,6 +323,12 @@ module Clacky
305
323
  "gpt-5.5" => "gpt-5.4-mini",
306
324
  "gpt-5.4" => "gpt-5.4-mini"
307
325
  },
326
+ # OpenAI's image generation model — same /v1/images/generations
327
+ # endpoint, so the OpenAICompat image provider handles it.
328
+ "image_models" => [
329
+ "gpt-image-2"
330
+ ],
331
+ "default_image_model" => "gpt-image-2",
308
332
  "website_url" => "https://platform.openai.com/api-keys"
309
333
  }.freeze,
310
334
 
@@ -342,6 +366,8 @@ module Clacky
342
366
 
343
367
  }.freeze
344
368
 
369
+ MEDIA_KINDS = %w[image video audio].freeze
370
+
345
371
  class << self
346
372
  # Check if a provider preset exists
347
373
  # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
@@ -446,6 +472,62 @@ module Clacky
446
472
  preset&.dig("models") || []
447
473
  end
448
474
 
475
+ # Get available image generation models for a provider.
476
+ # Returns an empty array when the provider doesn't declare any —
477
+ # callers should treat that as "image generation not supported by this provider".
478
+ # @param provider_id [String] The provider identifier
479
+ # @return [Array<String>] List of image model names
480
+ def image_models(provider_id)
481
+ preset = PRESETS[provider_id]
482
+ preset&.dig("image_models") || []
483
+ end
484
+
485
+ # Video generation models — placeholder. No provider supports video
486
+ # via Clacky yet; once they do, declare "video_models" alongside
487
+ # "image_models" in the relevant PRESETS entry and this returns it.
488
+ def video_models(provider_id)
489
+ preset = PRESETS[provider_id]
490
+ preset&.dig("video_models") || []
491
+ end
492
+
493
+ # Audio generation models — same placeholder pattern as video_models.
494
+ def audio_models(provider_id)
495
+ preset = PRESETS[provider_id]
496
+ preset&.dig("audio_models") || []
497
+ end
498
+
499
+ # Unified entry for media model lookup by kind.
500
+ # @param provider_id [String]
501
+ # @param kind [String] one of "image" / "video" / "audio"
502
+ # @return [Array<String>]
503
+ def media_models(provider_id, kind)
504
+ case kind.to_s
505
+ when "image" then image_models(provider_id)
506
+ when "video" then video_models(provider_id)
507
+ when "audio" then audio_models(provider_id)
508
+ else []
509
+ end
510
+ end
511
+
512
+ # Default media model for a kind under a provider. Falls back to the
513
+ # first declared model when no explicit default is set in the preset.
514
+ # Used by AgentConfig#derive_media_models! to pick which model to
515
+ # surface when the user is on "auto" mode.
516
+ def default_media_model(provider_id, kind)
517
+ preset = PRESETS[provider_id]
518
+ return nil unless preset
519
+ explicit = preset["default_#{kind}_model"]
520
+ return explicit if explicit
521
+ media_models(provider_id, kind).first
522
+ end
523
+
524
+ # The set of media kinds Clacky knows about. Drives UI rendering and
525
+ # derivation loops — adding a new modality means listing it here plus
526
+ # adding the corresponding generator class.
527
+ def media_kinds
528
+ MEDIA_KINDS
529
+ end
530
+
449
531
  # Get the lite model for a provider.
450
532
  # @param provider_id [String] The provider identifier
451
533
  # @param primary_model [String, nil] The currently-selected primary model name.
@@ -15,6 +15,10 @@ module Clacky
15
15
  @registry[platform.to_sym]
16
16
  end
17
17
 
18
+ def self.unregister(platform)
19
+ @registry.delete(platform.to_sym)
20
+ end
21
+
18
22
  def self.all
19
23
  @registry.values
20
24
  end
@@ -511,7 +511,7 @@ module Clacky
511
511
  platform = event[:platform].to_s
512
512
  count = @mutex.synchronize { @session_counters[platform] += 1 }
513
513
  name = "#{platform}-#{count}"
514
- session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
514
+ session_id = @session_builder.call(name: name, source: :channel)
515
515
  bind_key_to_session(key, session_id)
516
516
 
517
517
  # Create a long-lived ChannelUIController for this session and subscribe it
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ module Channel
7
+ module Adapters
8
+ # Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
9
+ #
10
+ # Each adapter file is plain Ruby that defines a subclass of
11
+ # Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
12
+ # exactly like the bundled adapters. This loader only discovers and requires
13
+ # those files after the built-in adapters are loaded — the existing
14
+ # self-registration mechanism then takes over with no further wiring.
15
+ #
16
+ # A broken adapter (syntax error, missing interface methods) is isolated:
17
+ # it is skipped with a logged warning and never aborts the load of others.
18
+ module UserAdapterLoader
19
+ DEFAULT_DIR = File.expand_path("~/.clacky/channels")
20
+
21
+ # Required class/instance methods a user adapter must implement to be usable.
22
+ REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
23
+ REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
24
+
25
+ Result = Struct.new(:loaded, :skipped, keyword_init: true)
26
+
27
+ # @param dir [String] directory to scan (override for tests)
28
+ # @return [Result] names loaded and skipped (with reasons)
29
+ def self.load_all(dir: DEFAULT_DIR)
30
+ result = Result.new(loaded: [], skipped: [])
31
+ if Dir.exist?(dir)
32
+ Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
33
+ name = File.basename(File.dirname(path))
34
+ load_one(path, name, result)
35
+ end
36
+ end
37
+ @last_result = result
38
+ result
39
+ end
40
+
41
+ # The result of the most recent load_all (set at startup). Lets `channel_verify`
42
+ # report status without re-requiring files (require is idempotent and would
43
+ # otherwise report already-loaded adapters as "did not register").
44
+ def self.last_result
45
+ @last_result || load_all
46
+ end
47
+
48
+ def self.load_one(path, name, result)
49
+ before = Adapters.all.dup
50
+
51
+ require path
52
+
53
+ newly = Adapters.all - before
54
+ klass = newly.last
55
+
56
+ unless klass
57
+ result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
58
+ log_skip(name, result.skipped.last[1])
59
+ return
60
+ end
61
+
62
+ if (missing = interface_gaps(klass)).any?
63
+ unregister(klass)
64
+ result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
65
+ log_skip(name, result.skipped.last[1])
66
+ return
67
+ end
68
+
69
+ result.loaded << name
70
+ Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
71
+ rescue StandardError, ScriptError => e
72
+ result.skipped << [name, e.message]
73
+ log_skip(name, e.message)
74
+ end
75
+
76
+ def self.interface_gaps(klass)
77
+ missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
78
+ # Base defines stub instance methods that only raise NotImplementedError,
79
+ # so method_defined? alone passes via inheritance. Require the subclass to
80
+ # actually override them — i.e. the method's owner must not be Base.
81
+ missing += REQUIRED_INSTANCE_METHODS.reject do |m|
82
+ klass.method_defined?(m) && klass.instance_method(m).owner != Base
83
+ end
84
+ missing
85
+ end
86
+
87
+ def self.unregister(klass)
88
+ platform = (klass.platform_id if klass.respond_to?(:platform_id))
89
+ return unless platform
90
+
91
+ Adapters.unregister(platform)
92
+ end
93
+
94
+ def self.log_skip(name, reason)
95
+ Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
96
+ end
97
+
98
+ # Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
99
+ # The skeleton already self-registers and implements the full interface with
100
+ # TODO markers — the author only fills in the method bodies.
101
+ # @return [String] path to the generated adapter.rb
102
+ def self.scaffold(name, dir: DEFAULT_DIR)
103
+ slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
104
+ raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
105
+
106
+ target_dir = File.join(dir, slug)
107
+ path = File.join(target_dir, "adapter.rb")
108
+ raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
109
+
110
+ FileUtils.mkdir_p(target_dir)
111
+ File.write(path, skeleton(slug))
112
+ path
113
+ end
114
+
115
+ def self.skeleton(slug)
116
+ const = slug.split("_").map(&:capitalize).join
117
+ <<~RUBY
118
+ # frozen_string_literal: true
119
+
120
+ # User-defined channel adapter for ":#{slug}".
121
+ # Edit the TODO sections, then it loads automatically on next start.
122
+ # Verify with: clacky channel verify
123
+
124
+ module Clacky
125
+ module Channel
126
+ module Adapters
127
+ class #{const}Adapter < Base
128
+ def self.platform_id
129
+ :#{slug}
130
+ end
131
+
132
+ # Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
133
+ def self.platform_config(data)
134
+ {
135
+ # TODO: pull your credentials out of `data`
136
+ # token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
137
+ }.compact
138
+ end
139
+
140
+ def initialize(config)
141
+ @config = config
142
+ end
143
+
144
+ # Begin receiving messages. Blocks until #stop — runs inside a Thread.
145
+ # Yield one standardized event Hash per inbound message.
146
+ def start(&on_message)
147
+ # TODO: connect to your platform and loop, calling on_message.call(event)
148
+ raise NotImplementedError
149
+ end
150
+
151
+ def stop
152
+ # TODO: close connections / stop the read loop
153
+ end
154
+
155
+ # Send a plain text (or Markdown) message to a chat.
156
+ # @return [Hash] { message_id: String }
157
+ def send_text(chat_id, text, reply_to: nil)
158
+ # TODO: call your platform's send API
159
+ raise NotImplementedError
160
+ end
161
+
162
+ # Optional: validate config; return array of error strings (empty = ok).
163
+ def validate_config(config)
164
+ []
165
+ end
166
+
167
+ Adapters.register(platform_id, self)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ RUBY
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
31
31
  require_relative "channel/channel_config"
32
32
  require_relative "channel/channel_ui_controller"
33
33
  require_relative "channel/channel_manager"
34
+
35
+ # Discover and load user-defined adapters from ~/.clacky/channels/.
36
+ # Must run after the bundled adapters so user adapters can extend or override.
37
+ require_relative "channel/user_adapter_loader"
38
+ Clacky::Channel::Adapters::UserAdapterLoader.load_all