legionio 1.4.59 → 1.4.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb8c466919694ff3ee71e207099e6cdbc7d93ec3a641c7213a9efa5f624fceec
4
- data.tar.gz: 714ca199c88b04b1f6b84680dbffe2c6f5d953d09d6114d115878cc98a10e3fe
3
+ metadata.gz: 6c7e2aaea33b6be8c15f2059d61621a70e9e431957e4e574b8ff574d33d40045
4
+ data.tar.gz: 07ea8f95f7b2c5132f8fe7992ccb247d5cec1fe909f3ba927f42bf172fff22ec
5
5
  SHA512:
6
- metadata.gz: '08cce649982a2b0fabb240cf1ce373bf8b98c7807d5608d4e05caea9f26458b56c98f7b62584950d15376112d3f6396dbd3bd5c91e2779ca61867e1debaab2c5'
7
- data.tar.gz: a7d37673e26e87ef65a04100884720da7c762f5fbd8c15f116febd7dc02314df77e0f1243acd2c383bc3e55e4ac940f3ca70ae49e0dfa76ece907871f39e7892
6
+ metadata.gz: a5648ed88592cb9eaaac03a285f3862812ff9dbd93ea3a0f8a2bd1ff400e7b2084c3d59e5b2b5fbbf0016ff05f4897291f25f59b62c543ef86b9806aeba6a377
7
+ data.tar.gz: 73b80071c84f14627df14ceca94a9f7201143ca47149244838d649387d82430cfffaf3a1998fcb92f91d6aecc73abc9a8ce151eb6ea626c8237b738554ca9965
data/.rubocop.yml CHANGED
@@ -39,6 +39,7 @@ Metrics/BlockLength:
39
39
  - 'lib/legion/api/auth.rb'
40
40
  - 'lib/legion/api/auth_worker.rb'
41
41
  - 'lib/legion/api/auth_human.rb'
42
+ - 'lib/legion/cli/auth_command.rb'
42
43
 
43
44
  Metrics/AbcSize:
44
45
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.61] - 2026-03-18
4
+
5
+ ### Added
6
+ - Chat persistent settings defaults via `Legion::Settings` (issue #5)
7
+ - `chat_setting(*keys)` helper for centralized settings access with error handling
8
+ - Settings priority chain: CLI flag > `Legion::Settings.dig(:chat, ...)` > hardcoded default
9
+ - Configurable via settings: model, provider, personality, permissions, markdown, incognito, max_budget_usd, subagent concurrency/timeout, headless max_turns
10
+ - `chat` subsystem added to `config scaffold` with full template
11
+ - `Subagent.configure_from_settings` reads concurrency and timeout from settings
12
+ - 22 new specs (19 settings integration + 3 subagent settings)
13
+
14
+ ## [1.4.60] - 2026-03-18
15
+
16
+ ### Fixed
17
+ - Empty Enter in chat REPL no longer exits the session; returns empty string instead of nil to disambiguate from Ctrl+D (EOF)
18
+
3
19
  ## [1.4.59] - 2026-03-17
4
20
 
5
21
  ### Added
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.4.52
12
+ **Version**: 1.4.61
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -711,7 +711,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
711
711
 
712
712
  ```bash
713
713
  bundle install
714
- bundle exec rspec # 1208 examples, 0 failures
714
+ bundle exec rspec # 1379 examples, 0 failures
715
715
  bundle exec rubocop # 396 files, 0 offenses
716
716
  ```
717
717
 
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module AuthKerberos
7
+ def self.registered(app)
8
+ register_negotiate(app)
9
+ end
10
+
11
+ def self.resolve_kerberos_role_map
12
+ return {} unless defined?(Legion::Settings)
13
+
14
+ Legion::Settings.dig(:kerberos, :role_map) || {}
15
+ rescue StandardError
16
+ {}
17
+ end
18
+
19
+ def self.kerberos_available?
20
+ defined?(Legion::Extensions::Kerberos::Client) &&
21
+ defined?(Legion::Rbac::KerberosClaimsMapper)
22
+ end
23
+
24
+ def self.register_negotiate(app)
25
+ app.get '/api/auth/negotiate' do
26
+ auth_header = request.env['HTTP_AUTHORIZATION']
27
+
28
+ unless auth_header&.match?(/\ANegotiate\s+/i)
29
+ headers['WWW-Authenticate'] = 'Negotiate'
30
+ halt 401, json_error('negotiate_required', 'Negotiate token required', status_code: 401)
31
+ end
32
+
33
+ halt 501, json_error('kerberos_not_available', 'Kerberos extension is not loaded', status_code: 501) unless Routes::AuthKerberos.kerberos_available?
34
+
35
+ token = auth_header.sub(/\ANegotiate\s+/i, '')
36
+
37
+ auth_result = begin
38
+ client = Legion::Extensions::Kerberos::Client.new
39
+ client.authenticate(token: token)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ unless auth_result&.dig(:success)
45
+ headers['WWW-Authenticate'] = 'Negotiate'
46
+ halt 401, json_error('kerberos_auth_failed', 'Kerberos authentication failed', status_code: 401)
47
+ end
48
+
49
+ role_map = Routes::AuthKerberos.resolve_kerberos_role_map
50
+ mapped = Legion::Rbac::KerberosClaimsMapper.map_with_fallback(
51
+ principal: auth_result[:principal],
52
+ groups: auth_result[:groups] || [],
53
+ role_map: role_map
54
+ )
55
+
56
+ ttl = 28_800
57
+ legion_token = Legion::API::Token.issue_human_token(
58
+ msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl
59
+ )
60
+
61
+ output_token = auth_result[:output_token]
62
+ headers['WWW-Authenticate'] = "Negotiate #{output_token}" if output_token
63
+
64
+ json_response({
65
+ token: legion_token,
66
+ principal: auth_result[:principal],
67
+ roles: mapped[:roles],
68
+ auth_method: 'kerberos'
69
+ })
70
+ end
71
+ end
72
+
73
+ class << self
74
+ private :register_negotiate
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -4,11 +4,12 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Middleware
6
6
  class Auth
7
- SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
8
- /api/auth/authorize /api/auth/callback].freeze
9
- AUTH_HEADER = 'HTTP_AUTHORIZATION'
10
- BEARER_PATTERN = /\ABearer\s+(.+)\z/i
11
- API_KEY_HEADER = 'HTTP_X_API_KEY'
7
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
8
+ /api/auth/authorize /api/auth/callback /api/auth/negotiate].freeze
9
+ AUTH_HEADER = 'HTTP_AUTHORIZATION'
10
+ BEARER_PATTERN = /\ABearer\s+(.+)\z/i
11
+ NEGOTIATE_PATTERN = /\ANegotiate\s+(.+)\z/i
12
+ API_KEY_HEADER = 'HTTP_X_API_KEY'
12
13
 
13
14
  def initialize(app, opts = {})
14
15
  @app = app
@@ -21,6 +22,10 @@ module Legion
21
22
  return @app.call(env) unless @enabled
22
23
  return @app.call(env) if skip_path?(env['PATH_INFO'])
23
24
 
25
+ # Try Negotiate/SPNEGO first (Kerberos)
26
+ result = try_negotiate(env)
27
+ return result if result
28
+
24
29
  # Try Bearer JWT first
25
30
  token = extract_token(env)
26
31
  if token
@@ -54,10 +59,76 @@ module Legion
54
59
 
55
60
  private
56
61
 
62
+ def try_negotiate(env)
63
+ negotiate_token = extract_negotiate_token(env)
64
+ return nil unless negotiate_token
65
+
66
+ negotiate_result = verify_negotiate(negotiate_token)
67
+ unless negotiate_result
68
+ return kerberos_available? ? unauthorized('Kerberos authentication failed') : nil
69
+ end
70
+
71
+ env['legion.auth'] = negotiate_result[:claims]
72
+ env['legion.auth_method'] = 'kerberos'
73
+ env['legion.owner_msid'] = negotiate_result[:claims][:sub]
74
+ status, app_headers, body = @app.call(env)
75
+ response_headers = app_headers.dup
76
+ response_headers['WWW-Authenticate'] = "Negotiate #{negotiate_result[:output_token]}" if negotiate_result[:output_token]
77
+ [status, response_headers, body]
78
+ end
79
+
57
80
  def skip_path?(path)
58
81
  SKIP_PATHS.any? { |p| path.start_with?(p) }
59
82
  end
60
83
 
