legion-tty 0.2.0 → 0.2.6
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/CHANGELOG.md +53 -0
- data/exe/legion-tty +3 -3
- data/lib/legion/tty/background/bootstrap_config.rb +121 -0
- data/lib/legion/tty/background/llm_probe.rb +61 -0
- data/lib/legion/tty/background/scanner.rb +52 -1
- data/lib/legion/tty/components/wizard_prompt.rb +28 -0
- data/lib/legion/tty/screens/onboarding.rb +338 -5
- data/lib/legion/tty/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04c412dfdca2f44d48e45caaf6dbd086e191b4fc403513798852a695892d976b
|
|
4
|
+
data.tar.gz: 1bf491cae65f9fc1f89059a1d2796e81c4edbb634adbd8dd115f3bab0f04f33e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd44868ba314487524945f1f86c99faaa6f62f8028aa3bbe868587f11c589ff5c78163910d3aa64bab9ee2ff057166e6e6578a9824fa8e4553f1b37e457ad049
|
|
7
|
+
data.tar.gz: 210be4228de8843e19b59d2646bad9bef38f11f19b29e5c49810a7ae30419642d3dedba241b99fd6147c529a10c2b81b68c23d377c1f0fcc8255db2cacfbca84
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.6] - 2026-03-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Remove local path references from Gemfile (legionio, legion-rbac, lex-kerberos)
|
|
7
|
+
|
|
8
|
+
## [0.2.5] - 2026-03-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Bootstrap config auto-import: detects `LEGIONIO_BOOTSTRAP_CONFIG` env var during onboarding
|
|
12
|
+
- Fetches config from local file or HTTP/HTTPS URL (raw JSON or base64-encoded)
|
|
13
|
+
- Splits top-level keys into individual settings files (`~/.legionio/settings/{key}.json`)
|
|
14
|
+
- Deep merges with existing config files if present
|
|
15
|
+
- Bootstrap summary line in reveal box showing imported sections
|
|
16
|
+
- 14 new specs (374 total)
|
|
17
|
+
|
|
18
|
+
## [0.2.4] - 2026-03-18
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Cache service awakening: detects running Redis/Memcached, offers to activate if missing
|
|
22
|
+
- GAIA daemon discovery: checks for running legionio daemon, offers to start it
|
|
23
|
+
- Cinematic typed prompts: "extending neural pathways", "GAIA is awake", "cognitive threads synchronized"
|
|
24
|
+
- Cache and GAIA status lines in reveal summary box
|
|
25
|
+
- 46 new specs (360 total)
|
|
26
|
+
|
|
27
|
+
## [0.2.3] - 2026-03-18
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Vault LDAP auth step in onboarding: prompts to connect to configured vault clusters
|
|
31
|
+
- Default username from Kerberos samaccountname or `$USER`, hidden password input
|
|
32
|
+
- `WizardPrompt#ask_secret` (masked) and `#ask_with_default` methods
|
|
33
|
+
- Vault cluster connection status in reveal summary box
|
|
34
|
+
- 26 new specs for vault auth and wizard prompt methods
|
|
35
|
+
|
|
36
|
+
## [0.2.2] - 2026-03-18
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- Dotfile scanning: gitconfig (name, email, signing key), JFrog CLI servers, Terraform credential hosts
|
|
40
|
+
- `Scanner#scan_dotfiles`, `#scan_gitconfig`, `#scan_jfrog`, `#scan_terraform` methods
|
|
41
|
+
- Onboarding reveal box now displays discovered dotfile configuration
|
|
42
|
+
- Specs for all dotfile scanning and summary display methods
|
|
43
|
+
|
|
44
|
+
## [0.2.1] - 2026-03-18
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Onboarding replaces credential prompts with LLM provider auto-detection and ping-testing
|
|
48
|
+
- Shows green checkmark or red X with latency for each configured provider
|
|
49
|
+
- Auto-selects default provider or lets user choose if multiple are available
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- `Background::LlmProbe` for async provider ping-testing during onboarding
|
|
53
|
+
- `WizardPrompt#display_provider_results` and `#select_default_provider` methods
|
|
54
|
+
- Bootsnap and YJIT startup optimizations in `exe/legion-tty`
|
|
55
|
+
|
|
3
56
|
## [0.2.0] - 2026-03-18
|
|
4
57
|
|
|
5
58
|
### Added
|
data/exe/legion-tty
CHANGED
|
@@ -5,9 +5,9 @@ RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
|
|
|
5
5
|
|
|
6
6
|
require 'bootsnap'
|
|
7
7
|
Bootsnap.setup(
|
|
8
|
-
cache_dir:
|
|
9
|
-
development_mode:
|
|
10
|
-
load_path_cache:
|
|
8
|
+
cache_dir: File.expand_path('~/.legionio/cache/bootsnap'),
|
|
9
|
+
development_mode: false,
|
|
10
|
+
load_path_cache: true,
|
|
11
11
|
compile_cache_iseq: true,
|
|
12
12
|
compile_cache_yaml: true
|
|
13
13
|
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module TTY
|
|
11
|
+
module Background
|
|
12
|
+
class BootstrapConfig
|
|
13
|
+
ENV_KEY = 'LEGIONIO_BOOTSTRAP_CONFIG'
|
|
14
|
+
SETTINGS_DIR = File.expand_path('~/.legionio/settings')
|
|
15
|
+
|
|
16
|
+
def initialize(logger: nil)
|
|
17
|
+
@log = logger
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run_async(queue)
|
|
21
|
+
Thread.new do
|
|
22
|
+
result = perform_bootstrap
|
|
23
|
+
queue.push(result)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
@log&.log('bootstrap', "ERROR: #{e.class}: #{e.message}")
|
|
26
|
+
queue.push({ type: :bootstrap_error, error: e.message })
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def perform_bootstrap
|
|
33
|
+
@log&.log('bootstrap', 'checking for bootstrap config')
|
|
34
|
+
source = ENV.fetch(ENV_KEY, nil)
|
|
35
|
+
return skip_result unless source && !source.empty?
|
|
36
|
+
|
|
37
|
+
@log&.log('bootstrap', "source: #{source}")
|
|
38
|
+
body = fetch_source(source)
|
|
39
|
+
config = parse_payload(body)
|
|
40
|
+
written = write_split_config(config)
|
|
41
|
+
@log&.log('bootstrap', "wrote #{written.size} config files: #{written.join(', ')}")
|
|
42
|
+
{ type: :bootstrap_complete, data: { files: written, sections: config.keys.map(&:to_s) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def skip_result
|
|
46
|
+
@log&.log('bootstrap', "#{ENV_KEY} not set, skipping")
|
|
47
|
+
{ type: :bootstrap_complete, data: nil }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetch_source(source)
|
|
51
|
+
if source.match?(%r{\Ahttps?://}i)
|
|
52
|
+
fetch_http(source)
|
|
53
|
+
else
|
|
54
|
+
path = File.expand_path(source)
|
|
55
|
+
raise "File not found: #{source}" unless File.exist?(path)
|
|
56
|
+
|
|
57
|
+
File.read(path)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch_http(url)
|
|
62
|
+
uri = URI.parse(url)
|
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
|
+
http.use_ssl = uri.scheme == 'https'
|
|
65
|
+
http.open_timeout = 10
|
|
66
|
+
http.read_timeout = 10
|
|
67
|
+
request = Net::HTTP::Get.new(uri)
|
|
68
|
+
response = http.request(request)
|
|
69
|
+
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
|
|
71
|
+
response.body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_payload(body)
|
|
75
|
+
parsed = ::JSON.parse(body, symbolize_names: true)
|
|
76
|
+
raise 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
parsed
|
|
79
|
+
rescue ::JSON::ParserError
|
|
80
|
+
decoded = Base64.decode64(body)
|
|
81
|
+
parsed = ::JSON.parse(decoded, symbolize_names: true)
|
|
82
|
+
raise 'Config must be a JSON object' unless parsed.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
parsed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_split_config(config)
|
|
88
|
+
FileUtils.mkdir_p(SETTINGS_DIR)
|
|
89
|
+
written = []
|
|
90
|
+
|
|
91
|
+
config.each do |key, value|
|
|
92
|
+
next unless value.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
path = File.join(SETTINGS_DIR, "#{key}.json")
|
|
95
|
+
content = { key => value }
|
|
96
|
+
|
|
97
|
+
if File.exist?(path)
|
|
98
|
+
existing = ::JSON.parse(File.read(path), symbolize_names: true)
|
|
99
|
+
content = deep_merge(existing, content)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
File.write(path, ::JSON.pretty_generate(content))
|
|
103
|
+
written << "#{key}.json"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
written
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def deep_merge(base, overlay)
|
|
110
|
+
base.merge(overlay) do |_key, old_val, new_val|
|
|
111
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
112
|
+
deep_merge(old_val, new_val)
|
|
113
|
+
else
|
|
114
|
+
new_val
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
module Background
|
|
6
|
+
class LlmProbe
|
|
7
|
+
def initialize(logger: nil)
|
|
8
|
+
@log = logger
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run_async(queue)
|
|
12
|
+
Thread.new do
|
|
13
|
+
result = probe_providers
|
|
14
|
+
queue.push({ data: result })
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
@log&.log('llm_probe', "error: #{e.message}")
|
|
17
|
+
queue.push({ data: { providers: [], error: e.message } })
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def probe_providers
|
|
24
|
+
require 'legion/llm'
|
|
25
|
+
require 'legion/settings'
|
|
26
|
+
start_llm
|
|
27
|
+
results = collect_provider_results
|
|
28
|
+
{ providers: results }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start_llm
|
|
32
|
+
Legion::LLM.start unless Legion::LLM.started?
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@log&.log('llm_probe', "LLM start failed: #{e.message}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def collect_provider_results
|
|
38
|
+
providers = Legion::LLM.settings[:providers] || {}
|
|
39
|
+
providers.filter_map do |name, config|
|
|
40
|
+
next unless config[:enabled]
|
|
41
|
+
|
|
42
|
+
result = ping_provider(name, config)
|
|
43
|
+
@log&.log('llm_probe', "#{name}: #{result[:status]} (#{result[:latency_ms]}ms)")
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ping_provider(name, config)
|
|
49
|
+
model = config[:default_model]
|
|
50
|
+
start_time = Time.now
|
|
51
|
+
RubyLLM.chat(model: model, provider: name).ask('Respond with only: pong')
|
|
52
|
+
latency = ((Time.now - start_time) * 1000).round
|
|
53
|
+
{ name: name, model: model, status: :ok, latency_ms: latency }
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
latency = ((Time.now - start_time) * 1000).round
|
|
56
|
+
{ name: name, model: model, status: :error, latency_ms: latency, error: e.message }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -60,7 +60,15 @@ module Legion
|
|
|
60
60
|
|
|
61
61
|
def scan_all
|
|
62
62
|
{ services: scan_services, repos: scan_git_repos, tools: scan_shell_history,
|
|
63
|
-
configs: scan_config_files }
|
|
63
|
+
configs: scan_config_files, dotfiles: scan_dotfiles }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scan_dotfiles
|
|
67
|
+
{
|
|
68
|
+
git: scan_gitconfig,
|
|
69
|
+
jfrog: scan_jfrog,
|
|
70
|
+
terraform: scan_terraform
|
|
71
|
+
}
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def run_async(queue)
|
|
@@ -153,6 +161,49 @@ module Legion
|
|
|
153
161
|
def extract_command(line)
|
|
154
162
|
line.sub(/^: \d+:\d+;/, '').split.first
|
|
155
163
|
end
|
|
164
|
+
|
|
165
|
+
def scan_gitconfig
|
|
166
|
+
name = `git config --global user.name 2>/dev/null`.strip
|
|
167
|
+
email = `git config --global user.email 2>/dev/null`.strip
|
|
168
|
+
signing_key = `git config --global user.signingkey 2>/dev/null`.strip
|
|
169
|
+
return nil if name.empty? && email.empty?
|
|
170
|
+
|
|
171
|
+
result = { name: name.empty? ? nil : name, email: email.empty? ? nil : email }
|
|
172
|
+
result[:signing_key] = signing_key unless signing_key.empty?
|
|
173
|
+
result
|
|
174
|
+
rescue StandardError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def scan_jfrog
|
|
179
|
+
config_path = File.expand_path('~/.jfrog/jfrog-cli.conf.v6')
|
|
180
|
+
return nil unless File.exist?(config_path)
|
|
181
|
+
|
|
182
|
+
require 'json'
|
|
183
|
+
data = ::JSON.parse(File.read(config_path), symbolize_names: true)
|
|
184
|
+
servers = data[:servers]
|
|
185
|
+
return nil unless servers.is_a?(Array) && !servers.empty?
|
|
186
|
+
|
|
187
|
+
servers.map do |s|
|
|
188
|
+
{ server_id: s[:serverId], url: s[:url], user: s[:user] }
|
|
189
|
+
end
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def scan_terraform
|
|
195
|
+
creds_path = File.expand_path('~/.terraform.d/credentials.tfrc.json')
|
|
196
|
+
return nil unless File.exist?(creds_path)
|
|
197
|
+
|
|
198
|
+
require 'json'
|
|
199
|
+
data = ::JSON.parse(File.read(creds_path), symbolize_names: true)
|
|
200
|
+
hosts = data[:credentials]&.keys || []
|
|
201
|
+
return nil if hosts.empty?
|
|
202
|
+
|
|
203
|
+
{ hosts: hosts.map(&:to_s) }
|
|
204
|
+
rescue StandardError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
156
207
|
end
|
|
157
208
|
# rubocop:enable Metrics/ClassLength
|
|
158
209
|
end
|
|
@@ -34,6 +34,14 @@ module Legion
|
|
|
34
34
|
@prompt.mask("Enter API key for #{provider}:")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def ask_secret(question)
|
|
38
|
+
@prompt.mask(question)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ask_with_default(question, default)
|
|
42
|
+
@prompt.ask(question, default: default)
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
# rubocop:disable Naming/PredicateMethod
|
|
38
46
|
def confirm(question)
|
|
39
47
|
@prompt.yes?(question)
|
|
@@ -43,6 +51,26 @@ module Legion
|
|
|
43
51
|
def select_from(question, choices)
|
|
44
52
|
@prompt.select(question, choices)
|
|
45
53
|
end
|
|
54
|
+
|
|
55
|
+
def display_provider_results(providers)
|
|
56
|
+
providers.each do |p|
|
|
57
|
+
icon = p[:status] == :ok ? "\u2705" : "\u274C"
|
|
58
|
+
latency = "#{p[:latency_ms]}ms"
|
|
59
|
+
label = "#{icon} #{p[:name]} (#{p[:model]}) \u2014 #{latency}"
|
|
60
|
+
label += " [#{p[:error]}]" if p[:error]
|
|
61
|
+
@prompt.say(label)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def select_default_provider(working_providers)
|
|
66
|
+
return nil if working_providers.empty?
|
|
67
|
+
return working_providers.first[:name] if working_providers.size == 1
|
|
68
|
+
|
|
69
|
+
choices = working_providers.map do |p|
|
|
70
|
+
{ name: "#{p[:name]} (#{p[:model]}, #{p[:latency_ms]}ms)", value: p[:name] }
|
|
71
|
+
end
|
|
72
|
+
@prompt.select('Multiple providers available. Choose your default:', choices)
|
|
73
|
+
end
|
|
46
74
|
end
|
|
47
75
|
end
|
|
48
76
|
end
|
|
@@ -6,6 +6,7 @@ require_relative '../components/wizard_prompt'
|
|
|
6
6
|
require_relative '../background/scanner'
|
|
7
7
|
require_relative '../background/github_probe'
|
|
8
8
|
require_relative '../background/kerberos_probe'
|
|
9
|
+
require_relative '../background/bootstrap_config'
|
|
9
10
|
require_relative '../boot_logger'
|
|
10
11
|
require_relative '../theme'
|
|
11
12
|
|
|
@@ -25,11 +26,16 @@ module Legion
|
|
|
25
26
|
@github_queue = Queue.new
|
|
26
27
|
@github_quick_queue = Queue.new
|
|
27
28
|
@kerberos_queue = Queue.new
|
|
29
|
+
@llm_queue = Queue.new
|
|
30
|
+
@bootstrap_queue = Queue.new
|
|
28
31
|
@kerberos_identity = nil
|
|
29
32
|
@github_quick = nil
|
|
33
|
+
@vault_results = nil
|
|
34
|
+
@bootstrap_data = nil
|
|
30
35
|
@log = BootLogger.new
|
|
31
36
|
end
|
|
32
37
|
|
|
38
|
+
# rubocop:disable Metrics/AbcSize
|
|
33
39
|
def activate
|
|
34
40
|
@log.log('onboarding', 'activate started')
|
|
35
41
|
start_background_threads
|
|
@@ -37,11 +43,16 @@ module Legion
|
|
|
37
43
|
run_intro
|
|
38
44
|
config = run_wizard
|
|
39
45
|
@log.log('wizard', "name=#{config[:name]} provider=#{config[:provider]}")
|
|
46
|
+
collect_bootstrap_result
|
|
47
|
+
run_vault_auth
|
|
40
48
|
scan_data, github_data = collect_background_results
|
|
49
|
+
run_cache_awakening(scan_data)
|
|
50
|
+
run_gaia_awakening
|
|
41
51
|
run_reveal(name: config[:name], scan_data: scan_data, github_data: github_data)
|
|
42
52
|
@log.log('onboarding', 'activate complete')
|
|
43
53
|
build_onboarding_result(config, scan_data, github_data)
|
|
44
54
|
end
|
|
55
|
+
# rubocop:enable Metrics/AbcSize
|
|
45
56
|
|
|
46
57
|
# rubocop:disable Metrics/AbcSize
|
|
47
58
|
def run_rain
|
|
@@ -92,13 +103,34 @@ module Legion
|
|
|
92
103
|
typed_output(" Nice to meet you, #{name}.")
|
|
93
104
|
@output.puts
|
|
94
105
|
sleep 1
|
|
95
|
-
|
|
106
|
+
providers = detect_providers
|
|
107
|
+
default = select_provider_default(providers)
|
|
108
|
+
@output.puts
|
|
109
|
+
{ name: name, provider: default, providers: providers }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def detect_providers
|
|
113
|
+
typed_output('Detecting AI providers...')
|
|
96
114
|
@output.puts
|
|
97
115
|
@output.puts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
llm_data = drain_with_timeout(@llm_queue, timeout: 15)
|
|
117
|
+
providers = llm_data&.dig(:data, :providers) || []
|
|
118
|
+
@wizard.display_provider_results(providers)
|
|
119
|
+
@output.puts
|
|
120
|
+
providers
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def select_provider_default(providers)
|
|
124
|
+
working = providers.select { |p| p[:status] == :ok }
|
|
125
|
+
if working.any?
|
|
126
|
+
default = @wizard.select_default_provider(working)
|
|
127
|
+
sleep 0.5
|
|
128
|
+
typed_output("Connected. Let's chat.")
|
|
129
|
+
default
|
|
130
|
+
else
|
|
131
|
+
typed_output('No AI providers detected. Configure one in ~/.legionio/settings/llm.json')
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
102
134
|
end
|
|
103
135
|
|
|
104
136
|
def start_background_threads
|
|
@@ -109,7 +141,81 @@ module Legion
|
|
|
109
141
|
@scanner.run_async(@scan_queue)
|
|
110
142
|
@kerberos_probe.run_async(@kerberos_queue)
|
|
111
143
|
@github_probe.run_quick_async(@github_quick_queue)
|
|
144
|
+
require_relative '../background/llm_probe'
|
|
145
|
+
@llm_probe = Background::LlmProbe.new(logger: @log)
|
|
146
|
+
@llm_probe.run_async(@llm_queue)
|
|
147
|
+
@bootstrap_probe = Background::BootstrapConfig.new(logger: @log)
|
|
148
|
+
@bootstrap_probe.run_async(@bootstrap_queue)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def run_cache_awakening(scan_data)
|
|
152
|
+
services = scan_data.is_a?(Hash) ? scan_data[:services] : nil
|
|
153
|
+
return unless services.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
if services.dig(:memcached, :running) || services.dig(:redis, :running)
|
|
156
|
+
typed_output('... extending neural pathways...')
|
|
157
|
+
sleep 0.8
|
|
158
|
+
typed_output('Additional memory online.')
|
|
159
|
+
else
|
|
160
|
+
run_cache_offer
|
|
161
|
+
end
|
|
162
|
+
@output.puts
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def run_cache_offer
|
|
166
|
+
typed_output('No extended memory detected.')
|
|
167
|
+
sleep 0.8
|
|
168
|
+
@output.puts
|
|
169
|
+
return unless @wizard.confirm('Shall I activate a memory cache?')
|
|
170
|
+
|
|
171
|
+
binary = detect_cache_binary
|
|
172
|
+
if binary
|
|
173
|
+
run_cache_start(binary)
|
|
174
|
+
else
|
|
175
|
+
typed_output('No cache service found. Install with: brew install memcached')
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def run_cache_start(binary)
|
|
180
|
+
started = start_cache_service(binary.to_s)
|
|
181
|
+
if started
|
|
182
|
+
typed_output('Memory cache activated. Neural capacity expanded.')
|
|
183
|
+
else
|
|
184
|
+
typed_output("No cache service found. Install with: brew install #{binary}")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
189
|
+
def run_gaia_awakening
|
|
190
|
+
typed_output('Scanning for active cognition threads...')
|
|
191
|
+
sleep 1.2
|
|
192
|
+
@output.puts
|
|
193
|
+
|
|
194
|
+
if legionio_running?
|
|
195
|
+
typed_output('GAIA is awake.')
|
|
196
|
+
sleep 0.5
|
|
197
|
+
typed_output('Heuristic mesh: nominal.')
|
|
198
|
+
sleep 0.8
|
|
199
|
+
typed_output('Cognitive threads synchronized.')
|
|
200
|
+
else
|
|
201
|
+
typed_output('GAIA is dormant.')
|
|
202
|
+
sleep 1
|
|
203
|
+
@output.puts
|
|
204
|
+
if @wizard.confirm('Shall I wake her?')
|
|
205
|
+
started = start_legionio_daemon
|
|
206
|
+
if started
|
|
207
|
+
typed_output('... initializing cognitive substrate...')
|
|
208
|
+
sleep 1
|
|
209
|
+
typed_output('GAIA online. All systems nominal.')
|
|
210
|
+
else
|
|
211
|
+
typed_output("Could not start daemon. Run 'legionio start' manually.")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
@output.puts
|
|
112
217
|
end
|
|
218
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
113
219
|
|
|
114
220
|
def collect_background_results
|
|
115
221
|
@log.log('collect', 'waiting for scanner results (10s timeout)')
|
|
@@ -145,16 +251,104 @@ module Legion
|
|
|
145
251
|
sleep 1
|
|
146
252
|
end
|
|
147
253
|
|
|
254
|
+
# rubocop:disable Metrics/AbcSize
|
|
148
255
|
def build_summary(name:, scan_data:, github_data:)
|
|
149
256
|
lines = ["Hello, #{name}!", '', "Here's what I found:"]
|
|
257
|
+
lines.concat(bootstrap_summary_lines)
|
|
150
258
|
lines.concat(identity_summary_lines)
|
|
151
259
|
lines.concat(scan_summary_lines(scan_data))
|
|
260
|
+
lines.concat(dotfiles_summary_lines(scan_data))
|
|
152
261
|
lines.concat(github_summary_lines(github_data))
|
|
262
|
+
lines.concat(vault_summary_lines)
|
|
263
|
+
lines.concat(cache_summary_lines(scan_data))
|
|
264
|
+
lines.concat(gaia_summary_lines)
|
|
153
265
|
lines.join("\n")
|
|
154
266
|
end
|
|
267
|
+
# rubocop:enable Metrics/AbcSize
|
|
155
268
|
|
|
156
269
|
private
|
|
157
270
|
|
|
271
|
+
def collect_bootstrap_result
|
|
272
|
+
result = drain_with_timeout(@bootstrap_queue, timeout: 5)
|
|
273
|
+
@bootstrap_data = result&.dig(:data)
|
|
274
|
+
return unless @bootstrap_data
|
|
275
|
+
|
|
276
|
+
files = @bootstrap_data[:files] || []
|
|
277
|
+
@log.log('bootstrap', "imported #{files.size} config files: #{files.join(', ')}")
|
|
278
|
+
typed_output('Configuration loaded.')
|
|
279
|
+
@output.puts
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def run_vault_auth
|
|
283
|
+
return unless vault_clusters_configured?
|
|
284
|
+
|
|
285
|
+
count = vault_cluster_count
|
|
286
|
+
@output.puts
|
|
287
|
+
typed_output("I found #{count} Vault cluster#{'s' if count != 1}.")
|
|
288
|
+
@output.puts
|
|
289
|
+
return unless @wizard.confirm('Connect now?')
|
|
290
|
+
|
|
291
|
+
run_vault_auth_credentials
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def run_vault_auth_credentials
|
|
295
|
+
username = @wizard.ask_with_default('Username:', default_vault_username)
|
|
296
|
+
password = @wizard.ask_secret('Password:')
|
|
297
|
+
return if password.nil? || password.empty?
|
|
298
|
+
|
|
299
|
+
@output.puts
|
|
300
|
+
typed_output('Authenticating...')
|
|
301
|
+
@output.puts
|
|
302
|
+
|
|
303
|
+
@vault_results = perform_vault_auth(username, password)
|
|
304
|
+
display_vault_results(@vault_results)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def vault_clusters_configured?
|
|
308
|
+
return false unless defined?(Legion::Settings)
|
|
309
|
+
|
|
310
|
+
clusters = Legion::Settings.dig(:crypt, :vault, :clusters)
|
|
311
|
+
clusters.is_a?(Hash) && clusters.any?
|
|
312
|
+
rescue StandardError
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def vault_cluster_count
|
|
317
|
+
Legion::Settings.dig(:crypt, :vault, :clusters)&.size || 0
|
|
318
|
+
rescue StandardError
|
|
319
|
+
0
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def default_vault_username
|
|
323
|
+
if @kerberos_identity&.dig(:samaccountname)
|
|
324
|
+
@kerberos_identity[:samaccountname]
|
|
325
|
+
else
|
|
326
|
+
ENV.fetch('USER', 'unknown')
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def perform_vault_auth(username, password)
|
|
331
|
+
return {} unless defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:ldap_login_all)
|
|
332
|
+
|
|
333
|
+
Legion::Crypt.ldap_login_all(username: username, password: password)
|
|
334
|
+
rescue StandardError => e
|
|
335
|
+
@log.log('vault', "LDAP auth failed: #{e.message}")
|
|
336
|
+
{}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def display_vault_results(results)
|
|
340
|
+
results.each do |name, result|
|
|
341
|
+
if result[:error]
|
|
342
|
+
@output.puts " #{Theme.c(:error, 'X')} #{name}: #{result[:error]}"
|
|
343
|
+
else
|
|
344
|
+
policies = result[:policies]&.size || 0
|
|
345
|
+
@output.puts " #{Theme.c(:success, 'ok')} #{name}: connected (#{policies} policies)"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
@output.puts
|
|
349
|
+
sleep 1
|
|
350
|
+
end
|
|
351
|
+
|
|
158
352
|
def build_onboarding_result(config, scan_data, github_data)
|
|
159
353
|
{
|
|
160
354
|
**config,
|
|
@@ -282,6 +476,85 @@ module Legion
|
|
|
282
476
|
['', "Running services: #{running.join(', ')}"]
|
|
283
477
|
end
|
|
284
478
|
|
|
479
|
+
def dotfiles_summary_lines(scan_data)
|
|
480
|
+
return [] unless scan_data.is_a?(Hash)
|
|
481
|
+
|
|
482
|
+
dotfiles = scan_data[:dotfiles]
|
|
483
|
+
return [] unless dotfiles.is_a?(Hash)
|
|
484
|
+
|
|
485
|
+
lines = []
|
|
486
|
+
lines.concat(git_summary_lines(dotfiles[:git]))
|
|
487
|
+
lines.concat(jfrog_summary_lines(dotfiles[:jfrog]))
|
|
488
|
+
lines.concat(terraform_summary_lines(dotfiles[:terraform]))
|
|
489
|
+
lines
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def git_summary_lines(git)
|
|
493
|
+
return [] unless git.is_a?(Hash)
|
|
494
|
+
|
|
495
|
+
lines = ['', "Git: #{git[:name]} <#{git[:email]}>"]
|
|
496
|
+
lines << " Signing key: #{git[:signing_key]}" if git[:signing_key]
|
|
497
|
+
lines
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def jfrog_summary_lines(jfrog)
|
|
501
|
+
return [] unless jfrog.is_a?(Array) && !jfrog.empty?
|
|
502
|
+
|
|
503
|
+
lines = ['', 'JFrog Artifactory:']
|
|
504
|
+
jfrog.each { |s| lines << " #{s[:server_id]}: #{s[:url]} (#{s[:user]})" }
|
|
505
|
+
lines
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def vault_summary_lines
|
|
509
|
+
return [] unless @vault_results.is_a?(Hash) && @vault_results.any?
|
|
510
|
+
|
|
511
|
+
lines = ['', 'Vault:']
|
|
512
|
+
@vault_results.each do |name, result|
|
|
513
|
+
lines << if result[:error]
|
|
514
|
+
" #{name}: failed (#{result[:error]})"
|
|
515
|
+
else
|
|
516
|
+
" #{name}: connected"
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
lines
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def cache_summary_lines(scan_data)
|
|
523
|
+
services = scan_data.is_a?(Hash) ? scan_data[:services] : nil
|
|
524
|
+
return [] unless services.is_a?(Hash)
|
|
525
|
+
|
|
526
|
+
if services.dig(:memcached, :running)
|
|
527
|
+
['', 'Memory: memcached online']
|
|
528
|
+
elsif services.dig(:redis, :running)
|
|
529
|
+
['', 'Memory: redis online']
|
|
530
|
+
else
|
|
531
|
+
['', 'Memory: no cache service running']
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def bootstrap_summary_lines
|
|
536
|
+
return [] unless @bootstrap_data.is_a?(Hash)
|
|
537
|
+
|
|
538
|
+
sections = @bootstrap_data[:sections] || []
|
|
539
|
+
return [] if sections.empty?
|
|
540
|
+
|
|
541
|
+
['', "Bootstrap config: #{sections.join(', ')}"]
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def gaia_summary_lines
|
|
545
|
+
if legionio_running?
|
|
546
|
+
['', 'GAIA: online']
|
|
547
|
+
else
|
|
548
|
+
['', 'GAIA: dormant']
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def terraform_summary_lines(dotfiles_tf)
|
|
553
|
+
return [] unless dotfiles_tf.is_a?(Hash) && dotfiles_tf[:hosts]&.any?
|
|
554
|
+
|
|
555
|
+
['', "Terraform: #{dotfiles_tf[:hosts].join(', ')}"]
|
|
556
|
+
end
|
|
557
|
+
|
|
285
558
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
286
559
|
def github_summary_lines(github_data)
|
|
287
560
|
return [] unless github_data.is_a?(Hash)
|
|
@@ -443,6 +716,66 @@ module Legion
|
|
|
443
716
|
nil
|
|
444
717
|
end
|
|
445
718
|
|
|
719
|
+
def detect_cache_binary
|
|
720
|
+
if system('which memcached > /dev/null 2>&1')
|
|
721
|
+
:memcached
|
|
722
|
+
elsif system('which redis-server > /dev/null 2>&1')
|
|
723
|
+
:redis
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def start_cache_service(service_name)
|
|
728
|
+
@log.log('cache', "starting #{service_name} via brew services")
|
|
729
|
+
result = system("brew services start #{service_name} > /dev/null 2>&1")
|
|
730
|
+
@log.log('cache', "brew services start #{service_name}: #{result ? 'ok' : 'failed'}")
|
|
731
|
+
result
|
|
732
|
+
rescue StandardError => e
|
|
733
|
+
@log.log('cache', "failed to start #{service_name}: #{e.message}")
|
|
734
|
+
false
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def legionio_running?
|
|
738
|
+
pid_paths = [
|
|
739
|
+
File.expand_path('~/.legionio/legion.pid'),
|
|
740
|
+
'/tmp/legionio.pid'
|
|
741
|
+
]
|
|
742
|
+
pid_paths.each do |path|
|
|
743
|
+
next unless File.exist?(path)
|
|
744
|
+
|
|
745
|
+
pid = File.read(path).strip.to_i
|
|
746
|
+
next unless pid.positive?
|
|
747
|
+
|
|
748
|
+
begin
|
|
749
|
+
::Process.kill(0, pid)
|
|
750
|
+
return true
|
|
751
|
+
rescue Errno::ESRCH
|
|
752
|
+
next
|
|
753
|
+
rescue Errno::EPERM
|
|
754
|
+
return true
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
system('pgrep -x legionio > /dev/null 2>&1')
|
|
758
|
+
rescue StandardError
|
|
759
|
+
false
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def start_legionio_daemon
|
|
763
|
+
@log.log('gaia', 'attempting to start legionio daemon')
|
|
764
|
+
if system('brew services start legionio > /dev/null 2>&1')
|
|
765
|
+
@log.log('gaia', 'started via brew services')
|
|
766
|
+
true
|
|
767
|
+
elsif system('legionio start -d > /dev/null 2>&1')
|
|
768
|
+
@log.log('gaia', 'started via legionio start -d')
|
|
769
|
+
true
|
|
770
|
+
else
|
|
771
|
+
@log.log('gaia', 'failed to start daemon')
|
|
772
|
+
false
|
|
773
|
+
end
|
|
774
|
+
rescue StandardError => e
|
|
775
|
+
@log.log('gaia', "start failed: #{e.message}")
|
|
776
|
+
false
|
|
777
|
+
end
|
|
778
|
+
|
|
446
779
|
def terminal_width
|
|
447
780
|
require 'tty-screen'
|
|
448
781
|
::TTY::Screen.width
|
data/lib/legion/tty/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-tty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -195,8 +195,10 @@ files:
|
|
|
195
195
|
- exe/legion-tty
|
|
196
196
|
- lib/legion/tty.rb
|
|
197
197
|
- lib/legion/tty/app.rb
|
|
198
|
+
- lib/legion/tty/background/bootstrap_config.rb
|
|
198
199
|
- lib/legion/tty/background/github_probe.rb
|
|
199
200
|
- lib/legion/tty/background/kerberos_probe.rb
|
|
201
|
+
- lib/legion/tty/background/llm_probe.rb
|
|
200
202
|
- lib/legion/tty/background/scanner.rb
|
|
201
203
|
- lib/legion/tty/boot_logger.rb
|
|
202
204
|
- lib/legion/tty/components/digital_rain.rb
|
|
@@ -241,7 +243,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
241
243
|
- !ruby/object:Gem::Version
|
|
242
244
|
version: '0'
|
|
243
245
|
requirements: []
|
|
244
|
-
rubygems_version:
|
|
246
|
+
rubygems_version: 3.6.9
|
|
245
247
|
specification_version: 4
|
|
246
248
|
summary: Interactive terminal UI for the LegionIO framework
|
|
247
249
|
test_files: []
|