legionio 1.4.53 → 1.4.58

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c0d47e1dc027fcf06d57aad2e04cc5217189555f4c2801bad0339bd97b2cef4
4
- data.tar.gz: 591da91efe655fe18c84fa248ef9446c0708d06d5a4ddab2fc7b785ef95aedfc
3
+ metadata.gz: b941e35cffdc0cee05b505548896ec05866dc24c517f42ecc4d9a3f16a77c53f
4
+ data.tar.gz: f0e5f2a91dc3627a3124bd9cbe74fb02ed235fad0fee966b221e48acc935bee7
5
5
  SHA512:
6
- metadata.gz: 4736ccb5a8f385295543757bc8a8e7d26d4aa70fe7fce868cae656dc8217e5c409e82f429c813bb61e8d44c1144587fd6979b06827872df26f44fc959be1371c
7
- data.tar.gz: 35888fd8a241fac71a4d5738b8322b33fdff3114ce2001ad4098d3fb0c84bafdd4c3d5d04febeb8f2a7ee90fd61f800135ec72c37011efe39071fc4097d60764
6
+ metadata.gz: 67561fe5e20de1e404b63218fa971989d44767ff9df893ddc922598e94086e14b5134884c434264bb47449065018c8fe99dad88274c3ed629be0f98345ac0036
7
+ data.tar.gz: fcb1fbe1f834f856410ce41d319dabc115bbbccbd23562783d26cc0efc8672c09eae205a5e7c376533744008cd3675f8b2208b393ba2e0dbc6335d4cb8e27ed8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.58] - 2026-03-17
4
+
5
+ ### Added
6
+ - `legion lex list` now groups output by category (tier order) by default.
7
+ - `legion lex list CATEGORY` filters the list to a specific category (e.g., `legion lex list agentic`).
8
+ - `--flat` option to `legion lex list` restores the original flat table without grouping.
9
+ - `category` and `tier` columns added to the extension table in all display modes.
10
+ - `discover_all` now includes `:category` and `:tier` keys in each extension info hash,
11
+ derived via `Legion::Extensions::Helpers::Segments.categorize_gem`.
12
+ - Results sorted by tier then name for deterministic ordering.
13
+
14
+ ## [1.4.57] - 2026-03-17
15
+
16
+ ### Added
17
+ - `--category` option to `legion lex create`: generates categorized extension gems with nested module
18
+ declarations, nested directory structure, and correct `VERSION` constant paths.
19
+ Example: `legion lex create cognitive-anchor --category agentic` produces gem `lex-agentic-cognitive-anchor`
20
+ with module `Legion::Extensions::Agentic::Cognitive::Anchor`.
21
+ - `LexGenerator` now accepts `gem_name:` keyword argument and uses `Legion::Extensions::Helpers::Segments`
22
+ to derive all namespace, const, and require-path values for both flat and nested extensions.
23
+ - `legion lex create` emits a warning via `Legion::Extensions.check_reserved_words` when reserved
24
+ category prefixes or framework words are used in the gem name.
25
+
26
+ ## [1.4.56] - 2026-03-17
27
+
28
+ ### Fixed
29
+ - `lex_class` now returns the full extension module constant by walking the namespace up to the first `NAMESPACE_BOUNDARIES` word, instead of always stopping at index 2. For nested extensions (`Legion::Extensions::Agentic::Cognitive::Anchor`), this returns `Legion::Extensions::Agentic::Cognitive::Anchor` rather than the incorrect `Legion::Extensions::Agentic`.
30
+ - `lex_const` now derives from `lex_class.to_s.split('::').last` so it returns the extension's root constant name (`Anchor`) rather than always returning the third element of the namespace array.
31
+ - `full_path` now builds the gem name from dash-joined segments (`lex-agentic-cognitive-anchor`) instead of underscore-joined `lex_name`, so `Gem::Specification.find_by_name` works for nested extensions.
32
+
33
+ ## [1.4.55] - 2026-03-17
34
+
35
+ ### Changed
36
+ - `build_default_exchange` now sets `exchange_name` on dynamically created exchange classes to return `amqp_prefix` (dot-joined segments with `legion.` prefix) instead of defaulting to the parent class behavior
37
+ - `auto_create_exchange` now derives `exchange_name` from `amqp_prefix` + the exchange's own downcased class name, replacing the index-based `split('::')[5].downcase` extraction that broke for nested extension namespaces
38
+ ### Fixed
39
+ - `legion config scaffold` now writes to `~/.legionio/settings/` by default instead of `./settings/`
40
+ - Removed Thor `default: './settings'` that shadowed the Ruby fallback in `ConfigScaffold.run`
41
+ - Added `~/.legionio/settings` to `legion config path` search paths to match `Service#default_paths`
42
+
43
+ ## [1.4.54] - 2026-03-17
44
+
45
+ ### Changed
46
+ - `Helpers::Logger#log` now passes `lex_segments:` array to `Legion::Logging::Logger` when the object responds to `:segments`
47
+ - Falls back to `lex:` string for legacy flat extensions that do not implement `:segments`
48
+
3
49
  ## [1.4.53] - 2026-03-17
4
50
 
5
51
  ### Fixed
@@ -167,12 +167,12 @@ module Legion
167
167
  desc 'scaffold', 'Generate starter config files for each subsystem'
168
168
  long_desc <<~DESC
169
169
  Generates JSON config files for LegionIO subsystems (transport, data, cache,
170
- crypt, logging, llm). Files are written to --dir (default: ./settings/).
170
+ crypt, logging, llm). Files are written to --dir (default: ~/.legionio/settings/).
171
171
 
172
172
  By default, generates minimal starter files with only the most commonly
173
173
  changed fields. Use --full for the complete schema with all defaults.
174
174
  DESC
175
- option :dir, type: :string, default: './settings', desc: 'Output directory'
175
+ option :dir, type: :string, desc: 'Output directory (default: ~/.legionio/settings)'
176
176
  option :only, type: :string, desc: 'Comma-separated subsystems (transport,data,cache,crypt,logging,llm)'
177
177
  option :full, type: :boolean, default: false, desc: 'Include all fields with defaults'
178
178
  option :force, type: :boolean, default: false, desc: 'Overwrite existing files'
@@ -194,6 +194,7 @@ module Legion
194
194
  active_found = false
195
195
  [
196
196
  '/etc/legionio',
197
+ File.expand_path('~/.legionio/settings'),
197
198
  File.expand_path('~/legionio'),
198
199
  File.expand_path('./settings')
199
200
  ].map do |path|
@@ -1,10 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'legion/extensions/helpers/segments'
4
5
 
5
6
  module Legion
6
7
  module CLI
7
8
  class Lex < Thor