84
+ def extract_negotiate_token(env)
85
+ header = env[AUTH_HEADER]
86
+ return nil unless header
87
+
88
+ match = header.match(NEGOTIATE_PATTERN)
89
+ match&.captures&.first
90
+ end
91
+
92
+ def verify_negotiate(token)
93
+ return nil unless kerberos_available?
94
+
95
+ client = Legion::Extensions::Kerberos::Client.new
96
+ auth_result = client.authenticate(token: token)
97
+ return nil unless auth_result[:success]
98
+
99
+ claims = Legion::Rbac::KerberosClaimsMapper.map_with_fallback(
100
+ principal: auth_result[:principal],
101
+ groups: auth_result[:groups] || [],
102
+ role_map: kerberos_role_map,
103
+ fallback: kerberos_fallback
104
+ )
105
+
106
+ { claims: claims, output_token: auth_result[:output_token] }
107
+ rescue StandardError
108
+ nil
109
+ end
110
+
111
+ def kerberos_available?
112
+ defined?(Legion::Extensions::Kerberos::Client) &&
113
+ defined?(Legion::Rbac::KerberosClaimsMapper)
114
+ end
115
+
116
+ def kerberos_role_map
117
+ return {} unless defined?(Legion::Settings)
118
+
119
+ Legion::Settings.dig(:kerberos, :role_map) || {}
120
+ rescue StandardError
121
+ {}
122
+ end
123
+
124
+ def kerberos_fallback
125
+ return :entra unless defined?(Legion::Settings)
126
+
127
+ Legion::Settings.dig(:kerberos, :fallback) || :entra
128
+ rescue StandardError
129
+ :entra
130
+ end
131
+
61
132
  def extract_api_key(env)
62
133
  env[API_KEY_HEADER]
63
134
  end
data/lib/legion/api.rb CHANGED
@@ -29,6 +29,7 @@ require_relative 'api/rbac'
29
29
  require_relative 'api/auth'
30
30
  require_relative 'api/auth_worker'
31
31
  require_relative 'api/auth_human'
32
+ require_relative 'api/auth_kerberos'
32
33
  require_relative 'api/capacity'
33
34
  require_relative 'api/audit'
34
35
  require_relative 'api/metrics'
@@ -103,6 +104,7 @@ module Legion
103
104
  register Routes::Auth
104
105
  register Routes::AuthWorker
105
106
  register Routes::AuthHuman
107
+ register Routes::AuthKerberos
106
108
  register Routes::Capacity
107
109
  register Routes::Audit
108
110
  register Routes::Metrics
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require 'uri'
5
+ require 'fileutils'
4
6
 
5
7
  module Legion
6
8
  module CLI
@@ -72,6 +74,38 @@ module Legion
72
74
  out.json({ authenticated: true, scopes: scopes, expires_in: body['expires_in'] })
73
75
  end
74
76
 
77
+ desc 'kerberos', 'Authenticate using Kerberos TGT from your workstation'
78
+ method_option :api_url, type: :string, desc: 'Legion API base URL'
79
+ method_option :realm, type: :string, desc: 'Kerberos realm override'
80
+ def kerberos
81
+ klist_output = `klist 2>&1`
82
+ unless $CHILD_STATUS&.success?
83
+ say 'No Kerberos ticket found. Run kinit first or check your domain connection.', :red
84
+ return
85
+ end
86
+
87
+ principal_match = klist_output.match(/Principal:\s+(\S+)/)
88
+ unless principal_match
89
+ say 'Could not detect Kerberos principal from klist output.', :red
90
+ return
91
+ end
92
+
93
+ principal = principal_match[1]
94
+ realm = options[:realm] || principal.split('@', 2).last
95
+ say 'Detected Kerberos ticket:', :green
96
+ say " Principal: #{principal}"
97
+ say " Realm: #{realm}"
98
+
99
+ api_url = resolve_api_url
100
+ say "Authenticating to #{api_url}..."
101
+
102
+ token = build_spnego_token(api_url)
103
+ response = send_negotiate_request(api_url, token)
104
+ handle_negotiate_response(response)
105
+ rescue StandardError => e
106
+ say "Kerberos auth error: #{e.message}", :red
107
+ end
108
+
75
109
  default_task :teams
76
110
 
77
111
  no_commands do
@@ -81,6 +115,56 @@ module Legion
81
115
  color: !options[:no_color]
82
116
  )
83
117
  end
