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,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Clacky
7
+ # Discovers extension containers across three source layers and resolves them
8
+ # into a flat list of capability units (panels, api, skills, agents) for the
9
+ # rest of the system to mount.
10
+ #
11
+ # A container is a directory holding an `ext.yml` manifest:
12
+ #
13
+ # id: canvas-suite
14
+ # name: Canvas Suite
15
+ # version: "1.0.0"
16
+ # author: Jane Doe # display credit (optional)
17
+ # homepage: https://example.com # optional
18
+ # license: MIT # optional, SPDX identifier
19
+ # origin: self # self | marketplace | enterprise
20
+ # contributes:
21
+ # panels:
22
+ # - id: canvas
23
+ # view: panels/canvas/view.js
24
+ # attach: [designer] # optional: panel author's default
25
+ # # ("*" = all agents; omit = hidden
26
+ # # unless an agent references it)
27
+ # api: api/handler.rb # single backend for the whole ext
28
+ #
29
+ # Source layers (ascending priority — same id, higher layer wins):
30
+ # builtin gem default_extensions/<id>/
31
+ # installed ~/.clacky/ext/installed/<id>/
32
+ # local ~/.clacky/ext/local/<id>/
33
+ #
34
+ # This class only *discovers and resolves*; actual mounting is done by the
35
+ # existing call sites:
36
+ # - api unit → ApiExtensionLoader.load_one(...) (server boot / reload)
37
+ # - panel units → http_server ext_script_block(...) (rendered per request)
38
+ # - skill units → SkillLoader#load_extension_skills (reads last_result on load_all)
39
+ # - agent units → AgentProfile + http_server agent_profile_data (lookup by id)
40
+ # - channel units → channel.rb extension_adapter_loader (require + register at boot)
41
+ # - patch units → PatchLoader.load_extension_patches (require at boot)
42
+ # - hook units → ShellHookLoader.load_extension_hooks (register at boot)
43
+ module ExtensionLoader
44
+ BUILTIN_DIR = File.expand_path("../default_extensions", __dir__)
45
+ INSTALLED_DIR = File.expand_path("~/.clacky/ext/installed")
46
+ LOCAL_DIR = File.expand_path("~/.clacky/ext/local")
47
+ DISABLED_FILE = File.expand_path("~/.clacky/ext/disabled.json")
48
+ MANIFEST = "ext.yml"
49
+
50
+ # Layers in ascending priority; later entries override earlier ones by id.
51
+ LAYERS = %i[builtin installed local].freeze
52
+ ORIGINS = %w[self marketplace enterprise].freeze
53
+
54
+ # One resolved capability unit. `kind` is :panel or :api.
55
+ Unit = Struct.new(
56
+ :kind, :id, :ext_id, :layer, :origin, :dir, :spec,
57
+ keyword_init: true
58
+ )
59
+
60
+ # One resolution error, structured so an AI author can locate and fix it.
61
+ Error = Struct.new(:ext_id, :layer, :unit, :message, :file, keyword_init: true)
62
+
63
+ Result = Struct.new(:panels, :api, :skills, :agents, :channels, :patches, :hooks, :errors, :overridden, :containers, keyword_init: true) do
64
+ def units
65
+ panels + api + skills + agents + channels + patches + hooks
66
+ end
67
+ end
68
+
69
+ class << self
70
+ def dir_for(layer)
71
+ case layer
72
+ when :builtin then BUILTIN_DIR
73
+ when :installed then INSTALLED_DIR
74
+ when :local then LOCAL_DIR
75
+ end
76
+ end
77
+
78
+ # Scan all layers, resolve overrides, return a structured Result.
79
+ # `layers` maps layer name => root dir; defaults to the real three-layer
80
+ # dirs. Tests inject temp dirs through it.
81
+ #
82
+ # Cached by a fingerprint of every container's `ext.yml` mtime — a hot
83
+ # path (per web request, per API call) hits the cache; the moment any
84
+ # manifest changes, we invalidate and rescan. Pass `force: true` to
85
+ # bypass the cache (used by CLI / tests).
86
+ def load_all(layers: default_layers, force: false)
87
+ fingerprint = [fingerprint_layers(layers), disabled_fingerprint]
88
+ if !force && @last_result && @last_fingerprint == fingerprint
89
+ return @last_result
90
+ end
91
+
92
+ by_id = {} # ext_id => resolved container (winning layer)
93
+ overridden = [] # [ext_id, losing_layer, winning_layer]
94
+ errors = []
95
+
96
+ layers.each do |layer, root|
97
+ next unless root && Dir.exist?(root)
98
+
99
+ Dir.children(root).sort.each do |ext_id|
100
+ next if ext_id.start_with?("_", ".")
101
+ dir = File.join(root, ext_id)
102
+ next unless File.directory?(dir)
103
+
104
+ manifest = File.join(dir, MANIFEST)
105
+ next unless File.file?(manifest)
106
+
107
+ container = read_container(ext_id, layer, dir, manifest, errors)
108
+ next unless container
109
+
110
+ if (prev = by_id[ext_id])
111
+ overridden << [ext_id, prev[:layer], layer]
112
+ end
113
+ by_id[ext_id] = container
114
+ end
115
+ end
116
+
117
+ result = Result.new(panels: [], api: [], skills: [], agents: [], channels: [], patches: [], hooks: [], errors: errors, overridden: overridden, containers: by_id)
118
+ disabled = disabled_ids
119
+ by_id.each_value do |container|
120
+ container[:disabled] = disabled.include?(container[:ext_id])
121
+ resolve_units(container, result) unless container[:disabled]
122
+ end
123
+
124
+ @last_result = result
125
+ @last_fingerprint = fingerprint
126
+ result
127
+ end
128
+
129
+ def default_layers
130
+ LAYERS.each_with_object({}) { |layer, h| h[layer] = dir_for(layer) }
131
+ end
132
+
133
+ def last_result
134
+ @last_result || load_all
135
+ end
136
+
137
+ # Discard the mtime cache so the next `load_all` rescans from disk.
138
+ # Used by CLI commands that mutate the ext tree (new / pack).
139
+ def invalidate_cache!
140
+ @last_fingerprint = nil
141
+ end
142
+
143
+ # ── Disabled state ────────────────────────────────────────────────
144
+ # A disabled extension stays discoverable (still listed as installed)
145
+ # but contributes no units until re-enabled. Persisted as a JSON array
146
+ # of ext ids at ~/.clacky/ext/disabled.json.
147
+
148
+ def disabled_ids
149
+ return Set.new unless File.file?(DISABLED_FILE)
150
+
151
+ data = JSON.parse(File.read(DISABLED_FILE))
152
+ data.is_a?(Array) ? data.map(&:to_s).to_set : Set.new
153
+ rescue StandardError
154
+ Set.new
155
+ end
156
+
157
+ def disabled?(id)
158
+ disabled_ids.include?(id.to_s)
159
+ end
160
+
161
+ def disable!(id)
162
+ ids = disabled_ids
163
+ ids << id.to_s
164
+ write_disabled(ids)
165
+ end
166
+
167
+ def enable!(id)
168
+ ids = disabled_ids
169
+ ids.delete(id.to_s)
170
+ write_disabled(ids)
171
+ end
172
+
173
+ # Remove an installed extension by deleting its installed-layer dir.
174
+ # Only the installed layer is removable (builtin ships with the gem;
175
+ # local is the author's own working copy). Returns true on removal.
176
+ def uninstall!(id)
177
+ dir = File.join(INSTALLED_DIR, id.to_s)
178
+ return false unless File.directory?(dir)
179
+
180
+ FileUtils.rm_rf(dir)
181
+ enable!(id) # clear any stale disabled flag
182
+ invalidate_cache!
183
+ true
184
+ end
185
+
186
+ private def write_disabled(ids)
187
+ FileUtils.mkdir_p(File.dirname(DISABLED_FILE))
188
+ File.write(DISABLED_FILE, JSON.generate(ids.to_a.sort))
189
+ invalidate_cache!
190
+ end
191
+
192
+ private def disabled_fingerprint
193
+ File.file?(DISABLED_FILE) ? File.mtime(DISABLED_FILE).to_f : 0.0
194
+ rescue StandardError
195
+ Object.new
196
+ end
197
+
198
+
199
+ # A layer's fingerprint is the sorted list of "<ext_id>|<mtime>" for
200
+ # every container manifest present. Cheap to compute (one stat per
201
+ # manifest); the whole map compared as a Hash so a container appearing
202
+ # or disappearing also invalidates.
203
+ private def fingerprint_layers(layers)
204
+ layers.each_with_object({}) do |(layer, root), acc|
205
+ next unless root && Dir.exist?(root)
206
+
207
+ entries = []
208
+ Dir.children(root).sort.each do |ext_id|
209
+ next if ext_id.start_with?("_", ".")
210
+ manifest = File.join(root, ext_id, MANIFEST)
211
+ next unless File.file?(manifest)
212
+
213
+ entries << [ext_id, File.mtime(manifest).to_f]
214
+ end
215
+ acc[layer] = entries
216
+ end
217
+ rescue StandardError
218
+ # On any stat error just return a unique object so we always rescan.
219
+ Object.new
220
+ end
221
+
222
+ private def read_container(ext_id, layer, dir, manifest, errors)
223
+ data = YAMLCompat.load_file(manifest) || {}
224
+ unless data.is_a?(Hash)
225
+ errors << Error.new(ext_id: ext_id, layer: layer.to_s,
226
+ message: "ext.yml must be a mapping", file: manifest)
227
+ return nil
228
+ end
229
+
230
+ origin = (data["origin"] || "self").to_s
231
+ unless ORIGINS.include?(origin)
232
+ errors << Error.new(ext_id: ext_id, layer: layer.to_s,
233
+ message: "invalid origin #{origin.inspect} (expected one of #{ORIGINS.join('/')})",
234
+ file: manifest)
235
+ return nil
236
+ end
237
+
238
+ { ext_id: ext_id, layer: layer, dir: dir, origin: origin,
239
+ public: data["public"] == true,
240
+ name: (data["name"] || ext_id).to_s,
241
+ version: data["version"].to_s,
242
+ author: data["author"].to_s,
243
+ homepage: data["homepage"].to_s,
244
+ license: data["license"].to_s,
245
+ contributes: data["contributes"] || {},
246
+ raw: data }
247
+ rescue StandardError => e
248
+ errors << Error.new(ext_id: ext_id, layer: layer.to_s,
249
+ message: "failed to parse ext.yml: #{e.message}", file: manifest)
250
+ nil
251
+ end
252
+
253
+ private def resolve_units(container, result)
254
+ contributes = container[:contributes]
255
+ return unless contributes.is_a?(Hash)
256
+
257
+ Array(contributes["panels"]).each do |spec|
258
+ unit = build_panel_unit(container, spec, result.errors)
259
+ result.panels << unit if unit
260
+ end
261
+
262
+ if (api_spec = contributes["api"])
263
+ unit = build_api_unit(container, api_spec, result.errors)
264
+ result.api << unit if unit
265
+ end
266
+
267
+ Array(contributes["skills"]).each do |spec|
268
+ unit = build_skill_unit(container, spec, result.errors)
269
+ result.skills << unit if unit
270
+ end
271
+
272
+ Array(contributes["agents"]).each do |spec|
273
+ unit = build_agent_unit(container, spec, result.errors)
274
+ result.agents << unit if unit
275
+ end
276
+
277
+ Array(contributes["channels"]).each do |spec|
278
+ unit = build_channel_unit(container, spec, result.errors)
279
+ result.channels << unit if unit
280
+ end
281
+
282
+ Array(contributes["patches"]).each do |spec|
283
+ unit = build_patch_unit(container, spec, result.errors)
284
+ result.patches << unit if unit
285
+ end
286
+
287
+ Array(contributes["hooks"]).each do |spec|
288
+ unit = build_hook_unit(container, spec, result.errors)
289
+ result.hooks << unit if unit
290
+ end
291
+ end
292
+
293
+ private def build_panel_unit(container, spec, errors)
294
+ ext_id = container[:ext_id]
295
+ unless spec.is_a?(Hash) && spec["id"] && spec["view"]
296
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "panel",
297
+ message: "panel needs both `id` and `view`")
298
+ return nil
299
+ end
300
+
301
+ view_abs = File.join(container[:dir], spec["view"])
302
+ unless File.file?(view_abs)
303
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "panel/#{spec['id']}",
304
+ message: "view file not found: #{spec['view']}", file: view_abs)
305
+ return nil
306
+ end
307
+
308
+ Unit.new(kind: :panel, id: spec["id"].to_s, ext_id: ext_id,
309
+ layer: container[:layer], origin: container[:origin],
310
+ dir: container[:dir],
311
+ spec: {
312
+ "view" => spec["view"],
313
+ "title" => spec["title"],
314
+ "title_zh" => spec["title_zh"],
315
+ "description" => spec["description"],
316
+ "description_zh" => spec["description_zh"],
317
+ "order" => spec["order"],
318
+ "attach" => Array(spec["attach"]).map(&:to_s),
319
+ })
320
+ end
321
+
322
+ private def build_api_unit(container, spec, errors)
323
+ ext_id = container[:ext_id]
324
+ handler_rel = spec.is_a?(String) ? spec : nil
325
+ unless handler_rel && !handler_rel.empty?
326
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "api",
327
+ message: "api must be a string path to the handler file (e.g. `api: api/handler.rb`)",
328
+ file: File.join(container[:dir], "ext.yml"))
329
+ return nil
330
+ end
331
+
332
+ handler_abs = File.join(container[:dir], handler_rel)
333
+ unless File.file?(handler_abs)
334
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "api",
335
+ message: "handler file not found: #{handler_rel}", file: handler_abs)
336
+ return nil
337
+ end
338
+
339
+ Unit.new(kind: :api, id: ext_id, ext_id: ext_id,
340
+ layer: container[:layer], origin: container[:origin],
341
+ dir: container[:dir],
342
+ spec: { "handler" => handler_rel, "handler_abs" => handler_abs })
343
+ end
344
+
345
+ private def build_skill_unit(container, spec, errors)
346
+ ext_id = container[:ext_id]
347
+ unless spec.is_a?(Hash) && spec["id"]
348
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "skill",
349
+ message: "skill needs `id`")
350
+ return nil
351
+ end
352
+
353
+ rel_dir = spec["dir"] || "skills/#{spec['id']}"
354
+ skill_dir = File.join(container[:dir], rel_dir)
355
+ unless File.directory?(skill_dir)
356
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "skill/#{spec['id']}",
357
+ message: "skill dir not found: #{rel_dir}", file: skill_dir)
358
+ return nil
359
+ end
360
+
361
+ encrypted = File.file?(File.join(skill_dir, "SKILL.md.enc"))
362
+ plain = File.file?(File.join(skill_dir, "SKILL.md"))
363
+ unless encrypted || plain
364
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "skill/#{spec['id']}",
365
+ message: "skill dir missing SKILL.md or SKILL.md.enc", file: skill_dir)
366
+ return nil
367
+ end
368
+
369
+ protected_default = container[:origin] == "marketplace"
370
+ protected_flag = spec.key?("protected") ? !!spec["protected"] : protected_default
371
+
372
+ Unit.new(kind: :skill, id: spec["id"].to_s, ext_id: ext_id,
373
+ layer: container[:layer], origin: container[:origin],
374
+ dir: container[:dir],
375
+ spec: { "dir" => rel_dir, "skill_dir_abs" => skill_dir,
376
+ "protected" => protected_flag, "encrypted" => encrypted })
377
+ end
378
+
379
+ private def build_agent_unit(container, spec, errors)
380
+ ext_id = container[:ext_id]
381
+ unless spec.is_a?(Hash) && spec["id"] && spec["prompt"]
382
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "agent",
383
+ message: "agent needs both `id` and `prompt`")
384
+ return nil
385
+ end
386
+
387
+ prompt_abs = File.join(container[:dir], spec["prompt"])
388
+ unless File.file?(prompt_abs)
389
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "agent/#{spec['id']}",
390
+ message: "prompt file not found: #{spec['prompt']}", file: prompt_abs)
391
+ return nil
392
+ end
393
+
394
+ Unit.new(kind: :agent, id: spec["id"].to_s, ext_id: ext_id,
395
+ layer: container[:layer], origin: container[:origin],
396
+ dir: container[:dir],
397
+ spec: {
398
+ "title" => spec["title"].to_s,
399
+ "title_zh" => spec["title_zh"].to_s,
400
+ "prompt" => spec["prompt"],
401
+ "prompt_abs" => prompt_abs,
402
+ "description" => spec["description"].to_s,
403
+ "description_zh" => spec["description_zh"].to_s,
404
+ "order" => spec["order"],
405
+ "author" => container[:author],
406
+ "homepage" => container[:homepage],
407
+ "license" => container[:license],
408
+ "panels" => Array(spec["panels"]).map(&:to_s),
409
+ "skills" => Array(spec["skills"]).map(&:to_s),
410
+ })
411
+ end
412
+
413
+ private def build_channel_unit(container, spec, errors)
414
+ ext_id = container[:ext_id]
415
+ unless spec.is_a?(Hash) && spec["id"] && spec["adapter"]
416
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "channel",
417
+ message: "channel needs both `id` and `adapter`")
418
+ return nil
419
+ end
420
+
421
+ adapter_abs = File.join(container[:dir], spec["adapter"])
422
+ unless File.file?(adapter_abs)
423
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "channel/#{spec['id']}",
424
+ message: "adapter file not found: #{spec['adapter']}", file: adapter_abs)
425
+ return nil
426
+ end
427
+
428
+ Unit.new(kind: :channel, id: spec["id"].to_s, ext_id: ext_id,
429
+ layer: container[:layer], origin: container[:origin],
430
+ dir: container[:dir],
431
+ spec: { "adapter" => spec["adapter"], "adapter_abs" => adapter_abs })
432
+ end
433
+
434
+ private def build_patch_unit(container, spec, errors)
435
+ ext_id = container[:ext_id]
436
+ unless spec.is_a?(Hash) && spec["target"] && spec["file"]
437
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "patch",
438
+ message: "patch needs both `target` and `file`")
439
+ return nil
440
+ end
441
+
442
+ file_abs = File.join(container[:dir], spec["file"])
443
+ unless File.file?(file_abs)
444
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "patch/#{spec['target']}",
445
+ message: "patch file not found: #{spec['file']}", file: file_abs)
446
+ return nil
447
+ end
448
+
449
+ Unit.new(kind: :patch, id: spec["target"].to_s, ext_id: ext_id,
450
+ layer: container[:layer], origin: container[:origin],
451
+ dir: container[:dir],
452
+ spec: {
453
+ "target" => spec["target"].to_s,
454
+ "file" => spec["file"],
455
+ "file_abs" => file_abs,
456
+ "fingerprint" => spec["fingerprint"].to_s,
457
+ "on_mismatch" => (spec["on_mismatch"] || "disable").to_s,
458
+ })
459
+ end
460
+
461
+ private def build_hook_unit(container, spec, errors)
462
+ ext_id = container[:ext_id]
463
+ unless spec.is_a?(Hash) && spec["event"] && spec["file"]
464
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "hook",
465
+ message: "hook needs both `event` and `file`")
466
+ return nil
467
+ end
468
+
469
+ file_abs = File.join(container[:dir], spec["file"])
470
+ unless File.file?(file_abs)
471
+ errors << Error.new(ext_id: ext_id, layer: container[:layer].to_s, unit: "hook/#{spec['event']}",
472
+ message: "hook file not found: #{spec['file']}", file: file_abs)
473
+ return nil
474
+ end
475
+
476
+ Unit.new(kind: :hook, id: "#{spec['event']}/#{File.basename(spec['file'], '.rb')}", ext_id: ext_id,
477
+ layer: container[:layer], origin: container[:origin],
478
+ dir: container[:dir],
479
+ spec: { "event" => spec["event"].to_s, "file" => spec["file"], "file_abs" => file_abs })
480
+ end
481
+ end
482
+ end
483
+ end