legionio 1.6.7 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e025887526f8b9e258eb168a14a83d5c34e60668eadbe9e1ccd5c995e27d725c
4
- data.tar.gz: a85e52dc5ec39293461e7df402f2ea8f1a410d293c28141fc935cfb6023b71df
3
+ metadata.gz: e67d5abaf8c274cad673af9996e29577949a9e945c66ee099b1c8e23eeff4a3f
4
+ data.tar.gz: 4c3113323f086005de660cd84128036d59919ee2f2482ce84a855334bab007c4
5
5
  SHA512:
6
- metadata.gz: 6ecefca3ab028b370f8e590cf4fa81290e3a9e106f53a5655d11928f40ec99a0d48451ab94c9516086fd81bd5c42d4523eae4d975b9df667d8db7010656884c6
7
- data.tar.gz: c5f4aa31b5431e6fb012991fe0048ac75c0d25d95e6402983590d7954491ad53e78b18d2911cb7969427cd60e63a47975d7ca89ff7642857cd857328dec559e3
6
+ metadata.gz: d221a3d63b90d155b24fabd47594aef550467d6136a96092208a7f710455f87f416ae4110815e323c7bfcb83aec13ec4c41dff7b2db14b0846dafbe55ca9be66
7
+ data.tar.gz: b495e7daddcbb0e5cbda7c93f152ca285e044972ee8b74c9d432f91e97c4536d1087539f7b8daf784910cb359935453b6aa1f9e972c8d81b688d55c8986e2f34
data/.rubocop.yml CHANGED
@@ -32,6 +32,7 @@ Metrics/BlockLength:
32
32
  Max: 40
33
33
  Exclude:
34
34
  - 'spec/**/*'
35
+ - 'integration/**/*'
35
36
  - 'legionio.gemspec'
36
37
  - 'lib/legion/cli/chat_command.rb'
37
38
  - 'lib/legion/cli/plan_command.rb'
data/CHANGELOG.md CHANGED
@@ -1,27 +1,41 @@
1
1
  # Legion Changelog
2
2
 
3
- ## [1.6.7] - 2026-03-26
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - End-to-end integration test for TBI Phase 5 self-generating functions loop (9 examples)
7
+ - Test dependencies: lex-codegen, lex-eval added to Gemfile for integration testing
8
+ - Specs for `legion codegen` CLI subcommand (8 subcommands, 22 examples)
9
+ - Specs for `/api/codegen/*` API routes (8 routes, 20 examples)
10
+ - Specs for `setup_generated_functions` boot loading in Service (4 examples)
4
11
 
5
12
  ### Fixed
6
- - `setup_generated_functions` now runs only when `extensions: true` (inside the extensions gate) preventing unexpected boot side-effects in CLI flows that disable extensions
7
- - Consumer tag entropy upgraded from `SecureRandom.hex(4)` (32-bit) to `SecureRandom.uuid` (122-bit) in both `prepare` and `subscribe` paths of subscription actor, eliminating the theoretical RabbitMQ `NOT_ALLOWED` tag collision
13
+ - Guard `Legion::Transport::Messages::Dynamic` stub definition in integration spec with `unless defined?` to prevent redefinition conflicts when real implementations are present
14
+ - Wrap `lex-codegen` and `lex-eval` requires in `LoadError` rescue guards in integration spec; sets `LEGION_CODEGEN_EXTENSION_AVAILABLE` / `LEGION_EVAL_EXTENSION_AVAILABLE` flags and skips entire example group via `before(:all)` when extensions are unavailable
15
+ - Move `Legion::LLM.chat` stub to `RSpec.configure before(:each)` block so it always intercepts regardless of whether the real `legion-llm` gem is loaded, preventing external LLM calls in integration tests
16
+ - Fix `service_setup_apollo_spec` "starts Apollo::Local" example: stub `Legion::Apollo.start` to prevent internal double-call of `Apollo::Local.start`
8
17
 
9
- ## [1.6.6] - 2026-03-26
18
+ ## [1.6.9] - 2026-03-26
19
+
20
+ ### Added
21
+ - `Helpers::Secret` mixin with `SecretAccessor` for per-user and per-lex Vault KV v2 secret access
22
+ - Identity resolution chain: Kerberos principal -> Entra UPN -> explicit user -> ENV['USER']
23
+ - `secret[:name]` / `secret[:name] = { ... }` / `secret.write` / `secret.exist?` / `secret.delete`
24
+ - `shared: true` option for extension-scoped (non-user) secrets
25
+
26
+ ## [1.6.8] - 2026-03-26
10
27
 
11
28
  ### Added
12
29
  - `legionio bootstrap SOURCE` command: combines `config import`, `config scaffold`, and `setup agentic` into one command
13
30
  - Pre-flight checks for klist (Kerberos ticket), brew availability, and legionio binary
14
- - `--skip-packs` flag to skip gem pack installation (config-only mode)
15
- - `--start` flag to start redis + legionio via brew services after bootstrap
16
- - `--force` flag to overwrite existing config files during bootstrap
17
- - `--json` flag for machine-readable bootstrap output
18
- - `shell_capture` helper extracted to make shell invocations stubbable in specs
19
- - 62 specs covering preflight checks, pack extraction, config fetch/write delegation, pack install, summary output, all flags, and error handling
31
+ - `--skip-packs`, `--start`, `--force`, `--json` flags for bootstrap command
32
+ - Self-awareness system prompt enrichment: `Context.to_system_prompt` appends live metacognition self-narrative from `lex-agentic-self` when loaded; guarded with `defined?()` and `rescue StandardError`
20
33
 
