legionio 1.4.29 → 1.4.43

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: b82509c414c56ab775cbaa5879a5312e9d3fd69226eadae308c52f5cba2bff25
4
- data.tar.gz: c82590fb0283ee9db793c5b18751dbba8eb51c209351e2674a2ac542e875b718
3
+ metadata.gz: 51e90ef95b551c27c044e9f9dac0d784a529d4947835ec0d6501276f73281fb9
4
+ data.tar.gz: 533c1b9813327e486e33cb2c1d657907248517deb15935c68616a095be699280
5
5
  SHA512:
6
- metadata.gz: eff59d6453668b41bfa4047f77db11d02dc3fadacd59801d94fc1a7ec4af14598e30d817e0f075631b087a195e1715a68744a0e399fec728c0d426d8b3dc20e6
7
- data.tar.gz: 10f935f6b10a6db5ef4df40e84a487b5fac01a10bc4789a3eca257aed77294a804309eccca224c70b5a3b082de5e0338408464a2dacb1fc353ef43fa54e128fd
6
+ metadata.gz: 298d051c13e5e658eb572971863a7039d3c08bd168b40b2e6806a3139ccae7261ff29c916bb84ab34fd2fea6c2e6905615f8689e80f5a415feafe75cbf0f56c5
7
+ data.tar.gz: 1f0005c08f21b456e5d64923020e662efbe88a714514ad7bd27b59caa0efc05676551f8b87a150d0226aa1bc9237681d0688bb6b42f1430624ea5ba81e420903
data/.rubocop.yml CHANGED
@@ -36,6 +36,7 @@ Metrics/BlockLength:
36
36
  - 'lib/legion/cli/gaia_command.rb'
37
37
  - 'lib/legion/cli/schedule_command.rb'
38
38
  - 'lib/legion/cli/update_command.rb'
39
+ - 'lib/legion/api/auth.rb'
39
40
 
40
41
  Metrics/AbcSize:
41
42
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.43] - 2026-03-17
4
+
5
+ ### Fixed
6
+ - Auth token exchange route used `Legion::Settings.dig` which doesn't exist — replaced with bracket access
7
+ - Auth spec required `legion/rbac` gem directly — replaced with inline stub for standalone test execution
8
+
9
+ ## [1.4.42] - 2026-03-17
10
+
11
+ ### Added
12
+ - `POST /api/auth/token`: Entra ID token exchange endpoint (validates external JWT via JWKS, maps claims via EntraClaimsMapper, issues Legion token)
13
+
14
+ ## [1.4.41] - 2026-03-17
15
+
16
+ ### Added
17
+ - `Legion::CLI::LexTemplates`: extension template registry (basic, llm-agent, service-integration, scheduled-task, webhook-handler)
18
+ - `Legion::Docs::SiteGenerator`: documentation site generation from existing markdown files
19
+
20
+ ## [1.4.40] - 2026-03-17
21
+
22
+ ### Added
23
+ - `Legion::Guardrails`: embedding similarity and RAG relevancy safety checks
24
+ - `Legion::Context`: session/user tracking with thread-local `SessionContext`
25
+ - `Legion::Catalog`: AI catalog registration for MCP tools and workers
26
+
27
+ ## [1.4.39] - 2026-03-17
28
+
29
+ ### Added
30
+ - `Legion::Webhooks`: outbound webhook dispatcher with HMAC-SHA256 signing
31
+ - Webhook registration, delivery tracking, and dead letter queue
32
+ - API routes: `GET/POST/DELETE /api/webhooks`
33
+
34
+ ## [1.4.38] - 2026-03-17
35
+
36
+ ### Added
37
+ - `Legion::Isolation`: per-agent data and tool access enforcement with thread-local context
38
+ - `Isolation::Context`: tool allowlist, data filter, and risk tier per agent
39
+
40
+ ## [1.4.37] - 2026-03-17
41
+
42
+ ### Added
43
+ - `POST /api/channels/teams/webhook`: Bot Framework activity delivery to GAIA sensory buffer
44
+
45
+ ## [1.4.36] - 2026-03-17
46
+
47
+ ### Added
48
+ - `Audit::HashChain`: SHA-256 hash chain for tamper-evident audit records
49
+ - `Audit::SiemExport`: SIEM-compatible JSON and NDJSON export with integrity metadata
50
+ - `Audit::HashChain.verify_chain` validates hash chain between records
51
+
52
+ ## [1.4.35] - 2026-03-17
53
+
54
+ ### Added
55
+ - `Chat::Team`: multi-user context tracking with thread-local user, env detection
56
+ - `Chat::ProgressBar`: progress indicator for long-running operations with ETA
57
+ - `legion notebook read/export`: Jupyter notebook reading and export (markdown/script)
58
+
59
+ ## [1.4.34] - 2026-03-17
60
+
61
+ ### Added
62
+ - `Legion::Registry`: central extension metadata store with search, risk tier filtering, AIRB status
63
+ - `Legion::Sandbox`: capability-based extension sandboxing with enforcement toggle
64
+ - `Legion::Registry::SecurityScanner`: naming convention, checksum, and gemspec metadata validation
65
+ - `legion marketplace`: CLI for search, info, list, scan operations
66
+
67
+ ## [1.4.33] - 2026-03-17
68
+
69
+ ### Added
70
+ - `legion cost summary`: overall cost summary (today/week/month)
71
+ - `legion cost worker <id>`: per-worker cost breakdown
72
+ - `legion cost top`: top cost consumers ranked by spend
73
+ - `legion cost export`: export cost data as JSON or CSV
74
+ - `Legion::CLI::CostData::Client`: API client for cost data retrieval
75
+
76
+ ### Fixed
77
+ - `Connection.resolve_config_dir` spec now correctly stubs `~/.legionio/settings` path
78
+
79
+ ## [1.4.32] - 2026-03-17
80
+
81
+ ### Fixed
82
+ - `NotificationBridge` missing `require_relative 'notification_queue'` causing `NameError` on `legion chat`
83
+
84
+ ## [1.4.31] - 2026-03-16
85
+
86
+ ### Added
87
+ - Skills system: `.legion/skills/` and `~/.legionio/skills/` YAML frontmatter markdown files
88
+ - `Legion::Chat::Skills`: discovery, parsing, and find for skill files
89
+ - `/skill-name` invocation in chat resolves user-defined skills
90
+ - `legion skill list`, `legion skill show`, `legion skill create`, `legion skill run` CLI subcommands
91
+
92
+ ## [1.4.30] - 2026-03-16
93
+
94
+ ### Added
95
+ - `MCP::Auth`: token-based MCP authentication (JWT + API key)
96
+ - `MCP::ToolGovernance`: risk-tier-aware tool filtering and invocation audit
97
+ - `MCP.server_for(token:)` builds identity-scoped MCP server instances
98
+ - HTTP transport auth: Bearer token validation with 401 response on failure
99
+ - MCP settings: `mcp.auth.enabled`, `mcp.auth.allowed_api_keys`, `mcp.governance.enabled`, `mcp.governance.tool_risk_tiers`
100
+
3
101
  ## [1.4.29] - 2026-03-16
4
102
 
5
103
  ### 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.29
12
+ **Version**: 1.4.36
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -481,8 +481,10 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
481
481
  | `lib/legion/api/middleware/body_limit.rb` | BodyLimit: request body size limit (1MB max, returns 413) |
482
482
  | `lib/legion/api/middleware/rate_limit.rb` | RateLimit: sliding-window rate limiting with per-IP/agent/tenant tiers |
483
483
  | **MCP** | |
484
- | `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory |
485
- | `lib/legion/mcp/server.rb` | MCP::Server builder, TOOL_CLASSES array, instructions |
484
+ | `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory, `server_for(token:)` |
485
+ | `lib/legion/mcp/auth.rb` | MCP authentication: JWT + API key verification |
486
+ | `lib/legion/mcp/tool_governance.rb` | Risk-tier tool filtering and invocation audit |
487
+ | `lib/legion/mcp/server.rb` | MCP::Server builder, TOOL_CLASSES array, governance-aware build |
486
488
  | `lib/legion/digital_worker.rb` | DigitalWorker module entry point |
