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"
DATA_DIR =

User data lives OUTSIDE the package tree so uninstalling (which deletes the package dir) never takes the data with it, and reinstalling the same extension transparently reconnects to it.

File.expand_path("~/.clacky/ext-data")
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

.data_dir_for(id) ⇒ Object

Package-external directory holding an extension's persisted user data.



84
85
86
# File 'lib/clacky/extension/loader.rb', line 84

def data_dir_for(id)
  File.join(DATA_DIR, id.to_s)
end

.default_layersObject



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

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

.dir_for(layer) ⇒ Object



75
76
77
78
79
80
81
# File 'lib/clacky/extension/loader.rb', line 75

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



171
172
173
174
175
# File 'lib/clacky/extension/loader.rb', line 171

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

.disabled?(id) ⇒ Boolean

Returns:

  • (Boolean)


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

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.



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

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



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

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).



149
150
151
# File 'lib/clacky/extension/loader.rb', line 149

def invalidate_cache!
  @last_fingerprint = nil
end

.last_resultObject



143
144
145
# File 'lib/clacky/extension/loader.rb', line 143

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).



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
128
129
130
131
132
133
134
135
136
137
# File 'lib/clacky/extension/loader.rb', line 96

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, purge_data: false) ⇒ 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). Package-external user data is preserved by default so a reinstall reconnects to it; pass purge_data: true to also delete ~/.clacky/ext-data/. Returns true on removal.



189
190
191
192
193
194
195
196
197
198
# File 'lib/clacky/extension/loader.rb', line 189

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

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