21
- ## [1.6.5] - 2026-03-26
34
+ ## [1.6.7] - 2026-03-26
22
35
 
23
- ### Added
24
- - `Context.to_system_prompt` appends a live self-awareness section from `lex-agentic-self` Metacognition when the gem is loaded; logic extracted into `self_awareness_hint` helper to keep `to_system_prompt` within Metrics/CyclomaticComplexity limits; guarded with `defined?()` and `rescue StandardError`
36
+ ### Fixed
37
+ - `setup_generated_functions` now runs only when `extensions: true` (inside the extensions gate) preventing unexpected boot side-effects in CLI flows that disable extensions
38
+ - Consumer tag entropy upgraded from `SecureRandom.hex(4)` (32-bit) to `SecureRandom.uuid` (122-bit) in both `prepare` and `subscribe` paths of subscription actor, eliminating the theoretical RabbitMQ `NOT_ALLOWED` tag collision
25
39
 
26
40
  ## [1.6.4] - 2026-03-26
27
41
 
data/Gemfile CHANGED
@@ -23,6 +23,8 @@ gem 'mysql2'
23
23
 
24
24
  group :test do
25
25
  gem 'graphql'
26
+ gem 'lex-codegen'
27
+ gem 'lex-eval'
26
28
  gem 'rack-test'
27
29
  gem 'rake'