487
489
  | `lib/legion/digital_worker/lifecycle.rb` | Worker state machine |
488
490
  | `lib/legion/digital_worker/registry.rb` | In-process worker registry |
@@ -564,6 +566,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
564
566
  | `API::Routes::Relationships` | Fully implemented (backed by legion-data migration 013) |
565
567
  | `API::Routes::Chains` | 501 stub - no data model |
566
568
  | `API::Middleware::Auth` | JWT Bearer auth middleware — real token validation and API key (`X-API-Key` header) auth both implemented |
569
+ | `MCP::Auth` | JWT + API key authentication for MCP server (HTTP transport) |
570
+ | `MCP::ToolGovernance` | Risk-tier tool filtering + audit — disabled by default, opt-in via settings |
567
571
  | `legion-data` chains/relationships models | Not yet implemented |
568
572
 
569
573
  ## Rubocop Notes
@@ -577,7 +581,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
577
581
 
578
582
  ```bash
579
583
  bundle install
580
- bundle exec rspec # 997 examples, 0 failures
584
+ bundle exec rspec # 1088 examples, 0 failures
581
585
  bundle exec rubocop # 0 offenses
582
586
  ```
583
587
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Auth
7
+ def self.registered(app)
8
+ register_token_exchange(app)
9
+ end
10
+
11
+ def self.register_token_exchange(app) # rubocop:disable Metrics/MethodLength
12
+ app.post '/api/auth/token' do
13
+ body = parse_request_body
14
+ grant_type = body[:grant_type]
15
+ subject_token = body[:subject_token]
16
+
17
+ unless grant_type == 'urn:ietf:params:oauth:grant-type:token-exchange'
18
+ halt 400, json_error('unsupported_grant_type', 'expected urn:ietf:params:oauth:grant-type:token-exchange',
19
+ status_code: 400)
20
+ end
21
+
22
+ halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400) unless subject_token
23
+
24
+ unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
25
+ halt 501, json_error('jwks_validation_not_available', 'legion-crypt JWKS support not loaded',
26
+ status_code: 501)
27
+ end
28
+
29
+ rbac_settings = (Legion::Settings[:rbac].is_a?(Hash) && Legion::Settings[:rbac][:entra]) || {}
30
+ tenant_id = rbac_settings[:tenant_id]
31
+ halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500) unless tenant_id
32
+
33
+ jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys"
34
+ issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0"
35
+
36
+ begin
37
+ entra_claims = Legion::Crypt::JWT.verify_with_jwks(
38
+ subject_token, jwks_url: jwks_url, issuers: [issuer]
39
+ )
40
+ rescue Legion::Crypt::JWT::ExpiredTokenError
41
+ halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
42
+ rescue Legion::Crypt::JWT::InvalidTokenError => e
43
+ halt 401, json_error('invalid_token', e.message, status_code: 401)
44
+ rescue Legion::Crypt::JWT::Error => e
45
+ halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
46
+ end
47
+
48
+ unless defined?(Legion::Rbac::EntraClaimsMapper)
49
+ halt 501, json_error('claims_mapper_not_available', 'legion-rbac EntraClaimsMapper not loaded',
50
+ status_code: 501)
51
+ end
52
+
53
+ mapped = Legion::Rbac::EntraClaimsMapper.map_claims(
54
+ entra_claims,
55
+ role_map: rbac_settings[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP,
56
+ group_map: rbac_settings[:group_map] || {},
57
+ default_role: rbac_settings[:default_role] || 'worker'
58
+ )
59
+
60
+ ttl = 28_800
61
+ token = Legion::API::Token.issue_human_token(
62
+ msid: mapped[:sub], name: mapped[:name],
63
+ roles: mapped[:roles], ttl: ttl
64
+ )
65
+
66
+ json_response({
67
+ access_token: token,
68
+ token_type: 'Bearer',
69
+ expires_in: ttl,
70
+ roles: mapped[:roles],
71
+ team: mapped[:team]
72
+ })
73
+ end
74
+ end
75
+
76
+ class << self
77
+ private :register_token_exchange
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -12,6 +12,28 @@ module Legion
12
12
  json_response({ started: false }, status_code: 503)
13
13
  end
14
14
  end
15
+
16
+ app.post '/api/channels/teams/webhook' do
17
+ body = request.body.read
18
+ activity = Legion::JSON.load(body)
19
+
20
+ adapter = Routes::Gaia.teams_adapter
21
+ halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503) unless adapter
22
+
23
+ input_frame = adapter.translate_inbound(activity)
24
+ Legion::Gaia.sensory_buffer&.push(input_frame) if defined?(Legion::Gaia)
25
+
26
+ json_response({ status: 'accepted', frame_id: input_frame&.id })
27
+ end
28
+ end
29
+
30
+ def self.teams_adapter
31
+ return nil unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:channel_registry)
32
+ return nil unless Legion::Gaia.channel_registry
33
+
34
+ Legion::Gaia.channel_registry.adapter_for(:teams)
35
+ rescue StandardError
36
+ nil
15
37
  end
