legion-tty 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ea1c7546376e19d51fa1dde5e9f01184b74d41889c9f131f511aa90f634652d9
4
+ data.tar.gz: d1c354bb8dcbb056429d4e5eeaafbe7acc7fedaf15cc990ff96ec5d1f2561f6b
5
+ SHA512:
6
+ metadata.gz: 45ddd593ac68b8bdac3b28497f3f60120ed09078b4d3a1d6a870b5a5ffa0797fb2c7debfbe8ee48d0edf87b30a64b43c60195c710adddfdc7eb2b6c55d3d7b85
7
+ data.tar.gz: da55989963a0b796421c8050d8d434e7be6a331a7a6cd379f0d16a77a3859e21a49ca37a82468dabdf1f03c78936fb8609526d549f7b52472b4c44cc86f8fa13
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2026-03-18
4
+
5
+ ### Added
6
+ - Token tracker with per-provider pricing (claude, openai, gemini, azure, local)
7
+ - Session persistence (save/load/list/delete) via JSON in ~/.legionio/sessions/
8
+ - Operational dashboard screen with service status, extensions, system info, recent activity
9
+ - Hotkey system with register/handle pattern and Ctrl+D dashboard toggle
10
+ - Slash commands: /cost, /export, /tools, /model, /session, /dashboard, /hotkeys, /save, /load, /sessions
11
+ - LegionIO CLI integration via `legion tty` subcommand (autoloaded)
12
+ - Background environment rescan on second-run
13
+ - LLM setup with Legion::LLM and ruby_llm fallback
14
+ - Export to markdown and JSON formats
15
+ - README with feature comparison table (vs Claude Code, Codex CLI)
16
+ - 306 specs covering all components, screens, background probes, and integration
17
+
18
+ ### Changed
19
+ - Chat screen now supports full slash command dispatch
20
+ - App orchestrator wires hotkeys, dashboard toggle, help overlay, and environment rescan
21
+
22
+ ## [0.1.0] - 2026-03-17
23
+
24
+ ### Added
25
+ - Initial gem scaffold
26
+ - Onboarding wizard with digital rain, identity detection, API key collection
27
+ - Chat screen with streaming LLM responses and message history
28
+ - Theme system with 17-shade purple palette and semantic colors
29
+ - Screen manager with push/pop stack pattern
30
+ - Components: DigitalRain, InputBar, MessageStream, StatusBar, ToolPanel, MarkdownView, WizardPrompt
31
+ - Background probes: Scanner (services, repos, configs), GitHubProbe, KerberosProbe
32
+ - Boot logger for startup diagnostics
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2021 Esity
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # legion-tty
2
+
3
+ Rich terminal UI for the LegionIO async cognition engine.
4
+
5
+ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell, operational dashboard, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
6
+
7
+ ## Features
8
+
9
+ - **Onboarding wizard** - First-run setup with Kerberos identity detection, GitHub profile probing, environment scanning, and LLM provider selection
10
+ - **Digital rain intro** - Matrix-style rain using discovered LEX extension names
11
+ - **AI chat shell** - Streaming LLM chat with slash commands, tool panels, and markdown rendering
12
+ - **Operational dashboard** - Service status, extension inventory, system info, recent activity (Ctrl+D or `/dashboard`)
13
+ - **Session persistence** - Auto-save on quit, `/save`, `/load`, `/sessions` to manage history across runs
14
+ - **Token tracking** - Real-time input/output token counts and estimated cost via `/cost`
15
+ - **Hotkey navigation** - Ctrl+D (dashboard), Ctrl+L (refresh), ? (help overlay)
16
+ - **Second-run flow** - Skips onboarding, re-scans environment, drops into chat
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ gem install legion-tty
22
+ ```
23
+
24
+ Or via Homebrew (if legion is installed):
25
+
26
+ ```bash
27
+ brew install legion
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### Standalone
33
+
34
+ ```bash
35
+ legion-tty
36
+ ```
37
+
38
+ ### Via LegionIO CLI
39
+
40
+ ```bash
41
+ legion tty # launch rich TUI (default: interactive)
42
+ legion tty reset # clear identity, re-run onboarding
43
+ legion tty sessions # list saved chat sessions
44
+ legion tty version # show legion-tty version
45
+ ```
46
+
47
+ ### Quick prompt (via legion chat)
48
+
49
+ ```bash
50
+ legion chat prompt "explain async cognition"
51
+ ```
52
+
53
+ ## Slash Commands
54
+
55
+ | Command | Description |
56
+ |---------|-------------|
57
+ | `/help` | Show all commands |
58
+ | `/quit` | Exit (auto-saves session) |
59
+ | `/clear` | Clear message history |
60
+ | `/model <name>` | Switch LLM model at runtime |
61
+ | `/session <name>` | Rename current session |
62
+ | `/cost` | Show token usage and estimated cost |
63
+ | `/export [md\|json]` | Export chat history to file |
64
+ | `/tools` | List discovered LEX extensions |
65
+ | `/dashboard` | Toggle operational dashboard |
66
+ | `/hotkeys` | Show registered hotkey bindings |
67
+ | `/save [name]` | Save current session |
68
+ | `/load <name>` | Load a saved session |
69
+ | `/sessions` | List all saved sessions |
70
+
71
+ ## Architecture
72
+
73
+ ```
74
+ legion-tty
75
+ App # Orchestrator: config, LLM setup, screen management
76
+ ScreenManager # Push/pop screen stack with overlay support
77
+ Hotkeys # Keybinding registry
78
+ SessionStore # JSON-based session persistence
79
+ BootLogger # Boot sequence logging
80
+
81
+ Screens/
82
+ Onboarding # First-run wizard (rain -> intro -> wizard -> reveal)
83
+ Chat # AI chat REPL with streaming + slash commands
84
+ Dashboard # Operational status panels
85
+
86
+ Components/
87
+ DigitalRain # Matrix-style falling characters
88
+ InputBar # Prompt line with thinking indicator
89
+ MessageStream # Scrollable message history
90
+ StatusBar # Model, tokens, cost, session display
91
+ ToolPanel # Expandable tool use panels
92
+ MarkdownView # TTY::Markdown rendering
93
+ WizardPrompt # TTY::Prompt wrappers
94
+ TokenTracker # Token counting and cost estimation
95
+
96
+ Background/
97
+ Scanner # Service port probing, git repo discovery, shell history
98
+ GitHubProbe # GitHub API profile, repos, PRs, notifications
99
+ KerberosProbe # klist + LDAP profile resolution
100
+ ```
101
+
102
+ ## Comparison
103
+
104
+ | Feature | legion-tty | Claude Code | Codex CLI |
105
+ |---------|-----------|-------------|-----------|
106
+ | Onboarding wizard | Yes (identity detection) | No (API key only) | No |
107
+ | Streaming chat | Yes | Yes | Yes |
108
+ | Tool use panels | Yes | Yes | Yes |
109
+ | Dashboard | Yes (services, extensions) | No | No |
110
+ | Session persistence | Yes | Yes (conversations) | No |
111
+ | Environment scanning | Yes (services, repos, history) | Yes (git context) | Yes (git context) |
112
+ | Extension ecosystem | Yes (LEX gems) | Yes (MCP servers) | Yes (tools) |
113
+ | Identity probing | Yes (Kerberos, GitHub, LDAP) | No | No |
114
+ | Token/cost tracking | Yes | Yes | Yes |
115
+ | Hotkey navigation | Yes | Yes | No |
116
+
117
+ ## Configuration
118
+
119
+ Identity and credentials are stored in `~/.legionio/settings/`:
120
+
121
+ - `identity.json` - Name, Kerberos identity, GitHub profile, environment scan
122
+ - `credentials.json` - LLM provider and API key (chmod 600)
123
+
124
+ Sessions are stored in `~/.legionio/sessions/`.
125
+ Exports go to `~/.legionio/exports/`.
126
+ Boot logs go to `~/.legionio/logs/tty-boot.log`.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ bundle install
132
+ bundle exec rspec
133
+ bundle exec rubocop
134
+ ```
135
+
136
+ ## License
137
+
138
+ Apache-2.0
data/exe/legion-tty ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
5
+
6
+ require 'bootsnap'
7
+ Bootsnap.setup(
8
+ cache_dir: File.expand_path('~/.legionio/cache/bootsnap'),
9
+ development_mode: false,
10
+ load_path_cache: true,
11
+ compile_cache_iseq: true,
12
+ compile_cache_yaml: true
13
+ )
14
+
15
+ require 'legion/tty'
16
+
17
+ Legion::TTY::App.run(ARGV)
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative 'screen_manager'
6
+ require_relative 'hotkeys'
7
+ require_relative 'screens/onboarding'
8
+ require_relative 'screens/chat'
9
+
10
+ module Legion
11
+ module TTY
12
+ # rubocop:disable Metrics/ClassLength
13
+ class App
14
+ CONFIG_DIR = File.expand_path('~/.legionio/settings')
15
+
16
+ attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat
17
+
18
+ def self.run(argv = [])
19
+ _ = argv
20
+ app = new
21
+ app.start
22
+ rescue Interrupt
23
+ app&.shutdown
24
+ end
25
+
26
+ def self.first_run?(config_dir: CONFIG_DIR)
27
+ !File.exist?(File.join(config_dir, 'identity.json'))
28
+ end
29
+
30
+ def initialize(config_dir: CONFIG_DIR)
31
+ @config_dir = config_dir
32
+ @config = load_config
33
+ @credentials = load_credentials
34
+ @screen_manager = ScreenManager.new
35
+ @hotkeys = Hotkeys.new
36
+ @llm_chat = nil
37
+ end
38
+
39
+ def start
40
+ setup_hotkeys
41
+ if self.class.first_run?(config_dir: @config_dir)
42
+ run_onboarding
43
+ else
44
+ run_chat
45
+ end
46
+ end
47
+
48
+ def setup_hotkeys
49
+ @hotkeys.register("\x04", 'Toggle dashboard (Ctrl+D)') do
50
+ toggle_dashboard
51
+ end
52
+ @hotkeys.register("\x0C", 'Refresh screen (Ctrl+L)') do
53
+ :refresh
54
+ end
55
+ @hotkeys.register('?', 'Show help overlay') do
56
+ show_help_overlay
57
+ end
58
+ end
59
+
60
+ def toggle_dashboard
61
+ active = @screen_manager.active_screen
62
+ if active.is_a?(Screens::Dashboard)
63
+ @screen_manager.pop
64
+ else
65
+ require_relative 'screens/dashboard'
66
+ dashboard = Screens::Dashboard.new(self)
67
+ @screen_manager.push(dashboard)
68
+ end
69
+ end
70
+
71
+ def show_help_overlay
72
+ bindings = @hotkeys.list
73
+ lines = bindings.map { |b| " #{b[:key].inspect} - #{b[:description]}" }
74
+ text = "Hotkeys:\n#{lines.join("\n")}"
75
+ @screen_manager.show_overlay(text)
76
+ end
77
+
78
+ def run_onboarding
79
+ onboarding = Screens::Onboarding.new(self)
80
+ data = onboarding.activate
81
+ save_config(data)
82
+ @config = load_config
83
+ @credentials = load_credentials
84
+ run_chat
85
+ end
86
+
87
+ def run_chat
88
+ rescan_environment
89
+ setup_llm
90
+ chat = Screens::Chat.new(self)
91
+ @screen_manager.push(chat)
92
+ chat.run
93
+ end
94
+
95
+ def setup_llm
96
+ return unless @credentials[:provider] && @credentials[:api_key]
97
+
98
+ provider = @credentials[:provider].to_sym
99
+ api_key = @credentials[:api_key]
100
+ configure_llm_provider(provider, api_key)
101
+ @llm_chat = create_llm_chat(provider)
102
+ rescue StandardError
103
+ @llm_chat = nil
104
+ end
105
+
106
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
107
+ def rescan_environment
108
+ identity_path = File.join(@config_dir, 'identity.json')
109
+ return unless File.exist?(identity_path)
110
+
111
+ Thread.new do
112
+ scanner = Background::Scanner.new
113
+ scan = scanner.scan_all
114
+ identity = deep_symbolize(::JSON.parse(File.read(identity_path)))
115
+ services = scan[:services]&.values&.select { |s| s[:running] }&.map { |s| s[:name] } || []
116
+ repos = scan[:repos]&.map { |r| { name: r[:name], language: r[:language] } } || []
117
+ identity[:environment] = {
118
+ running_services: services,
119
+ repos_count: repos.size,
120
+ top_languages: repos.filter_map { |r| r[:language] }.tally.sort_by { |_, v| -v }.first(5).to_h
121
+ }.compact
122
+ File.write(identity_path, ::JSON.generate(identity))
123
+ @config = load_config
124
+ rescue StandardError
125
+ nil
126
+ end
127
+ end
128
+
129
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
130
+
131
+ def save_config(data)
132
+ FileUtils.mkdir_p(@config_dir)
133
+ save_identity(data)
134
+ save_credentials(data)
135
+ end
136
+
137
+ def shutdown
138
+ @screen_manager.teardown_all
139
+ end
140
+
141
+ PROVIDER_MAP = {
142
+ claude: :anthropic,
143
+ azure: :openai
144
+ }.freeze
145
+
146
+ private
147
+
148
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
149
+ def save_identity(data)
150
+ identity = {
151
+ name: data[:name],
152
+ provider: data[:provider],
153
+ created_at: Time.now.iso8601
154
+ }
155
+
156
+ # Kerberos identity
157
+ if data[:identity].is_a?(Hash)
158
+ id = data[:identity]
159
+ identity[:kerberos] = {
160
+ principal: id[:principal],
161
+ username: id[:username],
162
+ realm: id[:realm],
163
+ display_name: id[:display_name],
164
+ first_name: id[:first_name],
165
+ last_name: id[:last_name],
166
+ email: id[:email],
167
+ title: id[:title],
168
+ department: id[:department],
169
+ company: id[:company],
170
+ city: id[:city],
171
+ state: id[:state],
172
+ country: id[:country],
173
+ tenure_years: id[:tenure_years]
174
+ }.compact
175
+ end
176
+
177
+ # GitHub profile
178
+ if data[:github].is_a?(Hash) && data[:github][:username]
179
+ gh = data[:github]
180
+ identity[:github] = {
181
+ username: gh[:username],
182
+ authenticated: gh[:authenticated],
183
+ profile: gh[:profile],
184
+ orgs: gh[:orgs]
185
+ }.compact
186
+ end
187
+
188
+ # Environment scan
189
+ if data[:scan].is_a?(Hash)
190
+ scan = data[:scan]
191
+ services = scan[:services]&.values&.select { |s| s[:running] }&.map { |s| s[:name] } || []
192
+ repos = scan[:repos]&.map { |r| { name: r[:name], language: r[:language] } } || []
193
+ identity[:environment] = {
194
+ running_services: services,
195
+ repos_count: repos.size,
196
+ top_languages: repos.filter_map { |r| r[:language] }.tally.sort_by { |_, v| -v }.first(5).to_h
197
+ }.compact
198
+ end
199
+
200
+ File.write(File.join(@config_dir, 'identity.json'), ::JSON.generate(identity))
201
+ end
202
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
203
+
204
+ def load_credentials
205
+ path = File.join(@config_dir, 'credentials.json')
206
+ return {} unless File.exist?(path)
207
+
208
+ deep_symbolize(::JSON.parse(File.read(path)))
209
+ rescue ::JSON::ParserError, Errno::ENOENT
210
+ {}
211
+ end
212
+
213
+ def configure_llm_provider(provider, api_key)
214
+ llm_provider = PROVIDER_MAP[provider] || provider
215
+
216
+ # Try Legion::LLM first (full stack)
217
+ return if try_legion_llm(llm_provider, api_key)
218
+
219
+ # Fallback: configure ruby_llm directly
220
+ require 'ruby_llm'
221
+ RubyLLM.configure do |c|
222
+ case llm_provider
223
+ when :anthropic then c.anthropic_api_key = api_key
224
+ when :openai then c.openai_api_key = api_key
225
+ when :gemini then c.gemini_api_key = api_key
226
+ end
227
+ end
228
+ end
229
+
230
+ def try_legion_llm(llm_provider, api_key)
231
+ require 'legion/llm'
232
+ return false unless defined?(Legion::Settings)
233
+
234
+ Legion::Settings[:llm][:providers][llm_provider][:enabled] = true
235
+ Legion::Settings[:llm][:providers][llm_provider][:api_key] = api_key
236
+ Legion::LLM.start unless Legion::LLM.started?
237
+ true
238
+ rescue LoadError, StandardError
239
+ false
240
+ end
241
+
242
+ def create_llm_chat(provider)
243
+ llm_provider = PROVIDER_MAP[provider] || provider
244
+ if defined?(Legion::LLM) && Legion::LLM.started?
245
+ Legion::LLM.chat(provider: llm_provider)
246
+ else
247
+ RubyLLM.chat(provider: llm_provider)
248
+ end
249
+ end
250
+
251
+ def save_credentials(data)
252
+ credentials = { api_key: data[:api_key], provider: data[:provider] }
253
+ creds_path = File.join(@config_dir, 'credentials.json')
254
+ File.write(creds_path, ::JSON.generate(credentials))
255
+ ::File.chmod(0o600, creds_path)
256
+ end
257
+
258
+ def load_config
259
+ path = File.join(@config_dir, 'identity.json')
260
+ return {} unless File.exist?(path)
261
+
262
+ deep_symbolize(::JSON.parse(File.read(path)))
263
+ rescue ::JSON::ParserError, Errno::ENOENT
264
+ {}
265
+ end
266
+
267
+ def deep_symbolize(obj)
268
+ case obj
269
+ when Hash then obj.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
270
+ when Array then obj.map { |v| deep_symbolize(v) }
271
+ else obj
272
+ end
273
+ end
274
+ end
275
+ # rubocop:enable Metrics/ClassLength
276
+ end
277
+ end