28
30
  gem 'rspec'
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+
7
+ # Load core gems
8
+ require 'legion/json'
9
+ require 'legion/logging'
10
+ require 'legion/settings'
11
+
12
+ # Stub modules that may not be available in isolation
13
+ unless defined?(Legion::Transport::Messages::Dynamic)
14
+ module Legion
15
+ module Transport
16
+ module Messages
17
+ class Dynamic
18
+ attr_reader :function, :data
19
+
20
+ def initialize(function:, data:, **)
21
+ @function = function
22
+ @data = data
23
+ end
24
+
25
+ def publish
26
+ Legion::Transport::Local.publish('codegen', @function, Legion::JSON.dump(@data))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ # Ensure Legion::LLM module exists so it can be stubbed, but don't overwrite a real implementation.
35
+ unless defined?(Legion::LLM)
36
+ module Legion
37
+ module LLM
38
+ end
39
+ end
40
+ end
41
+
42
+ # Load transport Local for InProcess mode
43
+ require 'legion/transport/local'
44
+
45
+ # Load codegen extension
46
+ begin
47
+ require 'legion/extensions/codegen'
48
+ LEGION_CODEGEN_EXTENSION_AVAILABLE = true
49
+ rescue LoadError => e
50
+ LEGION_CODEGEN_EXTENSION_AVAILABLE = false
51
+ warn "lex-codegen / legion codegen extension not available; skipping dependent behavior: #{e.message}"
52
+ end
53
+
54
+ # Load eval extension (only code_review runner + security evaluator)
55
+ begin
56
+ require 'legion/extensions/eval'
57
+ LEGION_EVAL_EXTENSION_AVAILABLE = true
58
+ rescue LoadError => e
59
+ LEGION_EVAL_EXTENSION_AVAILABLE = false
60
+ warn "lex-eval / legion eval extension not available; skipping dependent behavior: #{e.message}"
61
+ end
62
+
63
+ # Stub MCP Server if not available
64
+ unless defined?(Legion::MCP::Server)
65
+ module Legion
66
+ module MCP
67
+ module Server
68
+ @tool_registry = []
69
+ @tool_registry_lock = Mutex.new
70
+
71
+ class << self
72
+ attr_reader :tool_registry
73
+
74
+ def register_tool(tool_class)
75
+ @tool_registry_lock.synchronize do
76
+ return if tool_registry.any? { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_class.tool_name }
77
+
78
+ tool_registry << tool_class
79
+ end
80
+ end
81
+
82
+ def unregister_tool(tool_name)
83
+ @tool_registry_lock.synchronize do
84
+ tool_registry.reject! { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_name }
85
+ end
86
+ end
87
+
88
+ def reset_caches!; end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ LLM_STUB_CODE = <<~RUBY
96
+ # frozen_string_literal: true
97
+
98
+ module Legion
99
+ module Generated
100
+ module GreetUser
101
+ extend self
102
+
103
+ def greet(name:)
104
+ { success: true, greeting: "Hello, \#{name}!" }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ RUBY
110
+
111
+ RSpec.configure do |config|
112
+ config.disable_monkey_patching!
113
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
114
+
115
+ config.before(:each) do
116
+ allow(Legion::LLM).to receive(:chat) do |messages:, _caller: nil, **_kwargs|
117
+ messages.last[:content]
118
+ Struct.new(:content).new(LLM_STUB_CODE)
119
+ end
120
+ end
121
+ end
122
+
123
+ RSpec.describe 'Self-Generating Functions End-to-End' do
124
+ # Skip this entire example group if the required extensions are not available.
125
+ before(:all) do
126
+ extensions_unavailable =
127
+ (defined?(LEGION_CODEGEN_EXTENSION_AVAILABLE) && !LEGION_CODEGEN_EXTENSION_AVAILABLE) ||
128
+ (defined?(LEGION_EVAL_EXTENSION_AVAILABLE) && !LEGION_EVAL_EXTENSION_AVAILABLE)
129
+
130
+ skip('Legion Codegen/Eval extensions are not available; skipping self-generate integration specs.') if extensions_unavailable
131
+ end
132
+
133
+ let(:output_dir) { Dir.mktmpdir('legion_e2e_codegen') }
134
+
135
+ before do
136
+ # Reset Local transport
137
+ Legion::Transport::Local.reset! if Legion::Transport::Local.respond_to?(:reset!)
138
+
139
+ # Reset GeneratedRegistry (only if Codegen extension is loaded)
140
+ Legion::Extensions::Codegen::Helpers::GeneratedRegistry.reset! if defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
141
+
142
+ # Configure settings for test
143
+ allow(Legion::Settings).to receive(:dig).and_return(nil)
144
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :enabled).and_return(true)
145
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :cooldown_seconds).and_return(0)
146
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :max_gaps_per_cycle).and_return(5)
147
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :runner_method, :output_dir).and_return(output_dir)
148
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :validation).and_return(
149
+ { syntax_check: true, run_specs: false, llm_review: false, max_retries: 2 }
150
+ )
151
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :enabled).and_return(false)
152
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :min_agents).and_return(2)
153
+ allow(Legion::Settings).to receive(:dig).with(:node, :name).and_return('test-node')
154
+ allow(Legion::Settings).to receive(:[]).and_return(nil)
155
+ end
156
+
157
+ after do
158
+ FileUtils.rm_rf(output_dir)
159
+ end
160
+
161
+ describe 'gap detection -> generation -> validation -> registration' do
162
+ let(:synthetic_gap) do
163
+ {
164
+ gap_id: 'gap_e2e_001',
165
+ gap_type: 'unmatched_intent',
166
+ intent: 'greet user',
167
+ occurrence_count: 3,
168
+ priority: 0.7,
169
+ metadata: {}
170
+ }
171
+ end
172
+
173
+ it 'generates code from a gap and passes validation' do
174
+ # Phase 1: GapSubscriber receives a gap and generates code
175
+ subscriber = Object.new
176
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
177
+
178
+ generation = subscriber.action(synthetic_gap)
179
+
180
+ expect(generation[:success]).to be true
181
+ expect(generation[:generation_id]).to start_with('gen_')
182
+ expect(generation[:tier]).to eq(:simple)
183
+ expect(generation[:code]).to include('module Legion')
184
+ expect(generation[:file_path]).to start_with(output_dir)
185
+ expect(File.exist?(generation[:file_path])).to be true
186
+ end
187
+
188
+ it 'validates generated code through the review pipeline' do
189
+ # Phase 1: Generate
190
+ subscriber = Object.new
191
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
192
+ generation = subscriber.action(synthetic_gap)
193
+ expect(generation[:success]).to be true
194
+
195
+ # Phase 2: Review (simulating what CodeReviewSubscriber does)
196
+ review = Legion::Extensions::Eval::Runners::CodeReview.review_generated(
197
+ code: generation[:code],
198
+ spec_code: generation[:spec_code],
199
+ context: { gap_type: 'unmatched_intent', intent: 'greet user' }
200
+ )
201
+
202
+ expect(review[:passed]).to be true
203
+ expect(review[:verdict]).to eq(:approve)
204
+ expect(review[:confidence]).to be > 0.0
205
+ expect(review[:stages][:syntax][:passed]).to be true
206
+ expect(review[:stages][:security][:passed]).to be true
207
+ end
208
+
209
+ it 'registers approved code via ReviewHandler' do
210
+ # Phase 1: Generate
211
+ subscriber = Object.new
212
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
213
+ generation = subscriber.action(synthetic_gap)
214
+ expect(generation[:success]).to be true
215
+
216
+ # Phase 2: Persist to registry
217
+ registry_record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
218
+ generation: {
219
+ id: generation[:generation_id],
220
+ gap_id: generation[:gap_id],
221
+ gap_type: generation[:gap_type],
222
+ tier: generation[:tier],
223
+ name: 'greet_user',
224
+ file_path: generation[:file_path],
225
+ spec_path: generation[:spec_path]
226
+ }
227
+ )
228
+ expect(registry_record[:status]).to eq('pending')
229
+
230
+ # Phase 3: Review
231
+ review_result = {
232
+ generation_id: generation[:generation_id],
233
+ verdict: :approve,
234
+ confidence: 0.95,
235
+ issues: [],
236
+ scores: {}
237
+ }
238
+
239
+ # Phase 4: ReviewHandler processes the verdict
240
+ result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(review: review_result)
241
+
242
+ expect(result[:success]).to be true
243
+ expect(result[:action]).to eq(:approved)
244
+
245
+ # Verify registry updated
246
+ record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id])
247
+ expect(record[:status]).to eq('approved')
248
+ end
249
+
250
+ it 'parks rejected code' do
251
+ subscriber = Object.new
252
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
253
+ generation = subscriber.action(synthetic_gap)
254
+ expect(generation[:success]).to be true
255
+
256
+ Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
257
+ generation: {
258
+ id: generation[:generation_id],
259
+ gap_id: generation[:gap_id],
260
+ gap_type: generation[:gap_type],
261
+ tier: generation[:tier],
262
+ name: 'greet_user',
263
+ file_path: generation[:file_path],
264
+ spec_path: generation[:spec_path]
265
+ }
266
+ )
267
+
268
+ result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
269
+ review: { generation_id: generation[:generation_id], verdict: :reject, confidence: 0.1, issues: ['unsafe code'] }
270
+ )
271
+
272
+ expect(result[:success]).to be true
273
+ expect(result[:action]).to eq(:parked)
274
+
275
+ record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id])
276
+ expect(record[:status]).to eq('parked')
277
+ end
278
+
279
+ it 'retries on revise verdict up to max_retries then parks' do
280
+ subscriber = Object.new
281
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
282
+ generation = subscriber.action(synthetic_gap)
283
+ expect(generation[:success]).to be true
284
+
285
+ Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
286
+ generation: {
287
+ id: generation[:generation_id],
288
+ gap_id: generation[:gap_id],
289
+ gap_type: generation[:gap_type],
290
+ tier: generation[:tier],
291
+ name: 'greet_user',
292
+ file_path: generation[:file_path],
293
+ spec_path: generation[:spec_path],
294
+ attempt_count: 2
295
+ }
296
+ )
297
+
298
+ result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
299
+ review: { generation_id: generation[:generation_id], verdict: :revise, confidence: 0.4, issues: ['needs improvement'] }
300
+ )
301
+
302
+ expect(result[:success]).to be true
303
+ expect(result[:action]).to eq(:parked)
304
+ end
305
+
306
+ it 'exercises the full loop: generate -> validate -> register -> boot load' do
307
+ # Step 1: Generate
308
+ subscriber = Object.new
309
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
310
+ generation = subscriber.action(synthetic_gap)
311
+ expect(generation[:success]).to be true
312
+
313
+ # Step 2: Validate
314
+ review = Legion::Extensions::Eval::Runners::CodeReview.review_generated(
315
+ code: generation[:code], spec_code: generation[:spec_code], context: {}
316
+ )
317
+ expect(review[:verdict]).to eq(:approve)
318
+
319
+ # Step 3: Persist + Approve
320
+ Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
321
+ generation: {
322
+ id: generation[:generation_id],
323
+ gap_id: generation[:gap_id],
324
+ gap_type: generation[:gap_type],
325
+ tier: generation[:tier],
326
+ name: 'greet_user',
327
+ file_path: generation[:file_path],
328
+ spec_path: generation[:spec_path]
329
+ }
330
+ )
331
+
332
+ Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
333
+ review: { generation_id: generation[:generation_id], verdict: :approve, confidence: 0.95, issues: [] }
334
+ )
335
+
336
+ # Step 4: Boot load (simulates service restart)
337
+ loaded = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.load_on_boot
338
+ expect(loaded).to eq(1)
339
+
340
+ # Step 5: Verify the generated module is actually loaded
341
+ expect(defined?(Legion::Generated::GreetUser)).to be_truthy
342
+ result = Legion::Generated::GreetUser.greet(name: 'World')
343
+ expect(result[:success]).to be true
344
+ expect(result[:greeting]).to eq('Hello, World!')
345
+ end
346
+ end
347
+
348
+ describe 'tier classification' do
349
+ it 'classifies low occurrence gaps as simple' do
350
+ tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 5 })
351
+ expect(tier).to eq(:simple)
352
+ end
353
+
354
+ it 'classifies high occurrence gaps as complex' do
355
+ allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :tier, :simple_max_occurrences).and_return(10)
356
+ tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 15 })
357
+ expect(tier).to eq(:complex)
358
+ end
359
+ end
360
+
361
+ describe 'ReviewSubscriber actor' do
362
+ it 'routes verdict through ReviewHandler' do
363
+ subscriber = Object.new
364
+ subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber)
365
+ generation = subscriber.action(
366
+ gap_id: 'gap_rs_001', gap_type: 'unmatched_intent', intent: 'greet user',
367
+ occurrence_count: 3, priority: 0.7
368
+ )
369
+ expect(generation[:success]).to be true
370
+
371
+ Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist(
372
+ generation: {
373
+ id: generation[:generation_id],
374
+ gap_id: generation[:gap_id],
375
+ gap_type: generation[:gap_type],
376
+ tier: generation[:tier],
377
+ name: 'greet_user',
378
+ file_path: generation[:file_path],
379
+ spec_path: generation[:spec_path]
380
+ }
381
+ )
382
+
383
+ review_sub = Object.new
384
+ review_sub.extend(Legion::Extensions::Codegen::Actor::ReviewSubscriber)
385
+
386
+ result = review_sub.action(
387
+ generation_id: generation[:generation_id],
388
+ verdict: 'approve',
389
+ confidence: 0.9,
390
+ issues: [],
391
+ scores: {}
392
+ )
393
+
394
+ expect(result[:success]).to be true
395
+ expect(result[:action]).to eq(:approved)
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'rbconfig'
7
+ require 'thor'
8
+ require 'legion/cli/output'
9
+
10
+ module Legion
11
+ module CLI
12
+ class Bootstrap < Thor
13
+ namespace 'bootstrap'
14
+
15
+ def self.exit_on_failure?
16
+ true
17
+ end
18
+
19
+ class_option :json, type: :boolean, default: false, desc: 'Machine-readable output'
20
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
21
+ class_option :skip_packs, type: :boolean, default: false, desc: 'Skip gem pack installation (config only)'
22
+ class_option :start, type: :boolean, default: false, desc: 'Start redis + legionio via brew services after bootstrap'
23
+ class_option :force, type: :boolean, default: false, desc: 'Overwrite existing config files'
24
+
25
+ desc 'SOURCE', 'Bootstrap Legion from a URL or local config file (fetch config, scaffold, install packs)'
26
+ long_desc <<~DESC
27
+ Combines three manual steps into one:
28
+
29
+ legionio config import SOURCE (fetch + write config)
30
+ legionio config scaffold (fill gaps with env-detected defaults)
31
+ legionio setup agentic (install cognitive gem packs)
32
+
33
+ SOURCE may be an HTTPS URL or a local file path to a bootstrap JSON file.
34
+ The JSON may include a "packs" array (e.g. ["agentic"]) which controls which
35
+ gem packs are installed. That key is removed before the config is written.
36
+
37
+ Options:
38
+ --skip-packs Skip gem pack installation entirely
39
+ --start After bootstrap, run: brew services start redis && brew services start legionio
40
+ --force Overwrite existing config files
41
+ --json Machine-readable JSON output
42
+ DESC
43
+ def execute(source)
44
+ require_relative 'config_import'
45
+ require_relative 'config_scaffold'
46
+ require_relative 'setup_command'
47
+
48
+ out = formatter
49
+ results = {}
50
+ warns = []
51
+
52
+ # 1. Pre-flight checks
53
+ print_step(out, 'Pre-flight checks')
54
+ results[:preflight] = run_preflight_checks(out, warns)
55
+
56
+ # 2. Fetch + parse config
57
+ print_step(out, "Fetching config from #{source}")
58
+ body = ConfigImport.fetch_source(source)
59
+ config = ConfigImport.parse_payload(body)
60
+
61
+ # 3. Extract packs before writing (bootstrap-only directive)
62
+ pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?)
63
+ results[:packs_requested] = pack_names
64
+
65
+ # 4. Write config
66
+ path = ConfigImport.write_config(config, force: options[:force])
67
+ results[:config_written] = path
68
+ out.success("Config written to #{path}") unless options[:json]
69
+
70
+ # 5. Scaffold missing subsystem files
71
+ results[:scaffold] = run_scaffold(out)
72
+
73
+ # 6. Install packs (unless --skip-packs)
74
+ results[:packs_installed] = install_packs_step(pack_names, out)
75
+
76
+ # 7. Post-bootstrap summary
77
+ summary = build_summary(config, results, warns)
78
+ results[:summary] = summary
79
+ print_summary(out, summary)
80
+
81
+ # 8. Optional --start
82
+ if options[:start]
83
+ print_step(out, 'Starting services')
84
+ results[:services_started] = start_services(out)
85
+ end
86
+
87
+ out.json(results) if options[:json]
88
+ rescue CLI::Error => e
89
+ formatter.error(e.message)
90
+ raise SystemExit, 1
91
+ end
92
+
93
+ default_task :execute
94
+
95
+ no_commands do # rubocop:disable Metrics/BlockLength
96
+ def formatter
97
+ @formatter ||= Output::Formatter.new(
98
+ json: options[:json],
99
+ color: !options[:no_color]
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ def print_step(out, message)
106
+ return if options[:json]
107
+
108
+ out.spacer
109
+ out.header(message)
110
+ end
111
+
112
+ # Wraps backtick execution, returning [output, success_bool].
113
+ # Extracted as a method so specs can stub it cleanly.
114
+ def shell_capture(cmd)
115
+ output = `#{cmd} 2>&1`
116
+ [output, $CHILD_STATUS.success?]
117
+ end
118
+
119
+ # -----------------------------------------------------------------------
120
+ # Pre-flight checks
121
+ # -----------------------------------------------------------------------
122
+
123
+ def run_preflight_checks(out, warns)
124
+ {
125
+ klist: check_klist(out, warns),
126
+ brew: check_brew(out, warns),
127
+ legionio: check_legionio_binary(out, warns)
128
+ }
129
+ end
130
+
131
+ def check_klist(out, warns)
132
+ output, success = shell_capture('klist')
133
+ if success && output.match?(/principal|Credentials/i)
134
+ out.success('Kerberos ticket valid') unless options[:json]
135
+ { status: :ok }
136
+ else
137
+ msg = 'No valid Kerberos ticket found. Run `kinit` before bootstrapping.'
138
+ warns << msg
139
+ out.warn(msg) unless options[:json]
140
+ { status: :warn, message: msg }
141
+ end
142
+ rescue StandardError => e
143
+ msg = "klist check failed: #{e.message}"
144
+ warns << msg
145
+ out.warn(msg) unless options[:json]
146
+ { status: :warn, message: msg }
147
+ end
148
+
149
+ def check_brew(out, warns)
150
+ _, success = shell_capture('brew --version')
151
+ if success
152
+ out.success('Homebrew available') unless options[:json]
153
+ { status: :ok }
154
+ else
155
+ msg = 'Homebrew not found. Install from https://brew.sh'
156
+ warns << msg
157
+ out.warn(msg) unless options[:json]
158
+ { status: :warn, message: msg }
159
+ end
160
+ rescue StandardError => e
161
+ msg = "brew check failed: #{e.message}"
162
+ warns << msg
163
+ out.warn(msg) unless options[:json]
164
+ { status: :warn, message: msg }
165
+ end
166
+
167
+ def check_legionio_binary(out, warns)
168
+ _, success = shell_capture('legionio version')
169
+ if success
170
+ out.success('legionio binary works') unless options[:json]
171
+ { status: :ok }
172
+ else
173
+ msg = 'legionio binary not responding. Try reinstalling: brew reinstall legionio'
174
+ warns << msg
175
+ out.warn(msg) unless options[:json]
176
+ { status: :warn, message: msg }
177
+ end
178
+ rescue StandardError => e
179
+ msg = "legionio binary check failed: #{e.message}"
180
+ warns << msg
181
+ out.warn(msg) unless options[:json]
182
+ { status: :warn, message: msg }
183
+ end
184
+
185
+ def run_scaffold(out)
186
+ print_step(out, 'Scaffolding missing subsystem files')
187
+ silent_out = Output::Formatter.new(json: false, color: false)
188
+ scaffold_opts = build_scaffold_opts
189
+ scaffold_opts[:json] = false if options[:json]
190
+ ConfigScaffold.run(options[:json] ? silent_out : out, scaffold_opts)
191
+ :done
192
+ end
193
+
194
+ def install_packs_step(pack_names, out)
195
+ if options[:skip_packs]
196
+ out.warn('Skipping pack installation (--skip-packs)') unless options[:json]
197
+ []
198
+ else
199
+ print_step(out, "Installing packs: #{pack_names.join(', ')}") unless pack_names.empty?
200
+ install_packs(pack_names, out)
201
+ end
202
+ end
203
+
204
+ # -----------------------------------------------------------------------
205
+ # Scaffold options
206
+ # -----------------------------------------------------------------------
207
+
208
+ def build_scaffold_opts
209
+ {
210
+ force: options[:force],
211
+ json: options[:json],
212
+ only: options[:only],
213
+ full: options[:full],
214
+ dir: options[:dir]
215
+ }
216
+ end
217
+
218
+ # -----------------------------------------------------------------------
219
+ # Pack installation
220
+ # -----------------------------------------------------------------------
221
+
222
+ def install_packs(pack_names, out)
223
+ return [] if pack_names.empty?
224
+
225
+ gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem')
226
+ results = []
227
+
228
+ pack_names.each do |pack_name|
229
+ pack_sym = pack_name.to_sym
230
+ pack = Setup::PACKS[pack_sym]
231
+ unless pack
232
+ out.warn("Unknown pack: #{pack_name} (valid: #{Setup::PACKS.keys.join(', ')})") unless options[:json]
233
+ next
234
+ end
235
+
236
+ out.header("Installing pack: #{pack_name}") unless options[:json]
237
+ gem_results = install_pack_gems(pack[:gems], gem_bin, out)
238
+ Gem::Specification.reset
239
+ results << { pack: pack_name, results: gem_results }
240
+ end
241
+
242
+ results
243
+ end
244
+
245
+ def install_pack_gems(gem_names, gem_bin, out)
246
+ already_installed = []
247
+ to_install = []
248
+
249
+ gem_names.each do |name|
250
+ Gem::Specification.find_by_name(name)
251
+ already_installed << name
252
+ rescue Gem::MissingSpecError
253
+ to_install << name
254
+ end
255
+
256
+ gem_results = to_install.map { |g| install_single_gem(g, gem_bin, out) }
257
+
258
+ already_installed.each do |g|
259
+ out.success(" #{g} already installed") unless options[:json]
260
+ end
261
+
262
+ gem_results
263
+ end
264
+
265
+ def install_single_gem(name, gem_bin, out)
266
+ puts " Installing #{name}..." unless options[:json]
267
+ output, success = shell_capture("#{gem_bin} install #{name} --no-document")
268
+ if success
269
+ out.success(" #{name} installed") unless options[:json]
270
+ { name: name, status: 'installed' }
271
+ else
272
+ out.error(" #{name} failed") unless options[:json]
273
+ { name: name, status: 'failed', error: output.strip.lines.last&.strip }
274
+ end
275
+ end
276
+
277
+ # -----------------------------------------------------------------------
278
+ # Summary
279
+ # -----------------------------------------------------------------------
280
+
281
+ def build_summary(config, results, warns)
282
+ settings_dir = ConfigImport::SETTINGS_DIR
283
+ subsystem_files = ConfigScaffold::SUBSYSTEMS.to_h do |s|
284
+ path = File.join(settings_dir, "#{s}.json")
285
+ [s, File.exist?(path)]
286
+ end
287
+
288
+ {
289
+ config_sections: config.keys.map(&:to_s),
290
+ packs_requested: results[:packs_requested] || [],
291
+ packs_installed: results[:packs_installed] || [],
292
+ subsystem_files: subsystem_files,
293
+ warnings: warns,
294
+ preflight: results[:preflight] || {}
295
+ }
296
+ end
297
+
298
+ def print_summary(out, summary)
299
+ return if options[:json]
300
+
301
+ out.spacer
302
+ out.header('Bootstrap Summary')
303
+ out.spacer
304
+
305
+ print_config_sections(summary)
306
+ print_subsystem_files(summary)
307
+ print_packs_summary(out, summary)
308
+ print_warnings_section(out, summary)
309
+ print_next_steps(out)
310
+ end
311
+
312
+ def print_config_sections(summary)
313
+ puts " Config sections: #{summary[:config_sections].join(', ')}" if summary[:config_sections].any?
314
+ end
315
+
316
+ def print_subsystem_files(summary)
317
+ present = summary[:subsystem_files].select { |_, v| v }.keys
318
+ absent = summary[:subsystem_files].reject { |_, v| v }.keys
319
+ puts " Subsystem files present: #{present.join(', ')}" if present.any?
320
+ puts " Subsystem files missing: #{absent.join(', ')}" if absent.any?
321
+ end
322
+
323
+ def print_packs_summary(out, summary)
324
+ summary[:packs_installed].each do |pack_result|
325
+ successes = (pack_result[:results] || []).count { |r| r[:status] == 'installed' }
326
+ failures = (pack_result[:results] || []).count { |r| r[:status] == 'failed' }
327
+ if failures.zero?
328
+ out.success("Pack #{pack_result[:pack]}: #{successes} gem(s) installed")
329
+ else
330
+ out.warn("Pack #{pack_result[:pack]}: #{successes} installed, #{failures} failed")
331
+ end
332
+ end
333
+ out.warn('Pack installation skipped') if options[:skip_packs]
334
+ end
335
+
336
+ def print_warnings_section(out, summary)
337
+ return unless summary[:warnings].any?
338
+
339
+ out.spacer
340
+ out.header('Attention')
341
+ summary[:warnings].each { |w| out.warn(w) }
342
+ end
343
+
344
+ def print_next_steps(out)
345
+ return if options[:start]
346
+
347
+ out.spacer
348
+ puts ' Next steps:'
349
+ puts ' brew services start redis && brew services start legionio'
350
+ puts ' legion'
351
+ end
352
+
353
+ # -----------------------------------------------------------------------
354
+ # Service startup (--start)
355
+ # -----------------------------------------------------------------------
356
+
357
+ def start_services(out)
358
+ redis_ok = run_brew_service('redis', out)
359
+ legion_ok = run_brew_service('legionio', out)
360
+ poll_daemon_ready(out) if redis_ok && legion_ok
361
+ { redis: redis_ok, legionio: legion_ok }
362
+ end
363
+
364
+ def run_brew_service(service, out)
365
+ output, success = shell_capture("brew services start #{service}")
366
+ if success
367
+ out.success("#{service} started") unless options[:json]
368
+ true
369
+ else
370
+ out.warn("#{service} failed to start: #{output.strip.lines.last&.strip}") unless options[:json]
371
+ false
372
+ end
373
+ rescue StandardError => e
374
+ out.warn("brew services start #{service} raised: #{e.message}") unless options[:json]
375
+ false
376
+ end
377
+
378
+ def poll_daemon_ready(out, port: 4567, timeout: 30)
379
+ require 'net/http'
380
+ deadline = ::Time.now + timeout
381
+ until ::Time.now > deadline
382
+ begin
383
+ resp = Net::HTTP.get_response(URI("http://localhost:#{port}/api/ready"))
384
+ if resp.is_a?(Net::HTTPSuccess)
385
+ out.success("Daemon ready on port #{port}") unless options[:json]
386
+ return true
387
+ end
388
+ rescue StandardError
389
+ # not ready yet — keep polling
390
+ end
391
+ sleep 1
392
+ end
393
+ out.warn("Daemon did not become ready within #{timeout}s") unless options[:json]
394
+ false
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
@@ -61,6 +61,7 @@ module Legion
61
61
  end
62
62
 
63
63
  parts << cognitive_awareness(directory)
64
+ parts << self_awareness_hint
64
65
 
65
66
  extra_dirs.each do |dir|
66
67
  expanded = File.expand_path(dir)
@@ -181,6 +182,16 @@ module Legion
181
182
  end
182
183
  nil
183
184
  end
185
+
186
+ def self.self_awareness_hint
187
+ return nil unless defined?(Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition)
188
+
189
+ result = Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition.self_narrative
190
+ narrative = result[:prose] if result.is_a?(Hash) && result[:prose]
191
+ narrative ? "\nCurrent self-awareness:\n#{narrative}" : nil
192
+ rescue StandardError
193
+ nil
194
+ end
184
195
  end
185
196
  end
186
197
  end
data/lib/legion/cli.rb CHANGED
@@ -65,6 +65,7 @@ module Legion
65
65
  autoload :Features, 'legion/cli/features_command'
66
66
  autoload :Debug, 'legion/cli/debug_command'
67
67
  autoload :CodegenCommand, 'legion/cli/codegen_command'
68
+ autoload :Bootstrap, 'legion/cli/bootstrap_command'
68
69
 
69
70
  module Groups
70
71
  autoload :Ai, 'legion/cli/groups/ai_group'
@@ -236,6 +237,9 @@ module Legion
236
237
  desc 'setup SUBCOMMAND', 'Install feature packs and configure IDE integrations'
237
238
  subcommand 'setup', Legion::CLI::Setup
238
239
 
240
+ desc 'bootstrap SOURCE', 'One-command setup: fetch config, scaffold, and install packs'
241
+ subcommand 'bootstrap', Legion::CLI::Bootstrap
242
+
239
243
  desc 'update', 'Update Legion gems to latest versions'
240
244
  subcommand 'update', Legion::CLI::Update
241
245
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/json/helper'
4
+ require_relative 'secret'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -9,6 +10,7 @@ module Legion
9
10
  include Legion::Extensions::Helpers::Core
10
11
  include Legion::Extensions::Helpers::Logger
11
12
  include Legion::JSON::Helper
13
+ include Legion::Extensions::Helpers::Secret
12
14
 
13
15
  module ClassMethods
14
16
  def expose_as_mcp_tool(value = :_unset)
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Helpers
6
+ class SecretAccessor
7
+ def initialize(lex_name:)
8
+ @lex_name = lex_name
9
+ @warned = false
10
+ end
11
+
12
+ def [](name, shared: false, user: nil)
13
+ return nil unless crypt_available?
14
+
15
+ Legion::Crypt.get(resolve_path(name, shared: shared, user: user))
16
+ rescue StandardError => e
17
+ log_warn("secret read failed for #{name}: #{e.message}")
18
+ nil
19
+ end
20
+
21
+ def []=(name, value)
22
+ return unless crypt_available?
23
+
24
+ Legion::Crypt.write(resolve_path(name, shared: false, user: nil), **value)
25
+ rescue StandardError => e
26
+ log_warn("secret write failed for #{name}: #{e.message}")
27
+ end
28
+
29
+ def write(name, shared: false, user: nil, **data)
30
+ return false unless crypt_available?
31
+
32
+ Legion::Crypt.write(resolve_path(name, shared: shared, user: user), **data)
33
+ true
34
+ rescue StandardError => e
35
+ log_warn("secret write failed for #{name}: #{e.message}")
36
+ false
37
+ end
38
+
39
+ def exist?(name, shared: false, user: nil)
40
+ return false unless crypt_available?
41
+
42
+ Legion::Crypt.exist?(resolve_path(name, shared: shared, user: user))
43
+ rescue StandardError
44
+ false
45
+ end
46
+
47
+ def delete(name, shared: false, user: nil)
48
+ return false unless crypt_available?
49
+
50
+ Legion::Crypt.delete(resolve_path(name, shared: shared, user: user))
51
+ true
52
+ rescue StandardError => e
53
+ log_warn("secret delete failed for #{name}: #{e.message}")
54
+ false
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_path(name, shared:, user:)
60
+ prefix = shared ? 'shared' : "users/#{resolve_user(user)}"
61
+ "#{prefix}/#{@lex_name}/#{name}"
62
+ end
63
+
64
+ def resolve_user(explicit_user)
65
+ return explicit_user if explicit_user
66
+
67
+ Secret.resolved_identity || ENV.fetch('USER', 'default')
68
+ end
69
+
70
+ def crypt_available?
71
+ return false unless defined?(Legion::Crypt)
72
+
73
+ unless @warned || vault_connected?
74
+ log_warn('Vault not connected — secret operations may fail')
75
+ @warned = true
76
+ end
77
+ true
78
+ end
79
+
80
+ def vault_connected?
81
+ defined?(Legion::Settings) &&
82
+ Legion::Settings[:crypt]&.dig(:vault, :connected) == true
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ def log_warn(msg)
88
+ Legion::Logging.warn("[Secret] #{msg}") if defined?(Legion::Logging)
89
+ end
90
+ end
91
+
92
+ module Secret
93
+ @resolved_identity = nil
94
+ @identity_source = nil
95
+
96
+ class << self
97
+ attr_reader :resolved_identity, :identity_source
98
+
99
+ def resolve_identity!
100
+ @resolved_identity = nil
101
+ @identity_source = nil
102
+
103
+ if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:kerberos_principal) &&
104
+ Legion::Crypt.kerberos_principal
105
+ @resolved_identity = Legion::Crypt.kerberos_principal
106
+ @identity_source = :kerberos
107
+ elsif entra_principal
108
+ @resolved_identity = entra_principal
109
+ @identity_source = :entra
110
+ end
111
+
112
+ @resolved_identity
113
+ end
114
+
115
+ def reset_identity!
116
+ @resolved_identity = nil
117
+ @identity_source = nil
118
+ end
119
+
120
+ private
121
+
122
+ def entra_principal
123
+ return nil unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
124
+
125
+ cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
126
+ return nil unless cache.respond_to?(:instance)
127
+
128
+ instance = cache.instance
129
+ return nil unless instance.respond_to?(:user_principal)
130
+
131
+ principal = instance.user_principal
132
+ principal unless principal.nil? || principal.empty?
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ def secret
139
+ @secret ||= SecretAccessor.new(lex_name: lex_name)
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.7'
4
+ VERSION = '1.6.9'
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.6.7
4
+ version: 1.6.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -433,6 +433,7 @@ files:
433
433
  - docs/README.md
434
434
  - exe/legion
435
435
  - exe/legionio
436
+ - integration/self_generate_spec.rb
436
437
  - legionio.gemspec
437
438
  - lib/legion.rb
438
439
  - lib/legion/alerts.rb
@@ -512,6 +513,7 @@ files:
512
513
  - lib/legion/cli/apollo_command.rb
513
514
  - lib/legion/cli/audit_command.rb
514
515
  - lib/legion/cli/auth_command.rb
516
+ - lib/legion/cli/bootstrap_command.rb
515
517
  - lib/legion/cli/chain.rb
516
518
  - lib/legion/cli/chain_command.rb
517
519
  - lib/legion/cli/chat/agent_delegator.rb
@@ -766,6 +768,7 @@ files:
766
768
  - lib/legion/extensions/helpers/lex.rb
767
769
  - lib/legion/extensions/helpers/llm.rb
768
770
  - lib/legion/extensions/helpers/logger.rb
771
+ - lib/legion/extensions/helpers/secret.rb
769
772
  - lib/legion/extensions/helpers/segments.rb
770
773
  - lib/legion/extensions/helpers/task.rb
771
774
  - lib/legion/extensions/helpers/transport.rb