118
+
119
+ def resolve_api_url
120
+ url = options[:api_url]
121
+ url ||= Legion::Settings.dig(:api, :url) if defined?(Legion::Settings)
122
+ url || 'http://127.0.0.1:4567'
123
+ end
124
+
125
+ def build_spnego_token(api_url)
126
+ require 'gssapi'
127
+ require 'base64'
128
+ host = ::URI.parse(api_url).host
129
+ spnego = GSSAPI::Simple.new(host, 'HTTP')
130
+ ::Base64.strict_encode64(spnego.init_context)
131
+ end
132
+
133
+ def send_negotiate_request(api_url, token)
134
+ require 'net/http'
135
+ uri = ::URI.parse("#{api_url}/api/auth/negotiate")
136
+ http = ::Net::HTTP.new(uri.host, uri.port)
137
+ request = ::Net::HTTP::Get.new(uri.request_uri)
138
+ request['Authorization'] = "Negotiate #{token}"
139
+ http.request(request)
140
+ end
141
+
142
+ def handle_negotiate_response(response)
143
+ if response.code.to_i == 200
144
+ body = ::JSON.parse(response.body) rescue {} # rubocop:disable Style/RescueModifier
145
+ token_val = body.is_a?(Hash) ? (body['token'] || body.dig('data', 'token')) : nil
146
+ if token_val
147
+ save_credentials(token_val)
148
+ roles = body['roles'] || body.dig('data', 'roles') || []
149
+ say " Roles: #{Array(roles).join(', ')}", :green
150
+ say ' Token saved to ~/.legionio/credentials', :green
151
+ say 'Login successful (kerberos)', :green
152
+ else
153
+ say 'Authentication succeeded but no token in response', :yellow
154
+ end
155
+ else
156
+ say "Authentication failed: HTTP #{response.code}", :red
157
+ say response.body.to_s, :red
158
+ end
159
+ end
160
+
161
+ def save_credentials(token_val)
162
+ credentials_dir = ::File.join(::Dir.home, '.legionio')
163
+ ::FileUtils.mkdir_p(credentials_dir)
164
+ cred_path = ::File.join(credentials_dir, 'credentials')
165
+ ::File.write(cred_path, token_val)
166
+ ::File.chmod(0o600, cred_path)
167
+ end
84
168
  end
85
169
  end
86
170
  end
@@ -13,15 +13,32 @@ module Legion
13
13
  @running = []
14
14
  @mutex = Mutex.new
15
15
  @max_concurrency = MAX_CONCURRENCY
16
+ @timeout = TIMEOUT
16
17
 
17
18
  class << self
18
- attr_accessor :max_concurrency
19
+ attr_accessor :max_concurrency, :timeout
19
20
 
20
- def configure(max_concurrency: MAX_CONCURRENCY)
21
+ def configure(max_concurrency: MAX_CONCURRENCY, timeout: TIMEOUT)
21
22
  @max_concurrency = max_concurrency
23
+ @timeout = timeout
22
24
  @running = []
23
25
  end
24
26
 
27
+ def configure_from_settings
28
+ mc = begin
29
+ Legion::Settings.dig(:chat, :subagent, :max_concurrency)
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ to = begin
34
+ Legion::Settings.dig(:chat, :subagent, :timeout)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ @max_concurrency = mc || MAX_CONCURRENCY
39
+ @timeout = to || TIMEOUT
40
+ end
41
+
25
42
  def spawn(task:, model: nil, provider: nil, on_complete: nil)
26
43
  return { error: "Max concurrency reached (#{@max_concurrency}). Wait for a subagent to finish." } if at_capacity?
27
44
 
@@ -54,7 +71,7 @@ module Legion
54
71
  @mutex.synchronize { @running.length >= @max_concurrency }
55
72
  end
56
73
 
57
- def wait_all(timeout: TIMEOUT)
74
+ def wait_all(timeout: @timeout || TIMEOUT)
58
75
  deadline = Time.now + timeout
59
76
  @running.each do |agent|
60
77
  remaining = deadline - Time.now
@@ -45,7 +45,7 @@ module Legion
45
45
  system_prompt = build_system_prompt
46
46
  @session = Chat::Session.new(
47
47
  chat: chat_obj, system_prompt: system_prompt,
48
- budget_usd: options[:max_budget_usd]
48
+ budget_usd: effective_budget
49
49
  )