16
38
  end
17
39
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Webhooks
7
+ def self.registered(app)
8
+ app.get '/api/webhooks' do
9
+ json_response(Legion::Webhooks.list)
10
+ end
11
+
12
+ app.post '/api/webhooks' do
13
+ body = parse_request_body
14
+ result = Legion::Webhooks.register(
15
+ url: body[:url], secret: body[:secret],
16
+ event_types: body[:event_types] || ['*'],
17
+ max_retries: body[:max_retries] || 5
18
+ )
19
+ json_response(result, status_code: 201)
20
+ end
21
+
22
+ app.delete '/api/webhooks/:id' do
23
+ json_response(Legion::Webhooks.unregister(id: params[:id].to_i))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/legion/api.rb CHANGED
@@ -26,6 +26,7 @@ require_relative 'api/gaia'
26
26
  require_relative 'api/oauth'
27
27
  require_relative 'api/openapi'
28
28
  require_relative 'api/rbac'
29
+ require_relative 'api/auth'
29
30
  require_relative 'api/audit'
30
31
  require_relative 'api/metrics'
31
32
 
@@ -96,6 +97,7 @@ module Legion
96
97
  register Routes::Gaia
97
98
  register Routes::OAuth
98
99
  register Routes::Rbac
100
+ register Routes::Auth
99
101
  register Routes::Audit
100
102
  register Routes::Metrics
101
103
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Legion
6
+ module Audit
7
+ module HashChain
8
+ ALGORITHM = 'SHA256'
9
+ GENESIS_HASH = ('0' * 64).freeze
10
+ CANONICAL_FIELDS = %i[principal_id action resource source status detail created_at previous_hash].freeze
11
+
12
+ module_function
13
+
14
+ def compute_hash(record)
15
+ payload = canonical_payload(record)
16
+ OpenSSL::Digest.new(ALGORITHM).hexdigest(payload)
17
+ end
18
+
19
+ def canonical_payload(record)
20
+ CANONICAL_FIELDS.map { |f| "#{f}:#{record[f]}" }.join('|')
21
+ end
22
+
23
+ def verify_chain(records)
24
+ broken = []
25
+ records.each_cons(2) do |prev, curr|
26
+ broken << { id: curr[:id], expected: prev[:record_hash], got: curr[:previous_hash] } unless curr[:previous_hash] == prev[:record_hash]
27
+ end
28
+ { valid: broken.empty?, broken_links: broken, records_checked: records.size }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Audit
5
+ module SiemExport
6
+ module_function
7
+
8
+ def export_batch(records)
9
+ records.map do |r|
10
+ {
11
+ timestamp: r[:created_at],
12
+ source: 'legion',
13
+ event_type: r[:event_type] || 'audit',
14
+ principal: r[:principal_id],
15
+ action: r[:action],
16
+ resource: r[:resource],
17
+ status: r[:status],
18
+ detail: r[:detail],
19
+ integrity: {
20
+ record_hash: r[:record_hash],
21
+ previous_hash: r[:previous_hash],
22
+ algorithm: 'SHA256'
23
+ }
24
+ }
25
+ end
26
+ end
27
+
28
+ def to_ndjson(records)
29
+ export_batch(records).map { |r| Legion::JSON.dump(r) }.join("\n")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Legion
7
+ module Catalog
8
+ class << self
9
+ def register_tools(catalog_url:, api_key:)
10
+ tools = collect_mcp_tools
11
+ post_json("#{catalog_url}/api/tools", { tools: tools }, api_key)
12
+ end
13
+
14
+ def register_workers(catalog_url:, api_key:, workers:)
15
+ entries = workers.map do |w|
16
+ { id: w[:worker_id], status: w[:status], capabilities: w[:capabilities] || [] }
17
+ end
18
+ post_json("#{catalog_url}/api/workers", { workers: entries }, api_key)
19
+ end
20
+
21
+ def collect_mcp_tools
22
+ return [] unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:tools)
23
+
24
+ Legion::MCP.tools.map { |t| { name: t[:name], description: t[:description] } }
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ def post_json(url, body, api_key)
32
+ uri = URI(url)
33
+ req = Net::HTTP::Post.new(uri)
34
+ req['Authorization'] = "Bearer #{api_key}"
35
+ req['Content-Type'] = 'application/json'
36
+ req.body = Legion::JSON.dump(body)
37
+
38
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
39
+ http.request(req)
40
+ end
41
+ { status: response.code.to_i, body: response.body }
42
+ rescue StandardError => e
43
+ { error: e.message }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'notification_queue'
4
+
3
5
  module Legion