9
+ DEFAULT_CATEGORIES = {
10
+ core: { type: :list, tier: 1 },
11
+ ai: { type: :list, tier: 2 },
12
+ gaia: { type: :list, tier: 3 },
13
+ agentic: { type: :prefix, tier: 4 }
14
+ }.freeze
15
+
8
16
  def self.exit_on_failure?
9
17
  true
10
18
  end
@@ -12,32 +20,21 @@ module Legion
12
20
  class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
13
21
  class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
22
 
15
- desc 'list', 'List all installed extensions'
16
- option :all, type: :boolean, default: false, aliases: ['-a'], desc: 'Include disabled extensions'
17
- def list
18
- out = formatter
23
+ desc 'list [CATEGORY]', 'List all installed extensions, optionally filtered by category'
24
+ option :all, type: :boolean, default: false, aliases: ['-a'], desc: 'Include disabled extensions'
25
+ option :flat, type: :boolean, default: false, desc: 'Show all extensions in a flat list without category grouping'
26
+ def list(category = nil)
27
+ out = formatter
19
28
  lexs = discover_all
20
29
 
21
- rows = if options[:all]
22
- lexs
23
- else
24
- lexs.reject { |l| l[:status] == 'disabled' }
25
- end
26
-
27
- table_rows = rows.map do |l|
28
- [
29
- l[:name],
30
- l[:version],
31
- out.status(l[:status]),
32
- l[:runners].to_s,
33
- l[:actors].to_s
34
- ]
35
- end
30
+ rows = options[:all] ? lexs : lexs.reject { |l| l[:status] == 'disabled' }
31
+ rows = rows.select { |l| l[:category] == category } if category
36
32
 
37
- out.table(
38
- %w[name version status runners actors],
39
- table_rows
40
- )
33
+ if options[:flat] || category
34
+ render_flat_table(out, rows)
35
+ else
36
+ render_grouped_table(out, rows)
37
+ end
41
38
  end
42
39
  default_task :list
43
40
 
@@ -92,13 +89,22 @@ module Legion
92
89
  end
93
90
 
94
91
  desc 'create NAME', 'Scaffold a new Legion extension'
95
- option :rspec, type: :boolean, default: true, desc: 'Include RSpec setup'
96
- option :github_ci, type: :boolean, default: true, desc: 'Include GitHub Actions CI'
97
- option :git_init, type: :boolean, default: true, desc: 'Initialize git repository'
98
- option :bundle_install, type: :boolean, default: true, desc: 'Run bundle install'
92
+ method_option :rspec, type: :boolean, default: true, desc: 'Include RSpec setup'
93
+ method_option :github_ci, type: :boolean, default: true, desc: 'Include GitHub Actions CI'
94
+ method_option :git_init, type: :boolean, default: true, desc: 'Initialize git repository'
95
+ method_option :bundle_install, type: :boolean, default: true, desc: 'Run bundle install'
96
+ method_option :category, type: :string, default: nil,
97
+ desc: 'Extension category (agentic, ai, gaia). Determines namespace nesting and gem prefix.'
99
98
  def create(name)
100
99
  out = formatter
101
- target_dir = "lex-#{name}"
100
+
101
+ if options[:category] && options[:category] !~ /\A[a-z][a-z0-9_-]*\z/
102
+ out.error('--category must be lowercase letters, numbers, underscores, or hyphens')
103
+ return
104
+ end
105
+
106
+ gem_name = options[:category] ? "lex-#{options[:category]}-#{name}" : "lex-#{name}"
107
+ target_dir = gem_name
102
108
 
103
109
  if Dir.exist?(target_dir)
104
110
  out.error("Directory #{target_dir} already exists")
@@ -110,15 +116,17 @@ module Legion
110
116
  raise SystemExit, 1
111
117
  end
112
118
 
113
- out.success("Creating lex-#{name}...")
119
+ Legion::Extensions.check_reserved_words(gem_name, known_org: false)
120
+
121
+ out.success("Creating #{gem_name}...")
114
122
 
115
123
  vars = { filename: target_dir, class_name: name.split('_').map(&:capitalize).join, lex: name }
116
124
 
117
- generator = LexGenerator.new(name, vars, options)
125
+ generator = LexGenerator.new(name, vars, options, gem_name: gem_name)
118
126
  generator.generate(out)
119
127
 
120
128
  out.spacer
121
- out.success("Extension lex-#{name} created in ./#{target_dir}")
129
+ out.success("Extension #{gem_name} created in ./#{target_dir}")
122
130
  out.spacer
123
131
  puts ' Next steps:'
124
132
  puts " cd #{target_dir}"
@@ -166,6 +174,25 @@ module Legion
166
174
  )
167
175
  end
168
176
 
177
+ def render_flat_table(out, rows)
178
+ table_rows = rows.map do |l|
179
+ [l[:name], l[:version], l[:category].to_s, l[:tier].to_s, out.status(l[:status]), l[:runners].to_s, l[:actors].to_s]
180
+ end
181
+ out.table(%w[name version category tier status runners actors], table_rows)
182
+ end
183
+
184
+ def render_grouped_table(out, rows)
185
+ grouped = rows.group_by { |l| [l[:tier], l[:category]] }
186
+ grouped.keys.sort_by { |tier, cat| [tier, cat.to_s] }.each do |key|
187
+ tier, cat = key
188
+ out.header("=== #{cat} (tier #{tier}) ===")
189
+ group_rows = grouped[key].map do |l|
190
+ [l[:name], l[:version], l[:category].to_s, l[:tier].to_s, out.status(l[:status]), l[:runners].to_s, l[:actors].to_s]
191
+ end
192
+ out.table(%w[name version category tier status runners actors], group_rows)
193
+ end
194
+ end
195
+
169
196
  def discover_all
170
197
  installed = Gem::Specification.select { |s| s.name.start_with?('lex-') }
171
198
 
@@ -177,20 +204,19 @@ module Legion
177
204
  ext_settings = {}
178
205
  end
179
206
 
207
+ categories = resolve_categories
208
+ cat_lists = resolve_cat_lists
209
+
180
210
  result = installed.map do |spec|
181
211
  short_name = spec.name.sub('lex-', '')
182
- class_name = short_name.split('_').map(&:capitalize).join
183
- extension_class = "Legion::Extensions::#{class_name}"
212
+ extension_class = Legion::Extensions::Helpers::Segments.derive_const_path(spec.name)
184
213
 
185
214
  setting = ext_settings[short_name.to_sym] || {}
186
- status = if setting[:enabled] == false
187
- 'disabled'
188
- else
189
- 'installed'
190
- end
215
+ status = setting[:enabled] == false ? 'disabled' : 'installed'
191
216
 
192
217
  runner_info = extract_runners(spec)
193
- actor_info = extract_actors(spec)
218
+ actor_info = extract_actors(spec)
219
+ cat_info = Legion::Extensions::Helpers::Segments.categorize_gem(spec.name, categories: categories, lists: cat_lists)
194
220
 