50
50
  @indicator = Chat::StatusIndicator.new(@session) unless options[:json]
51
51
 
@@ -55,7 +55,7 @@ module Legion
55
55
 
56
56
  setup_notification_bridge
57
57
 
58
- chat_log.info "session started model=#{@session.model_id} incognito=#{options[:incognito]}"
58
+ chat_log.info "session started model=#{@session.model_id} incognito=#{incognito?}"
59
59
  out.banner(version: Legion::VERSION)
60
60
  puts
61
61
  puts out.dim(" Model: #{@session.model_id}")
@@ -80,7 +80,7 @@ module Legion
80
80
 
81
81
  desc 'prompt TEXT', 'Send a single prompt and exit (headless mode)'
82
82
  option :output_format, type: :string, default: 'text', desc: 'Output format: text, json'
83
- option :max_turns, type: :numeric, default: 10, desc: 'Maximum tool-use turns'
83
+ option :max_turns, type: :numeric, desc: 'Maximum tool-use turns (default: 10)'
84
84
  def prompt(text)
85
85
  out = formatter
86
86
  setup_chat_logger
@@ -94,7 +94,7 @@ module Legion
94
94
  system_prompt = build_system_prompt
95
95
  session = Chat::Session.new(
96
96
  chat: chat_obj, system_prompt: system_prompt,
97
- budget_usd: options[:max_budget_usd]
97
+ budget_usd: effective_budget
98
98
  )
99
99
 
100
100
  chat_log.info "headless prompt model=#{session.model_id} length=#{text.length}"
@@ -129,6 +129,24 @@ module Legion
129
129
  end
130
130
 
131
131
  no_commands do
132
+ def chat_setting(*keys)
133
+ Legion::Settings.dig(:chat, *keys)
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ def incognito?
139
+ options[:incognito] || chat_setting(:incognito) == true
140
+ end
141
+
142
+ def effective_budget
143
+ options[:max_budget_usd] || chat_setting(:max_budget_usd)
144
+ end
145
+
146
+ def effective_max_turns
147
+ options[:max_turns] || chat_setting(:headless, :max_turns) || 10
148
+ end
149
+
132
150
  def formatter
133
151
  @formatter ||= Output::Formatter.new(
134
152
  json: options[:json],
@@ -173,7 +191,12 @@ module Legion
173
191
  end
174
192
 
175
193
  def render_response(text, out)
176
- return text if options[:no_markdown] || options[:no_color]
194
+ markdown_enabled = if options[:no_markdown]
195
+ false
196
+ else
197
+ chat_setting(:markdown) != false
198
+ end
199
+ return text unless markdown_enabled && out.color_enabled
177
200
 
178
201
  require 'legion/cli/chat/markdown_renderer'
179
202
  Chat::MarkdownRenderer.render(text, color: out.color_enabled)
@@ -194,6 +217,8 @@ module Legion
194
217
  require 'legion/cli/chat/permissions'
195
218
  Chat::Permissions.mode = if options[:auto_approve]
196
219
  :auto_approve
220
+ elsif (setting = chat_setting(:permissions))
221
+ setting.to_sym
197
222
  else
198
223
  default
199
224
  end
@@ -201,8 +226,9 @@ module Legion
201
226
 
202
227
  def create_chat
203
228
  opts = {}
204
- opts[:model] = options[:model] if options[:model]
205
- opts[:provider] = options[:provider]&.to_sym if options[:provider]
229
+ opts[:model] = options[:model] || chat_setting(:model)
230
+ opts[:provider] = (options[:provider] || chat_setting(:provider))&.to_sym
231
+ opts.compact!
206
232
 
207
233
  require 'legion/cli/chat/tool_registry'
208
234
  chat = Legion::LLM.chat(**opts)
@@ -217,13 +243,11 @@ module Legion
217
243
  @extra_dirs = options[:add_dir] || []
218
244
  prompt = Chat::Context.to_system_prompt(Dir.pwd, extra_dirs: @extra_dirs)
219
245
 
220
- if options[:personality]
221
- @personality = options[:personality]
222
- case @personality
223
- when 'concise' then prompt += "\n\nBe extremely concise. Short answers, minimal explanation. Code over prose."
224
- when 'verbose' then prompt += "\n\nBe thorough and detailed. Explain your reasoning step by step."
225
- when 'educational' then prompt += "\n\nBe educational. Explain concepts, provide context, teach as you help."
226
- end
246
+ @personality = options[:personality] || chat_setting(:personality)
247
+ case @personality
248
+ when 'concise' then prompt += "\n\nBe extremely concise. Short answers, minimal explanation. Code over prose."
249
+ when 'verbose' then prompt += "\n\nBe thorough and detailed. Explain your reasoning step by step."
250
+ when 'educational' then prompt += "\n\nBe educational. Explain concepts, provide context, teach as you help."
227
251
  end
228
252
 
229
253
  prompt
@@ -335,7 +359,7 @@ module Legion
335
359
  end
336
360
 
337
361
  result = lines.join("\n")
338
- result.strip.empty? ? nil : result
362
+ result.strip.empty? ? '' : result
339
363
  rescue Interrupt
340
364
  raise if first_line
341
365
 
@@ -1014,7 +1038,7 @@ module Legion
1014
1038
 
1015
1039
  def auto_save_session(out)
1016
1040
  return if @auto_saved
1017
- return if options[:incognito]
1041
+ return if incognito?
1018
1042
  return unless @session
1019
1043
  return if @session.stats[:messages_sent].zero?
1020
1044
 
@@ -7,7 +7,7 @@ require 'net/http'
7
7
  module Legion
8
8
  module CLI
9
9
  module ConfigScaffold
10
- SUBSYSTEMS = %w[transport data cache crypt logging llm].freeze
10
+ SUBSYSTEMS = %w[transport data cache crypt logging llm chat].freeze
11
11
 
12
12
  ENV_DETECTIONS = {
13
13
  'AWS_BEARER_TOKEN_BEDROCK' => { subsystem: 'llm', provider: :bedrock, field: :bearer_token },
@@ -202,6 +202,19 @@ module Legion
202
202
  ollama: { enabled: false, base_url: 'http://localhost:11434' }
203
203
  }
204
204
  } }