4
6
  module Chat
5
7
  class NotificationBridge
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Legion
6
+ module Chat
7
+ module Skills
8
+ SKILL_DIRS = ['.legion/skills', '~/.legionio/skills'].freeze
9
+
10
+ class << self
11
+ def discover
12
+ SKILL_DIRS.flat_map do |dir|
13
+ expanded = File.expand_path(dir)
14
+ next [] unless Dir.exist?(expanded)
15
+
16
+ Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
17
+ end
18
+ end
19
+
20
+ def find(name)
21
+ discover.find { |s| s[:name] == name.to_s }
22
+ end
23
+
24
+ def parse(path)
25
+ content = File.read(path)
26
+ return nil unless content.start_with?('---')
27
+
28
+ parts = content.split(/^---\s*$/, 3)
29
+ return nil if parts.size < 3
30
+
31
+ frontmatter = YAML.safe_load(parts[1], permitted_classes: [Symbol])
32
+ body = parts[2]&.strip
33
+
34
+ {
35
+ name: frontmatter['name'] || File.basename(path, '.md'),
36
+ description: frontmatter['description'] || '',
37
+ model: frontmatter['model'],
38
+ tools: Array(frontmatter['tools']),
39
+ prompt: body,
40
+ path: path
41
+ }
42
+ rescue StandardError => e
43
+ Legion::Logging.warn "Skill parse error #{path}: #{e.message}" if defined?(Legion::Logging)
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Chat
6
+ class ProgressBar
7
+ attr_reader :total, :current
8
+
9
+ def initialize(total:, label: '', width: 40, output: $stdout)
10
+ @total = [total, 1].max
11
+ @current = 0
12
+ @label = label
13
+ @width = width
14
+ @output = output
15
+ @start_time = Time.now
16
+ end
17
+
18
+ def advance(amount = 1)
19
+ @current = [@current + amount, @total].min
20
+ render
21
+ self
22
+ end
23
+
24
+ def finish
25
+ @current = @total
26
+ render
27
+ @output.puts
28
+ self
29
+ end
30
+
31
+ def percentage
32
+ (@current.to_f / @total * 100).round(1)
33
+ end
34
+
35
+ def elapsed
36
+ Time.now - @start_time
37
+ end
38
+
39
+ def eta
40
+ return 0 if @current.zero? || @current >= @total
41
+
42
+ (elapsed / @current * (@total - @current)).round
43
+ end
44
+
45
+ private
46
+
47
+ def render
48
+ filled = (@width * @current.to_f / @total).round
49
+ bar = ('#' * filled) + ('-' * [(@width - filled), 0].max)
50
+ @output.print "\r#{@label} [#{bar}] #{percentage}% (#{@current}/#{@total}) ETA: #{eta}s "
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Chat
6
+ module Team
7
+ class UserContext
8
+ attr_reader :user_id, :team_id, :display_name
9
+
10
+ def initialize(user_id:, team_id: nil, display_name: nil)
11
+ @user_id = user_id
12
+ @team_id = team_id
13
+ @display_name = display_name || user_id
14
+ end
15
+
16
+ def to_h
17
+ { user_id: user_id, team_id: team_id, display_name: display_name }
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def current_user
23
+ Thread.current[:legion_chat_user]
24
+ end
25
+
26
+ def with_user(context)
27
+ previous = Thread.current[:legion_chat_user]
28
+ Thread.current[:legion_chat_user] = context
29
+ yield
30
+ ensure
31
+ Thread.current[:legion_chat_user] = previous
32
+ end
33
+
34
+ def detect_user
35
+ user_id = ENV.fetch('LEGION_USER', ENV.fetch('USER', 'anonymous'))
36
+ team_id = ENV.fetch('LEGION_TEAM', nil)
37
+ UserContext.new(user_id: user_id, team_id: team_id)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -447,7 +447,13 @@ module Legion
447
447
  when '/dream'