195
221
  {
196
222
  name: short_name,
@@ -200,10 +226,25 @@ module Legion
200
226
  extension_class: extension_class,
201
227
  runners: runner_info,
202
228
  actors: actor_info,
203
- dependencies: spec.runtime_dependencies.map(&:to_s)
229
+ dependencies: spec.runtime_dependencies.map(&:to_s),
230
+ category: cat_info[:category].to_s,
231
+ tier: cat_info[:tier]
204
232
  }
205
233
  end
206
- result.sort_by { |l| l[:name] }
234
+ result.sort_by { |l| [l[:tier], l[:name]] }
235
+ end
236
+
237
+ def resolve_categories
238
+ raw = Legion::Settings.dig(:extensions, :categories)
239
+ raw.nil? || raw.empty? ? DEFAULT_CATEGORIES : raw
240
+ end
241
+
242
+ def resolve_cat_lists
243
+ {
244
+ core: Array(Legion::Settings.dig(:extensions, :core)),
245
+ ai: Array(Legion::Settings.dig(:extensions, :ai)),
246
+ gaia: Array(Legion::Settings.dig(:extensions, :gaia))
247
+ }
207
248
  end
208
249
 
209
250
  def find_lex(name)
@@ -212,7 +253,8 @@ module Legion
212
253
  end
213
254
 
214
255
  def extract_runners(spec)
215
- runner_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions', spec.name.sub('lex-', ''), 'runners')
256
+ runner_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions',
257
+ Legion::Extensions::Helpers::Segments.derive_segments(spec.name).join('/'), 'runners')
216
258
  return [] unless Dir.exist?(runner_dir)
217
259
 
218
260
  Dir.glob("#{runner_dir}/*.rb").map { |f| File.basename(f, '.rb') }
@@ -221,7 +263,8 @@ module Legion
221
263
  end
222
264
 
223
265
  def extract_actors(spec)
224
- actor_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions', spec.name.sub('lex-', ''), 'actors')
266
+ actor_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions',
267
+ Legion::Extensions::Helpers::Segments.derive_segments(spec.name).join('/'), 'actors')
225
268
  return [] unless Dir.exist?(actor_dir)
226
269
 
227
270
  Dir.glob("#{actor_dir}/*.rb").map do |f|
@@ -255,11 +298,12 @@ module Legion
255
298
 
256
299
  # Thin generator class that wraps the template logic
257
300
  class LexGenerator
258
- def initialize(name, vars, options)
259
- @name = name
260
- @vars = vars
301
+ def initialize(name, vars, options, gem_name: nil)
302
+ @name = name
303
+ @vars = vars
261
304
  @options = options
262
- @target = "lex-#{name}"
305
+ @gem_name = gem_name || "lex-#{name}"
306
+ @target = @gem_name
263
307
  end
264
308
 
265
309
  def generate(out)
@@ -270,24 +314,76 @@ module Legion
270
314
 
271
315
  private
272
316
 
273
- def create_structure(out)
317
+ attr_reader :gem_name
318
+
319
+ def target_dir
320
+ @target
321
+ end
322
+
323
+ def namespace_segments
324
+ @namespace_segments ||= Legion::Extensions::Helpers::Segments.derive_namespace(@gem_name)
325
+ end
326
+
327
+ def const_path
328
+ @const_path ||= Legion::Extensions::Helpers::Segments.derive_const_path(@gem_name)
329
+ end
330
+
331
+ def require_path
332
+ @require_path ||= Legion::Extensions::Helpers::Segments.derive_require_path(@gem_name)
333
+ end
334
+
335
+ def extension_dirs
336
+ base = "#{@target}/lib/legion/extensions"
337
+ segs = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name)
274
338
  dirs = [
275
339
  @target,
276
340
  "#{@target}/lib",
277
341
  "#{@target}/lib/legion",
278
- "#{@target}/lib/legion/extensions",
279
- "#{@target}/lib/legion/extensions/#{@name}",
280
- "#{@target}/lib/legion/extensions/#{@name}/runners",
281
- "#{@target}/lib/legion/extensions/#{@name}/actors",
282
- "#{@target}/lib/legion/extensions/#{@name}/tools",
342
+ base
343
+ ]
344
+ segs.each_with_index do |_, i|
345
+ dirs << "#{base}/#{segs[0..i].join('/')}"
346
+ end
347
+ dirs += [
348
+ "#{base}/#{segs.join('/')}/runners",
349
+ "#{base}/#{segs.join('/')}/actors",
350
+ "#{base}/#{segs.join('/')}/tools",
283
351
  "#{@target}/spec",
284
352
  "#{@target}/spec/legion"
285
353
  ]
354
+ dirs
355
+ end
356
+
357
+ def module_open_lines
358
+ indent = ' '
359
+ lines = ["module Legion\n", "#{indent}module Extensions\n"]
360
+ namespace_segments.each_with_index do |seg, i|
361
+ lines << "#{indent * (i + 2)}module #{seg}\n"
362
+ end
363
+ lines
364
+ end
365
+
366
+ def module_close_lines
367
+ depth = namespace_segments.length + 2
368
+ (1..depth).map { |i| "#{' ' * (depth - i)}end\n" }
369
+ end
370
+
371
+ def nested_module_wrap(inner_lines)
372
+ opens = module_open_lines
373
+ closes = module_close_lines
374
+ (opens + inner_lines + closes).join
375
+ end
286
376
 
377
+ def create_structure(out)
378
+ dirs = extension_dirs
287
379
  dirs << "#{@target}/.github/workflows" if @options[:github_ci]
288
380
 
289
381
  dirs.each { |d| FileUtils.mkdir_p(d) }
290
- FileUtils.touch("#{@target}/lib/legion/extensions/#{@name}/tools/.gitkeep")
382
+
383
+ ext_base = "lib/legion/extensions/#{Legion::Extensions::Helpers::Segments.derive_segments(@gem_name).join('/')}"
384
+ FileUtils.touch("#{@target}/#{ext_base}/tools/.gitkeep")
385
+
386
+ entry_file = "lib/legion/extensions/#{require_path.split('legion/extensions/').last}"
291
387
 
292
388
  write_template("#{@target}/#{@target}.gemspec", gemspec_content)
293
389
  write_template("#{@target}/Gemfile", gemfile_content)
@@ -295,13 +391,15 @@ module Legion
295
391
  write_template("#{@target}/.rubocop.yml", rubocop_content)
296
392
  write_template("#{@target}/LICENSE", license_content)
297
393
  write_template("#{@target}/README.md", readme_content)
