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 +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +27 -13
- data/Gemfile +2 -0
- data/integration/self_generate_spec.rb +398 -0
- data/lib/legion/cli/bootstrap_command.rb +399 -0
- data/lib/legion/cli/chat/context.rb +11 -0
- data/lib/legion/cli.rb +4 -0
- data/lib/legion/extensions/helpers/lex.rb +2 -0
- data/lib/legion/extensions/helpers/secret.rb +144 -0
- data/lib/legion/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e67d5abaf8c274cad673af9996e29577949a9e945c66ee099b1c8e23eeff4a3f
|
|
4
|
+
data.tar.gz: 4c3113323f086005de660cd84128036d59919ee2f2482ce84a855334bab007c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d221a3d63b90d155b24fabd47594aef550467d6136a96092208a7f710455f87f416ae4110815e323c7bfcb83aec13ec4c41dff7b2db14b0846dafbe55ca9be66
|
|
7
|
+
data.tar.gz: b495e7daddcbb0e5cbda7c93f152ca285e044972ee8b74c9d432f91e97c4536d1087539f7b8daf784910cb359935453b6aa1f9e972c8d81b688d55c8986e2f34
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
-
- `
|
|
7
|
-
-
|
|
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.
|
|
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
|
|
15
|
-
-
|
|
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.
|
|
34
|
+
## [1.6.7] - 2026-03-26
|
|
22
35
|
|
|
23
|
-
###
|
|
24
|
-
- `
|
|
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
|
@@ -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
|
data/lib/legion/version.rb
CHANGED
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.
|
|
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
|