Module: Clacky::ExtensionLoader

Defined in:
lib/clacky/extension/loader.rb

Overview

Discovers extension containers across three source layers and resolves them into a flat list of capability units (panels, api, skills, agents) for the rest of the system to mount.

A container is a directory holding an ext.yml manifest:

id: canvas-suite
name: Canvas Suite
version: "1.0.0"
author: Jane Doe                # display credit (optional)
homepage: https://example.com   # optional
license: MIT                    # optional, SPDX identifier
origin: self                 # self | marketplace | enterprise
contributes:
panels:
  - id: canvas
    view: panels/canvas/view.js
    attach: [designer]               # optional: panel author's default
                                     # ("*" = all agents; omit = hidden
                                     # unless an agent references it)
api: api/handler.rb                  # single backend for the whole ext

Source layers (ascending priority — same id, higher layer wins):

builtin    gem default_extensions/<id>/
installed  ~/.clacky/ext/installed/<id>/
local      ~/.clacky/ext/local/<id>/

This class only discovers and resolves; actual mounting is done by the existing call sites:

- api unit      → ApiExtensionLoader.load_one(...)  (server boot / reload)
- panel units   → http_server ext_script_block(...) (rendered per request)
- skill units   → SkillLoader#load_extension_skills (reads last_result on load_all)
- agent units   → AgentProfile + http_server agent_profile_data (lookup by id)
- channel units → channel.rb extension_adapter_loader (require + register at boot)
- patch units   → PatchLoader.load_extension_patches (require at boot)
- hook units    → ShellHookLoader.load_extension_hooks (register at boot)

Defined Under Namespace

Classes: Error, Result, Unit

Constant Summary collapse

BUILTIN_DIR =
File.expand_path("../default_extensions", __dir__)
INSTALLED_DIR =
File.expand_path("~/.clacky/ext/installed")
LOCAL_DIR =
File.expand_path("~/.clacky/ext/local")
DISABLED_FILE =
File.expand_path("~/.clacky/ext/disabled.json")
MANIFEST =
"ext.yml"
LAYERS =

Layers in ascending priority; later entries override earlier ones by id.

%i[builtin installed local].freeze
ORIGINS =
%w[self marketplace enterprise].freeze

Class Method Summary collapse

Class Method Details

.default_layersObject



129
130
131
# File 'lib/clacky/extension/loader.rb', line 129

def default_layers
  LAYERS.each_with_object({}) { |layer, h| h[layer] = dir_for(layer) }
end

.dir_for(layer) ⇒ Object



70
71
72
73
74
75
76
# File 'lib/clacky/extension/loader.rb', line 70

def dir_for(layer)
  case layer
  when :builtin   then BUILTIN_DIR
  when :installed then INSTALLED_DIR
  when :local     then LOCAL_DIR
  end
end

.disable!(id) ⇒ Object



161
162
163
164
165
# File 'lib/clacky/extension/loader.rb', line 161

def disable!(id)
  ids = disabled_ids
  ids << id.to_s
  write_disabled(ids)
end

.disabled?(id) ⇒ Boolean

Returns:

  • (Boolean)


157
158
159
# File 'lib/clacky/extension/loader.rb', line 157

def disabled?(id)
  disabled_ids.include?(id.to_s)
end

.disabled_idsObject

── Disabled state ──────────────────────────────────────────────── A disabled extension stays discoverable (still listed as installed) but contributes no units until re-enabled. Persisted as a JSON array of ext ids at ~/.clacky/ext/disabled.json.



148
149
150
151
152
153
154
155
# File 'lib/clacky/extension/loader.rb', line 148

def disabled_ids
  return Set.new unless File.file?(DISABLED_FILE)

  data = JSON.parse(File.read(DISABLED_FILE))
  data.is_a?(Array) ? data.map(&:to_s).to_set : Set.new
rescue StandardError
  Set.new
end

.enable!(id) ⇒ Object



167
168
169
170
171
# File 'lib/clacky/extension/loader.rb', line 167

def enable!(id)
  ids = disabled_ids
  ids.delete(id.to_s)
  write_disabled(ids)
end

.invalidate_cache!Object

Discard the mtime cache so the next load_all rescans from disk. Used by CLI commands that mutate the ext tree (new / pack).



139
140
141
# File 'lib/clacky/extension/loader.rb', line 139

def invalidate_cache!
  @last_fingerprint = nil
end

.last_resultObject



133
134
135
# File 'lib/clacky/extension/loader.rb', line 133

def last_result
  @last_result || load_all
end

.load_all(layers: default_layers, force: false) ⇒ Object

Scan all layers, resolve overrides, return a structured Result. layers maps layer name => root dir; defaults to the real three-layer dirs. Tests inject temp dirs through it.

Cached by a fingerprint of every container's ext.yml mtime — a hot path (per web request, per API call) hits the cache; the moment any manifest changes, we invalidate and rescan. Pass force: true to bypass the cache (used by CLI / tests).



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/clacky/extension/loader.rb', line 86

def load_all(layers: default_layers, force: false)
  fingerprint = [fingerprint_layers(layers), disabled_fingerprint]
  if !force && @last_result && @last_fingerprint == fingerprint
    return @last_result
  end

  by_id = {}          # ext_id => resolved container (winning layer)
  overridden = []     # [ext_id, losing_layer, winning_layer]
  errors = []

  layers.each do |layer, root|
    next unless root && Dir.exist?(root)

    Dir.children(root).sort.each do |ext_id|
      next if ext_id.start_with?("_", ".")
      dir = File.join(root, ext_id)
      next unless File.directory?(dir)

      manifest = File.join(dir, MANIFEST)
      next unless File.file?(manifest)

      container = read_container(ext_id, layer, dir, manifest, errors)
      next unless container

      if (prev = by_id[ext_id])
        overridden << [ext_id, prev[:layer], layer]
      end
      by_id[ext_id] = container
    end
  end

  result = Result.new(panels: [], api: [], skills: [], agents: [], channels: [], patches: [], hooks: [], errors: errors, overridden: overridden, containers: by_id)
  disabled = disabled_ids
  by_id.each_value do |container|
    container[:disabled] = disabled.include?(container[:ext_id])
    resolve_units(container, result) unless container[:disabled]
  end

  @last_result = result
  @last_fingerprint = fingerprint
  result
end

.uninstall!(id) ⇒ Object

Remove an installed extension by deleting its installed-layer dir. Only the installed layer is removable (builtin ships with the gem; local is the author's own working copy). Returns true on removal.



176
177
178
179
180
181
182
183
184
# File 'lib/clacky/extension/loader.rb', line 176

def uninstall!(id)
  dir = File.join(INSTALLED_DIR, id.to_s)
  return false unless File.directory?(dir)

  FileUtils.rm_rf(dir)
  enable!(id) # clear any stale disabled flag
  invalidate_cache!
  true
end