298
- write_template("#{@target}/lib/legion/extensions/#{@name}.rb", extension_entry_content)
299
- write_template("#{@target}/lib/legion/extensions/#{@name}/version.rb", version_content)
300
- write_template("#{@target}/lib/legion/extensions/#{@name}/client.rb", client_content)
394
+ write_template("#{@target}/lib/#{entry_file}.rb", extension_entry_content)
395
+ write_template("#{@target}/#{ext_base}/version.rb", version_content)
396
+ write_template("#{@target}/#{ext_base}/client.rb", client_content)
301
397
 
302
398
  if @options[:rspec]
399
+ spec_relative = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name).join('/')
400
+ FileUtils.mkdir_p("#{@target}/spec/legion/extensions/#{File.dirname(spec_relative)}")
303
401
  write_template("#{@target}/spec/spec_helper.rb", spec_helper_content)
304
- write_template("#{@target}/spec/legion/#{@name}_spec.rb", spec_content)
402
+ write_template("#{@target}/spec/legion/extensions/#{spec_relative}_spec.rb", spec_content)
305
403
  end
306
404
 
307
405
  if @options[:github_ci]
@@ -336,16 +434,16 @@ module Legion
336
434
  <<~RUBY
337
435
  # frozen_string_literal: true
338
436
 
339
- require_relative 'lib/legion/extensions/#{@name}/version'
437
+ require_relative 'lib/#{require_path}/version'
340
438
 
341
439
  Gem::Specification.new do |spec|
342
- spec.name = '#{@target}'
343
- spec.version = Legion::Extensions::#{@vars[:class_name]}::VERSION
440
+ spec.name = '#{@gem_name}'
441
+ spec.version = #{const_path}::VERSION
344
442
  spec.authors = ['Esity']
345
443
  spec.email = ['matthewdiverson@gmail.com']
346
- spec.summary = 'A LegionIO Extension for #{@vars[:class_name]}'
347
- spec.description = 'A LegionIO Extension (LEX) for #{@vars[:class_name]}'
348
- spec.homepage = 'https://github.com/LegionIO/#{@target}'
444
+ spec.summary = 'A LegionIO Extension for #{namespace_segments.last}'
445
+ spec.description = 'A LegionIO Extension (LEX) for #{namespace_segments.last}'
446
+ spec.homepage = 'https://github.com/LegionIO/#{@gem_name}'
349
447
  spec.license = 'MIT'
350
448
  spec.required_ruby_version = '>= 3.4'
351
449
 
@@ -432,14 +530,14 @@ module Legion
432
530
 
433
531
  def readme_content
434
532
  <<~MD
435
- # lex-#{@name}
533
+ # #{@gem_name}
436
534
 