448
448
  handle_dream_in_chat(out)
449
449
  else
450
- out.warn("Unknown command: #{cmd}. Type /help for available commands.")
450
+ require 'legion/chat/skills'
451
+ skill = Legion::Chat::Skills.find(cmd.delete_prefix('/'))
452
+ if skill
453
+ handle_skill(skill, args.first, out)
454
+ else
455
+ out.warn("Unknown command: #{cmd}. Type /help for available commands.")
456
+ end
451
457
  end
452
458
  true
453
459
  end
@@ -1104,6 +1110,18 @@ module Legion
1104
1110
  out.error("Dream failed: #{e.message}")
1105
1111
  end
1106
1112
 
1113
+ def handle_skill(skill, args_text, out)
1114
+ out.info("Running skill: #{skill[:name]}")
1115
+ user_input = args_text || ''
1116
+ system_prompt = skill[:prompt]
1117
+
1118
+ @session.chat.ask(user_input) do |msg|
1119
+ msg.system_prompt = system_prompt if system_prompt && msg.respond_to?(:system_prompt=)
1120
+ end
1121
+ rescue StandardError => e
1122
+ out.error("Skill error: #{e.message}")
1123
+ end
1124
+
1107
1125
  def api_port_for_chat
1108
1126
  4567
1109
1127
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Legion
6
+ module CLI
7
+ module CostData
8
+ class Client
9
+ def initialize(base_url: 'http://localhost:4567')
10
+ @base_url = base_url
11
+ end
12
+
13
+ def summary(period: 'month')
14
+ fetch("/api/costs/summary?period=#{period}") || default_summary
15
+ end
16
+
17
+ def worker_cost(worker_id)
18
+ fetch("/api/workers/#{worker_id}/value") || {}
19
+ end
20
+
21
+ def top_consumers(limit: 10)
22
+ workers = fetch('/api/workers') || []
23
+ workers = workers[:data] if workers.is_a?(Hash) && workers.key?(:data)
24
+ results = Array(workers).map do |w|
25
+ id = w[:worker_id] || w[:id]
26
+ cost = worker_cost(id)
27
+ { worker_id: id, cost: cost }
28
+ end
29
+ results.sort_by { |w| -(w.dig(:cost, :total_cost_usd) || 0) }.first(limit)
30
+ end
31
+
32
+ private
33
+
34
+ def fetch(path)
35
+ uri = URI("#{@base_url}#{path}")
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.open_timeout = 5
38
+ http.read_timeout = 5
39
+ response = http.request(Net::HTTP::Get.new(uri))
40
+ return nil unless response.is_a?(Net::HTTPSuccess)
41
+
42
+ Legion::JSON.load(response.body)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def default_summary
48
+ { today: 0.0, week: 0.0, month: 0.0, workers: 0 }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end