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 +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +2 -2
- data/lib/legion/api/auth_kerberos.rb +79 -0
- data/lib/legion/api/middleware/auth.rb +76 -5
- data/lib/legion/api.rb +2 -0
- data/lib/legion/cli/auth_command.rb +84 -0
- data/lib/legion/cli/chat/subagent.rb +20 -3
- data/lib/legion/cli/chat_command.rb +40 -16
- data/lib/legion/cli/config_scaffold.rb +27 -1
- data/lib/legion/cli/tty_command.rb +117 -0
- data/lib/legion/cli.rb +4 -0
- data/lib/legion/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c7e2aaea33b6be8c15f2059d61621a70e9e431957e4e574b8ff574d33d40045
|
|
4
|
+
data.tar.gz: 07ea8f95f7b2c5132f8fe7992ccb247d5cec1fe909f3ba927f42bf172fff22ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5648ed88592cb9eaaac03a285f3862812ff9dbd93ea3a0f8a2bd1ff400e7b2084c3d59e5b2b5fbbf0016ff05f4897291f25f59b62c543ef86b9806aeba6a377
|
|
7
|
+
data.tar.gz: 73b80071c84f14627df14ceca94a9f7201143ca47149244838d649387d82430cfffaf3a1998fcb92f91d6aecc73abc9a8ce151eb6ea626c8237b738554ca9965
|
data/.rubocop.yml
CHANGED
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.
|
|
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 #
|
|
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
|
|
8
|
-
|
|
9
|
-
AUTH_HEADER
|
|
10
|
-
BEARER_PATTERN
|
|
11
|
-
|
|
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:
|
|
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=#{
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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]
|
|
205
|
-
opts[:provider] = 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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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? ?
|
|
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
|
|
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', '')
|
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.4.
|
|
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
|