437
- A [LegionIO](https://github.com/LegionIO) extension for #{@vars[:class_name]}.
535
+ A [LegionIO](https://github.com/LegionIO) extension for #{namespace_segments.last}.
438
536
 
439
537
  ## Installation
440
538
 
441
539
  ```ruby
442
- gem 'lex-#{@name}'
540
+ gem '#{@gem_name}'
443
541
  ```
444
542
 
445
543
  ## Usage
@@ -461,64 +559,44 @@ module Legion
461
559
  end
462
560
 
463
561
  def extension_entry_content
464
- <<~RUBY
465
- # frozen_string_literal: true
466
-
467
- require_relative '#{@name}/version'
468
- require_relative '#{@name}/client'
469
-
470
- module Legion
471
- module Extensions
472
- module #{@vars[:class_name]}
473
- end
474
- end
475
- end
476
- RUBY
562
+ segs = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name)
563
+ last_seg = segs.last
564
+ inner = [" require_relative '#{last_seg}/version'\n",
565
+ " require_relative '#{last_seg}/client'\n",
566
+ "\n"]
567
+ "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}"
477
568
  end
478
569
 
479
570
  def version_content
480
- <<~RUBY
481
- # frozen_string_literal: true
482
-
483
- module Legion
484
- module Extensions
485
- module #{@vars[:class_name]}
486
- VERSION = '0.1.0'
487
- end
488
- end
489
- end
490
- RUBY
571
+ depth = namespace_segments.length + 2
572
+ inner = ["#{' ' * depth}VERSION = '0.1.0'\n"]
573
+ "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}"
491
574
  end
492
575
 
493
576
  def client_content
494
- <<~RUBY
495
- # frozen_string_literal: true
496
-
497
- module Legion
498
- module Extensions
499
- module #{@vars[:class_name]}
500
- class Client
501
- attr_reader :opts
502
-
503
- def initialize(**kwargs)
504
- @opts = kwargs
505
- end
506
-
507
- def connection(**override)
508
- Helpers::Client.connection(**@opts, **override)
509
- end
510
- end
511
- end
512
- end
513
- end
514
- RUBY
577
+ depth = namespace_segments.length + 2
578
+ pad = ' ' * depth
579
+ inner = [
580
+ "#{pad}class Client\n",
581
+ "#{pad} attr_reader :opts\n",
582
+ "\n",
583
+ "#{pad} def initialize(**kwargs)\n",
584
+ "#{pad} @opts = kwargs\n",
585
+ "#{pad} end\n",
586
+ "\n",
587
+ "#{pad} def connection(**override)\n",
588
+ "#{pad} Helpers::Client.connection(**@opts, **override)\n",
589
+ "#{pad} end\n",
590
+ "#{pad}end\n"
591
+ ]
592
+ "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}"
515
593
  end
516
594
 
517
595
  def spec_helper_content
518
596
  <<~RUBY
519
597
  # frozen_string_literal: true
520
598
 
521
- require 'legion/extensions/#{@name}'
599
+ require '#{require_path}'
522
600
 
523
601
  RSpec.configure do |config|
524
602
  config.expect_with :rspec do |expectations|
@@ -532,9 +610,9 @@ module Legion
532
610
  <<~RUBY
533
611
  # frozen_string_literal: true
534
612
 
535
- RSpec.describe Legion::Extensions::#{@vars[:class_name]} do
613
+ RSpec.describe #{const_path} do
536
614
  it 'has a version number' do
537
- expect(Legion::Extensions::#{@vars[:class_name]}::VERSION).not_to be_nil
615
+ expect(#{const_path}::VERSION).not_to be_nil
538
616
  end
539
617
  end
540
618
  RUBY
@@ -5,6 +5,7 @@ require_relative 'builders/helpers'
5
5
  require_relative 'builders/hooks'
6
6
  require_relative 'builders/runners'
7
7
 
8
+ require_relative 'helpers/segments'
8
9
  require_relative 'helpers/core'
9
10
  require_relative 'helpers/task'
10
11
  require_relative 'helpers/logger'
@@ -4,19 +4,62 @@ module Legion
4
4
  module Extensions
5
5
  module Helpers
6
6
  module Base
7
+ # Words that mark the boundary between extension namespace segments and
8
+ # internal module structure. Segment extraction stops at these words.
9
+ NAMESPACE_BOUNDARIES = %w[Actor Actors Runners Helpers Transport Data].freeze
10
+
11
+ def segments
12
+ @segments ||= derive_segments_from_namespace
13
+ end
14
+
15
+ def lex_slug
16
+ segments.join('.')
17
+ end
18
+
19
+ def log_tag
20
+ Helpers::Segments.segments_to_log_tag(segments)
21
+ end
22
+
23
+ def amqp_prefix
24
+ Helpers::Segments.segments_to_amqp_prefix(segments)
25
+ end
26
+
27
+ def settings_path
28
+ Helpers::Segments.segments_to_settings_path(segments)
29
+ end
30
+
31
+ def table_prefix
32
+ Helpers::Segments.segments_to_table_prefix(segments)
33
+ end
34
+
7
35
  def lex_class
8
- @lex_class ||= Kernel.const_get(calling_class_array[0..2].join('::'))
36
+ @lex_class ||= begin
37
+ parts = calling_class_array
38
+ ext_idx = parts.index('Extensions')
39
+ # All LEX extensions must be under Legion::Extensions::. If 'Extensions'
40
+ # is not present, this is a misconfigured caller — fail loudly.
41
+ raise ArgumentError, "#{calling_class} is not under Legion::Extensions namespace" unless ext_idx
42
+
43
+ end_idx = ext_idx + 1
44
+ end_idx += 1 while end_idx < parts.length && !NAMESPACE_BOUNDARIES.include?(parts[end_idx])
45
+ # NameError cannot occur here: lex_class is only ever called from autobuild,
46
+ # build_transport, build_runners, build_actors, and transport helpers — all of
47
+ # which execute while the extension module is already required and fully defined.
48
+ # The constant we resolve (e.g. Legion::Extensions::Http) is the very module
49
+ # that owns this method, so it must already exist.
50
+ Kernel.const_get(parts[0...end_idx].join('::'))
51
+ end
9
52
  end
10
53
  alias extension_class lex_class
11
54
 
12
55
  def lex_name
13
- @lex_name ||= calling_class_array[2].gsub(/(?<!^)[A-Z]/) { "_#{Regexp.last_match(0)}" }.downcase
56
+ segments.join('_')
14
57
  end
15
58
  alias extension_name lex_name
16
59
  alias lex_filename lex_name
17
60
 
18
61
  def lex_const
19
- @lex_const ||= calling_class_array[2]
62
+ @lex_const ||= lex_class.to_s.split('::').last
20
63
  end
21
64
 
22
65
  def calling_class
@@ -52,7 +95,12 @@ module Legion
52
95
  end
53
96
 
54
97
  def full_path
55
- @full_path ||= "#{Gem::Specification.find_by_name("lex-#{lex_name}").gem_dir}/lib/legion/extensions/#{lex_filename}"
98
+ @full_path ||= begin
99
+ gem_name = "lex-#{segments.join('-')}"
100
+ gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir
101
+ require_path = Helpers::Segments.derive_require_path(gem_name)
102
+ "#{gem_dir}/lib/#{require_path}"
103
+ end
56
104
  end
57
105
  alias extension_path full_path
58
106
 
@@ -78,6 +126,28 @@ module Legion
78
126
  end
79
127
  end
80
128
  end
129
+
130
+ private
131
+
132
+ def derive_segments_from_namespace
133
+ parts = calling_class_array
134
+ ext_idx = parts.index('Extensions')
135
+ return [camelize_to_snake(parts[0])] unless ext_idx
136
+
137
+ ext_parts = []
138
+ ((ext_idx + 1)...parts.length).each do |i|
139
+ break if NAMESPACE_BOUNDARIES.include?(parts[i])
140
+
141
+ ext_parts << camelize_to_snake(parts[i])
142
+ end
143
+ ext_parts.empty? ? [camelize_to_snake(parts[ext_idx + 1])] : ext_parts
144
+ end
145
+
146
+ def camelize_to_snake(str)
147
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
148
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
149
+ .downcase
150
+ end
81
151
  end
82
152
  end
83
153
  end
@@ -7,8 +7,11 @@ module Legion
7
7
  def log
8
8
  return @log unless @log.nil?
9
9
 
10
- logger_hash = { lex: lex_filename || nil }
11
- logger_hash[:lex] = lex_filename.first if logger_hash[:lex].is_a? Array
10
+ logger_hash = if respond_to?(:segments)
11
+ { lex_segments: Array(segments) }
12
+ else
13
+ { lex: lex_filename.is_a?(Array) ? lex_filename.first : lex_filename }
14
+ end
12
15
  if respond_to?(:settings) && settings.key?(:logger)
13
16
  logger_hash[:level] = settings[:logger].key?(:level) ? settings[:logger][:level] : 'info'
14
17
  logger_hash[:log_file] = settings[:logger][:log_file] if settings[:logger].key? :log_file
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Helpers
6
+ module Segments
7
+ module_function
8
+
9
+ def derive_segments(gem_name)
10
+ gem_name.delete_prefix('lex-').split('-')
11
+ end
12
+
13
+ def derive_namespace(gem_name)
14
+ derive_segments(gem_name).map { |s| s.split('_').map(&:capitalize).join }
15
+ end
16
+
17
+ def derive_const_path(gem_name)
18
+ "Legion::Extensions::#{derive_namespace(gem_name).join('::')}"
19
+ end
20
+
21
+ def derive_require_path(gem_name)
22
+ "legion/extensions/#{derive_segments(gem_name).join('/')}"
23
+ end
24
+
25
+ def segments_to_log_tag(segments)
26
+ segments.map { |s| "[#{s}]" }.join
27
+ end
28
+
29
+ def segments_to_amqp_prefix(segments)
30
+ "legion.#{segments.join('.')}"
31
+ end
32
+
33
+ def segments_to_settings_path(segments)
34
+ segments.map(&:to_sym)
35
+ end
36
+
37
+ def segments_to_table_prefix(segments)
38
+ segments.join('_')
39
+ end
40
+
41
+ def categorize_gem(gem_name, categories:, lists:)
42
+ # Check defined lists first (list membership takes priority)
43
+ lists.each do |cat_name, gem_list|
44
+ next unless categories.key?(cat_name)
45
+
46
+ return { category: cat_name, tier: categories[cat_name][:tier] } if gem_list.include?(gem_name)
47
+ end
48
+
49
+ # Check prefix-matched categories
50
+ bare = gem_name.delete_prefix('lex-')
51
+ categories.each do |cat_name, cat_config|
52
+ next unless cat_config[:type] == :prefix
53
+
54
+ return { category: cat_name, tier: cat_config[:tier] } if bare.start_with?("#{cat_name}-")
55
+ end
56
+
57
+ { category: :default, tier: 5 }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -33,12 +33,13 @@ module Legion
33
33
  end
34
34
 
35
35
  def build_default_exchange
36
- exchange = "#{transport_class}::Exchanges::#{lex_const}"
37
- return Object.const_get(exchange) if transport_class::Exchanges.const_defined? lex_const
36
+ return transport_class::Exchanges.const_get(lex_const) if transport_class::Exchanges.const_defined? lex_const
38
37
 
39
- transport_class::Exchanges.const_set(lex_const, Class.new(Legion::Transport::Exchange))
40
- @default_exchange = Kernel.const_get(exchange)
41
- @default_exchange
38
+ amqp = amqp_prefix
39
+ transport_class::Exchanges.const_set(lex_const, Class.new(Legion::Transport::Exchange) do
40
+ define_method(:exchange_name) { amqp }
41
+ end)
42
+ @default_exchange = transport_class::Exchanges.const_get(lex_const)
42
43
  end
43
44
  end
44
45
  end
@@ -52,10 +52,9 @@ module Legion
52
52
  end
53
53
  return build_default_exchange if default_exchange
54
54
 
55
+ ext_amqp = amqp_prefix
55
56
  transport_class::Exchanges.const_set(exchange.split('::').pop, Class.new(Legion::Transport::Exchange) do
56
- def exchange_name
57
- self.class.ancestors.first.to_s.split('::')[5].downcase
58
- end
57
+ define_method(:exchange_name) { "#{ext_amqp}.#{self.class.to_s.split('::').last.downcase}" }
59
58
  end)
60
59
  end
61
60
 
@@ -39,23 +39,25 @@ module Legion
39
39
  end
40
40
 
41
41
  def load_extensions
42
- @extensions ||= {}
42
+ @extensions ||= []
43
43
  @loaded_extensions ||= []
44
- @extensions.each do |extension, values|
45
- if values.key(:enabled) && !values[:enabled]
46
- Legion::Logging.info "Skipping #{extension} because it's disabled"
44
+ @extensions.each do |entry|
45
+ gem_name = entry[:gem_name]
46
+ ext_name = entry[:require_path].split('/').last
47
+
48
+ if Legion::Settings[:extensions].key?(ext_name.to_sym) &&
49
+ Legion::Settings[:extensions][ext_name.to_sym].is_a?(Hash) &&
50
+ Legion::Settings[:extensions][ext_name.to_sym].key?(:enabled) &&
51
+ !Legion::Settings[:extensions][ext_name.to_sym][:enabled]
52
+ Legion::Logging.info "Skipping #{gem_name} because it's disabled"
47
53
  next
48
54
  end
49
55
 
50
- if Legion::Settings[:extensions].key?(extension.to_sym) && Legion::Settings[:extensions][extension.to_sym].key?(:enabled) && !Legion::Settings[:extensions][extension.to_sym][:enabled] # rubocop:disable Layout/LineLength
56
+ unless load_extension(entry)
57
+ Legion::Logging.warn("#{gem_name} failed to load")
51
58
  next
52
59
  end
53
-
54
- unless load_extension(extension, values)
55
- Legion::Logging.warn("#{extension} failed to load")
56
- next
57
- end
58
- @loaded_extensions.push(extension)
60
+ @loaded_extensions.push(gem_name)
59
61
  end
60
62
  Legion::Logging.info(
61
63
  "#{@extensions.count} extensions loaded with " \
@@ -67,38 +69,47 @@ module Legion
67
69
  )
68
70
  end
69
71
 
70
- def load_extension(extension, values) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength
71
- return unless gem_load(values[:gem_name], extension)
72
+ def load_extension(entry) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength
73
+ ensure_namespace(entry[:const_path]) if entry[:segments].length > 1
74
+ return unless gem_load(entry)
72
75
 
73
- extension = Kernel.const_get(values[:extension_class])
76
+ extension = Kernel.const_get(entry[:const_path])
74
77
  extension.extend Legion::Extensions::Core unless extension.singleton_class.include?(Legion::Extensions::Core)
75
78
 
76
- ext_settings = Legion::Settings[:extensions][values[:extension_name]]
79
+ ext_name = entry[:segments].join('_')
80
+ ext_settings = Legion::Settings[:extensions][ext_name.to_sym]
77
81
  min_version = ext_settings[:min_version] if ext_settings.is_a?(Hash)
78
- Legion::Logging.fatal values if min_version.is_a?(String) && Gem::Version.new(values[:version]) >= Gem::Version.new(min_version)
82
+ if min_version.is_a?(String)
83
+ begin
84
+ gem_spec = Gem::Specification.find_by_name(entry[:gem_name])
85
+ Legion::Logging.fatal entry if Gem::Version.new(gem_spec.version.to_s) >= Gem::Version.new(min_version)
86
+ rescue Gem::MissingSpecError
87
+ Legion::Logging.warn "Could not find gem spec for #{entry[:gem_name]}, skipping min_version check"
88
+ end
89
+ end
79
90
 
80
91
  if extension.data_required? && Legion::Settings[:data][:connected] == false
81
- Legion::Logging.warn "#{values[:extension_name]} requires Legion::Data but isn't enabled, skipping"
92
+ Legion::Logging.warn "#{ext_name} requires Legion::Data but isn't enabled, skipping"
82
93
  return false
83
94
  end
84
95
 
85
96
  if extension.cache_required? && Legion::Settings[:cache][:connected] == false
86
- Legion::Logging.warn "#{values[:extension_name]} requires Legion::Cache but isn't enabled, skipping"
97
+ Legion::Logging.warn "#{ext_name} requires Legion::Cache but isn't enabled, skipping"
87
98
  return false
88
99
  end
89
100
 
90
101
  if extension.crypt_required? && Legion::Settings[:crypt][:cs].nil?
91
- Legion::Logging.warn "#{values[:extension_name]} requires Legion::Crypt but isn't ready, skipping"
102
+ Legion::Logging.warn "#{ext_name} requires Legion::Crypt but isn't ready, skipping"
92
103
  return false
93
104
  end
94
105
 
95
106
  if extension.vault_required? && Legion::Settings[:crypt][:vault][:connected] == false
96
- Legion::Logging.warn "#{values[:extension_name]} requires Legion::Crypt::Vault but isn't enabled, skipping"
107
+ Legion::Logging.warn "#{ext_name} requires Legion::Crypt::Vault but isn't enabled, skipping"
97
108
  return false
98
109
  end
99
110
 
100
111
  if extension.llm_required? && (!Legion::Settings.key?(:llm) || Legion::Settings[:llm][:connected] == false)
101
- Legion::Logging.warn "#{values[:extension_name]} requires Legion::LLM but isn't enabled, skipping"
112
+ Legion::Logging.warn "#{ext_name} requires Legion::LLM but isn't enabled, skipping"
102
113
  return false
103
114
  end
104
115
 
@@ -120,14 +131,14 @@ module Legion
120
131
  hook_actor(**actor)
121
132
  end
122
133
  extension.log.info "Loaded v#{extension::VERSION}"
123
- Legion::Events.emit('extension.loaded', name: values[:extension_name], version: values[:version])
134
+ Legion::Events.emit('extension.loaded', name: ext_name, version: entry[:gem_name])
124
135
 
125
136
  begin
126
137
  if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
127
- worker_id = "lex-#{values[:extension_name]}"
138
+ worker_id = "lex-#{ext_name}"
128
139
  worker = Legion::Data::Model::DigitalWorker.find_or_create(worker_id: worker_id) do |w|
129
- w.name = values[:extension_name]
130
- w.extension_name = values[:extension_name]
140
+ w.name = ext_name
141
+ w.extension_name = ext_name
131
142
  w.lifecycle_state = 'active'
132
143
  w.risk_tier = 'low'
133
144
  w.team = 'extensions'
@@ -198,15 +209,27 @@ module Legion
198
209
  end
199
210
  end
200
211
 
201
- def gem_load(gem_name, name)
202
- gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir
203
- require "#{gem_dir}/lib/legion/extensions/#{name}"
212
+ def gem_load(entry)
213
+ gem_name = entry[:gem_name]
214
+ require_path = entry[:require_path]
215
+ gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir
216
+ require "#{gem_dir}/lib/#{require_path}"
204
217
  true
218
+ rescue Gem::MissingSpecError => e
219
+ Legion::Logging.warn "#{gem_name} gem not found: #{e.message}"
220
+ nil
205
221
  rescue LoadError => e
206
- Legion::Logging.error e.message
207
- Legion::Logging.error e.backtrace
208
- Legion::Logging.error "gem_path: #{gem_dir}" if defined?(gem_dir) && gem_dir
209
- false
222
+ Legion::Logging.warn "#{gem_name} failed to load: #{e.message}"
223
+ nil
224
+ end
225
+
226
+ def ensure_namespace(const_path)
227
+ parts = const_path.split('::')
228
+ current = ::Legion::Extensions
229
+ parts[2...-1].each do |part|
230
+ current.const_set(part, Module.new) unless current.const_defined?(part, false)
231
+ current = current.const_get(part, false)
232
+ end
210
233
  end
211
234
 
212
235
  def gem_names_for_discovery
@@ -222,17 +245,11 @@ module Legion
222
245
  return if role.nil? || role[:profile].nil?
223
246
 
224
247
  profile = role[:profile].to_sym
225
- allowed = case profile
226
- when :core then core_extension_names
227
- when :cognitive then core_extension_names + agentic_extension_names
228
- when :service then core_extension_names + service_extension_names + other_extension_names
229
- when :dev then core_extension_names + ai_extension_names + dev_agentic_names
230
- when :custom then Array(role[:extensions]).map(&:to_s)
231
- else return
232
- end
248
+ allowed = allowed_gem_names_for_profile(profile, role)
249
+ return if allowed.nil?
233
250
 
234
251
  before = @extensions.count
235
- @extensions.select! { |name, _| allowed.include?(name) }
252
+ @extensions.select! { |entry| allowed.include?(entry[:gem_name]) }
236
253
  Legion::Logging.info "Role profile :#{profile} filtered #{before} -> #{@extensions.count} extensions"
237
254
  end
238
255
 
@@ -260,61 +277,160 @@ module Legion
260
277
  end
261
278
 
262
279
  def agentic_extension_names
263
- known = core_extension_names + service_extension_names + other_extension_names + ai_extension_names
264
- @extensions.keys.reject { |name| known.include?(name) }
280
+ known_gem_names = (core_extension_names + service_extension_names + other_extension_names + ai_extension_names).map { |n| "lex-#{n}" }
281
+ Array(@extensions).reject { |entry| known_gem_names.include?(entry[:gem_name]) }.map { |entry| entry[:gem_name] }
265
282
  end
266
283
 
267
- def find_extensions
268
- @extensions ||= {}
269
- gem_names_for_discovery.each do |spec|
270
- next unless spec[:name].start_with?('lex-')
271
-
272
- ext_name = spec[:name].delete_prefix('lex-').tr('-', '_')
273
- @extensions[ext_name] = { full_gem_name: "#{spec[:name]}-#{spec[:version]}",
274
- gem_name: spec[:name],
275
- extension_name: ext_name,
276
- version: spec[:version],
277
- extension_class: "Legion::Extensions::#{ext_name.split('_').collect(&:capitalize).join}" }
284
+ def categorize_and_order(gem_names)
285
+ ext_settings = ::Legion::Settings[:extensions] || {}
286
+ categories = ext_settings[:categories] || default_category_registry
287
+ lists = {
288
+ core: Array(ext_settings[:core]),
289
+ ai: Array(ext_settings[:ai]),
290
+ gaia: Array(ext_settings[:gaia])
291
+ }
292
+ ctx = {
293
+ blocked: Array(ext_settings[:blocked]),
294
+ agentic_cfg: ext_settings[:agentic] || {},
295
+ categories: categories,
296
+ gem_set: gem_names.to_set,
297
+ ordered: [],
298
+ claimed: Set.new
299
+ }
300
+
301
+ collect_list_category_gems(lists, ctx)
302
+ collect_prefix_category_gems(gem_names, ctx)
303
+
304
+ (gem_names.to_a - ctx[:claimed].to_a - ctx[:blocked]).sort.each do |gn|
305
+ ctx[:ordered] << build_extension_entry(gn, :default, categories, nesting: false)
306
+ end
307
+
308
+ ctx[:ordered]
309
+ end
310
+
311
+ def check_reserved_words(gem_name, known_org: true)
312
+ return if known_org
313
+
314
+ bare = gem_name.delete_prefix('lex-')
315
+ first_segment = bare.split('-').first
316
+
317
+ configured_prefixes = begin
318
+ Array(::Legion::Settings.dig(:extensions, :reserved_prefixes))
319
+ rescue StandardError
320
+ []
321
+ end
322
+ reserved_prefixes = configured_prefixes.empty? ? %w[core ai agentic gaia] : configured_prefixes
323
+
324
+ configured_words = begin
325
+ Array(::Legion::Settings.dig(:extensions, :reserved_words))
326
+ rescue StandardError
327
+ []
328
+ end
329
+ reserved_words = configured_words.empty? ? %w[transport cache crypt data settings json logging llm rbac legion] : configured_words
330
+
331
+ if reserved_prefixes.include?(first_segment)
332
+ ::Legion::Logging.warn(
333
+ "#{gem_name} uses reserved prefix '#{first_segment}' — " \
334
+ "it will be loaded in the #{first_segment} category namespace"
335
+ )
336
+ elsif reserved_words.include?(first_segment)
337
+ ::Legion::Logging.warn(
338
+ "#{gem_name} uses reserved word '#{first_segment}' as its first segment — " \
339
+ 'this may shadow framework modules'
340
+ )
278
341
  end
342
+ end
343
+
344
+ def find_extensions
345
+ return @extensions if @extensions
279
346
 
347
+ all_specs = gem_names_for_discovery
348
+ lex_names = all_specs.select { |s| s[:name].start_with?('lex-') }.map { |s| s[:name] }
349
+ @extensions = categorize_and_order(lex_names)
280
350
  apply_role_filter
351
+ @extensions
352
+ end
353
+
354
+ private
355
+
356
+ def lex_prefix(names)
357
+ names.map { |n| n.start_with?('lex-') ? n : "lex-#{n}" }
358
+ end
281
359
 
282
- enabled = 0
283
- requested = 0
284
-
285
- Legion::Settings[:extensions].each do |extension, values|
286
- next if @extensions.key? extension.to_s
287
- next if values[:enabled] == false
288
-
289
- requested += 1
290
- next if values[:auto_install] == false
291
- next if ENV['_'].include? 'bundle'
292
-
293
- Legion::Logging.warn "#{extension} is missing, attempting to install automatically.."
294
- install = Gem.install("lex-#{extension}", values[:version])
295
- Legion::Logging.debug(install)
296
- lex = Gem::Specification.find_by_name("lex-#{extension}")
297
-
298
- @extensions[extension.to_s] = {
299
- full_gem_name: "lex-#{extension}-#{lex.version}",
300
- gem_name: "lex-#{extension}",
301
- extension_name: extension.to_s,
302
- version: lex.version,
303
- extension_class: "Legion::Extensions::#{extension.to_s.split('_').collect(&:capitalize).join}"
304
- }
305
-
306
- enabled += 1
307
- rescue StandardError, Gem::MissingSpecError => e
308
- Legion::Logging.error "Failed to auto install #{extension}, e: #{e.message}"
360
+ def allowed_gem_names_for_profile(profile, role)
361
+ case profile
362
+ when :core then lex_prefix(core_extension_names)
363
+ when :cognitive then lex_prefix(core_extension_names + agentic_extension_names)
364
+ when :service then lex_prefix(core_extension_names + service_extension_names + other_extension_names)
365
+ when :dev then lex_prefix(core_extension_names + ai_extension_names + dev_agentic_names)
366
+ when :custom then lex_prefix(Array(role[:extensions]).map(&:to_s))
309
367
  end
310
- return true if requested == enabled
368
+ end
369
+
370
+ def collect_list_category_gems(lists, ctx)
371
+ lists.sort_by { |cat, _| ctx[:categories].dig(cat, :tier) || 99 }.each do |cat_name, gem_list|
372
+ gem_list.each do |gn|
373
+ next unless ctx[:gem_set].include?(gn)
374
+ next if ctx[:blocked].include?(gn)
311
375
 
312
- Legion::Logging.warn "A total of #{requested - enabled} where skipped"
313
- if ENV.key?('_') && ENV['_'].include?('bundle')
314
- Legion::Logging.warn 'Please add them to your Gemfile since you are using bundler'
376
+ ctx[:ordered] << build_extension_entry(gn, cat_name, ctx[:categories], nesting: false)
377
+ ctx[:claimed].add(gn)
378
+ end
379
+ end
380
+ end
381
+
382
+ def collect_prefix_category_gems(gem_names, ctx)
383
+ prefix_cats = ctx[:categories].select { |_, v| v[:type].to_s == 'prefix' }
384
+ .sort_by { |_, v| v[:tier] || 99 }
385
+ .to_h
386
+ prefix_cats.each_key do |cat_name|
387
+ prefix = "lex-#{cat_name}-"
388
+ matched = gem_names.select { |gn| gn.start_with?(prefix) && !ctx[:claimed].include?(gn) }.sort
389
+ matched.each do |gn|
390
+ next if ctx[:blocked].include?(gn)
391
+ next if cat_name == :agentic && agentic_blocked?(gn, ctx[:agentic_cfg])
392
+ next if cat_name == :agentic && !agentic_allowed?(gn, ctx[:agentic_cfg])
393
+
394
+ ctx[:ordered] << build_extension_entry(gn, cat_name, ctx[:categories], nesting: true)
395
+ ctx[:claimed].add(gn)
396
+ end
397
+ end
398
+ end
399
+
400
+ def build_extension_entry(gem_name, category, categories, nesting:)
401
+ segments = Helpers::Segments.derive_segments(gem_name)
402
+ tier = category == :default ? 5 : (categories.dig(category, :tier) || 5)
403
+
404
+ if nesting
405
+ const_path = Helpers::Segments.derive_const_path(gem_name)
406
+ require_path = Helpers::Segments.derive_require_path(gem_name)
315
407
  else
316
- Legion::Logging.warn 'You must have auto_install_missing_lex set to true to auto install missing extensions'
408
+ flat_name = gem_name.delete_prefix('lex-').tr('-', '_')
409
+ const_path = "Legion::Extensions::#{flat_name.split('_').map(&:capitalize).join}"
410
+ require_path = "legion/extensions/#{flat_name}"
317
411
  end
412
+
413
+ { gem_name: gem_name, category: category, tier: tier,
414
+ segments: segments, const_path: const_path, require_path: require_path }
415
+ end
416
+
417
+ def default_category_registry
418
+ {
419
+ core: { type: :list, tier: 1 },
420
+ ai: { type: :list, tier: 2 },
421
+ gaia: { type: :list, tier: 3 },
422
+ agentic: { type: :prefix, tier: 4 }
423
+ }
424
+ end
425
+
426
+ def agentic_blocked?(gem_name, config)
427
+ Array(config[:blocked]).any? { |pat| File.fnmatch(pat, gem_name) }
428
+ end
429
+
430
+ def agentic_allowed?(gem_name, config)
431
+ return true if config[:allowed].nil?
432
+
433
+ Array(config[:allowed]).any? { |pat| File.fnmatch(pat, gem_name) }
318
434
  end
319
435
  end
320
436
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.53'
4
+ VERSION = '1.4.58'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.53
4
+ version: 1.4.58
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -514,6 +514,7 @@ files:
514
514
  - lib/legion/extensions/helpers/data.rb
515
515
  - lib/legion/extensions/helpers/lex.rb
516
516
  - lib/legion/extensions/helpers/logger.rb
517
+ - lib/legion/extensions/helpers/segments.rb
517
518
  - lib/legion/extensions/helpers/task.rb
518
519
  - lib/legion/extensions/helpers/transport.rb
519
520
  - lib/legion/extensions/hooks/base.rb