205
+ when 'chat'
206
+ { chat: {
207
+ permissions: 'interactive',
208
+ model: nil,
209
+ provider: nil,
210
+ personality: nil,
211
+ markdown: true,
212
+ incognito: false,
213
+ max_budget_usd: nil,
214
+ subagent: { max_concurrency: 3, timeout: 300 },
215
+ headless: { max_turns: 10 },
216
+ notifications: { patterns: [] }
217
+ } }
205
218
  end
206
219
  end
207
220
 
@@ -344,6 +357,19 @@ module Legion
344
357
  ollama: { enabled: false, base_url: 'http://localhost:11434' }
345
358
  }
346
359
  } }
360
+ when 'chat'
361
+ { chat: {
362
+ permissions: 'interactive',
363
+ model: nil,
364
+ provider: nil,
365
+ personality: nil,
366
+ markdown: true,
367
+ incognito: false,
368
+ max_budget_usd: nil,
369
+ subagent: { max_concurrency: 3, timeout: 300 },
370
+ headless: { max_turns: 10 },
371
+ notifications: { patterns: [] }
372
+ } }
347
373
  end
348
374
  end
349
375
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'legion/cli/output'
5
+
6
+ module Legion
7
+ module CLI
8
+ class Tty < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+ class_option :config_dir, type: :string, desc: 'Config directory (~/.legionio/settings)'
15
+ class_option :skip_rain, type: :boolean, default: false, desc: 'Skip the digital rain intro'
16
+
17
+ default_task :interactive
18
+
19
+ desc 'interactive', 'Launch the rich terminal UI (default)'
20
+ long_desc <<~DESC
21
+ Launches the Legion TTY - a rich terminal interface with:
22
+ - Onboarding wizard (first run)
23
+ - AI chat shell with streaming responses
24
+ - Operational dashboard (Ctrl+D or /dashboard)
25
+ - Session persistence across runs
26
+
27
+ Similar to tools like Claude Code (CLI) and OpenAI Codex,
28
+ but purpose-built for LegionIO's async cognition engine.
29
+
30
+ First run: walks you through identity detection (Kerberos/GitHub),
31
+ provider selection, and API key setup.
32
+
33
+ Subsequent runs: loads saved identity, re-scans environment,
34
+ and drops straight into the chat shell.
35
+ DESC
36
+ def interactive
37
+ require_tty_gem
38
+ config_dir = options[:config_dir] || Legion::TTY::App::CONFIG_DIR
39
+ app = Legion::TTY::App.new(config_dir: config_dir)
40
+ app.start
41
+ rescue Interrupt
42
+ app&.shutdown
43
+ end
44
+
45
+ desc 'reset', 'Clear saved identity and credentials (re-run onboarding)'
46
+ option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation'
47
+ def reset
48
+ out = formatter
49
+ config_dir = options[:config_dir] || File.expand_path('~/.legionio/settings')
50
+
51
+ identity = File.join(config_dir, 'identity.json')
52
+ credentials = File.join(config_dir, 'credentials.json')
53
+
54
+ unless options[:confirm]
55
+ out.warn('This will delete your saved identity and credentials.')
56
+ out.warn('You will need to re-run onboarding.')
57
+ require 'tty-prompt'
58
+ prompt = ::TTY::Prompt.new
59
+ return unless prompt.yes?('Continue?')
60
+ end
61
+
62
+ [identity, credentials].each do |path|
63
+ if File.exist?(path)
64
+ File.delete(path)
65
+ out.success("Deleted #{File.basename(path)}")
66
+ end
67
+ end
68
+ end
69
+
70
+ desc 'sessions', 'List saved chat sessions'
71
+ def sessions
72
+ out = formatter
73
+ require_tty_gem
74
+
75
+ store = Legion::TTY::SessionStore.new
76
+ list = store.list
77
+
78
+ if list.empty?
79
+ out.detail('No saved sessions.')
80
+ return
81
+ end
82
+
83
+ list.each do |session|
84
+ name = session[:name]
85
+ count = session[:message_count]
86
+ saved = session[:saved_at] || 'unknown'
87
+ puts " #{name.ljust(30)} #{count} messages #{saved}"
88
+ end
89
+ end
90
+
91
+ desc 'version', 'Show legion-tty version'
92
+ def version
93
+ require_tty_gem
94
+ puts "legion-tty #{Legion::TTY::VERSION}"
95
+ end
96
+
97
+ no_commands do
98
+ def formatter
99
+ @formatter ||= Output::Formatter.new(
100
+ json: false,
101
+ color: !options[:no_color]
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def require_tty_gem
108
+ require 'legion/tty'
109
+ rescue LoadError => e
110
+ formatter.error("legion-tty gem not installed: #{e.message}")
111
+ formatter.detail('Install with: gem install legion-tty')
112
+ raise SystemExit, 1
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/legion/cli.rb CHANGED
@@ -41,6 +41,7 @@ module Legion
41
41
  autoload :Cost, 'legion/cli/cost_command'
42
42
  autoload :Marketplace, 'legion/cli/marketplace_command'
43
43
  autoload :Notebook, 'legion/cli/notebook_command'
44
+ autoload :Tty, 'legion/cli/tty_command'
44
45
 
45
46
  class Main < Thor
46
47
  def self.exit_on_failure?
@@ -227,6 +228,9 @@ module Legion
227
228
  desc 'notebook', 'Read and export Jupyter notebooks'
228
229
  subcommand 'notebook', Legion::CLI::Notebook
229
230
 
231
+ desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
232
+ subcommand 'tty', Legion::CLI::Tty
233
+
230
234
  desc 'tree', 'Print a tree of all available commands'
231
235
  def tree
232
236
  legion_print_command_tree(self.class, 'legion', '')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.59'
4
+ VERSION = '1.4.61'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.59
4
+ version: 1.4.61
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -319,6 +319,7 @@ files:
319
319
  - lib/legion/api/audit.rb
320
320
  - lib/legion/api/auth.rb
321
321
  - lib/legion/api/auth_human.rb
322
+ - lib/legion/api/auth_kerberos.rb
322
323
  - lib/legion/api/auth_worker.rb
323
324
  - lib/legion/api/capacity.rb
324
325
  - lib/legion/api/chains.rb
@@ -479,6 +480,7 @@ files:
479
480
  - lib/legion/cli/theme.rb
480
481
  - lib/legion/cli/trace_command.rb
481
482
  - lib/legion/cli/trigger.rb
483
+ - lib/legion/cli/tty_command.rb
482
484
  - lib/legion/cli/update_command.rb
483
485
  - lib/legion/cli/version.rb
484
486
  - lib/legion/cli/worker_command.rb