legionio 1.4.103 → 1.4.105

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: f036f54d059c1cdf1660a1ae08ed95b1fe97404d509123574508b9a6b36a1674
4
- data.tar.gz: 616cacd65849dcdf73095f946df27c1afd9af68f9ee5f1b292ef53ee06f4554c
3
+ metadata.gz: bc07c77ea1337b17cf9a4be55e6469e9273beaa182e11aa7afac08405fe70275
4
+ data.tar.gz: e1666f8f6255c3a5cfd418357435fc407d5261be55621ae2cd875e01590c8fb0
5
5
  SHA512:
6
- metadata.gz: 6681f51110762da3d76aa93b1e1ee2a92814c22e101842ef588f3a4d9492f585bdbf9bb4244df59c320c89d0dc992e17ba388b2ad56015a533fd7dec5411d378
7
- data.tar.gz: ef6398f327e82cc91396d26fc855a6472eff22ee5f12e3ad225fbbbc06ee6df92912ef5c376dc9c0c838e0b6108bb3dbc7066f0372c820bb8e740d677fa70923
6
+ metadata.gz: 893094d33f99257ec0c2066a77cb680bc44ad6bb952e0ac7fa8a2099729b2a9643043e02bbb07bf7f638e9e0f22f101c35527e3c15578c241aa796ec241b85d8
7
+ data.tar.gz: 59400eb852207387b9f7df6d22f2f3668ae52a2d5b578e935a11277622029096f51184f8212b7a13cb18a7f733067c652c57d6b09f0e3f6bd1ffc53cbce283f8
data/.rubocop.yml CHANGED
@@ -43,7 +43,9 @@ Metrics/BlockLength:
43
43
  - 'lib/legion/cli/detect_command.rb'
44
44
  - 'lib/legion/cli/prompt_command.rb'
45
45
  - 'lib/legion/cli/image_command.rb'
46
+ - 'lib/legion/cli/notebook_command.rb'
46
47
  - 'lib/legion/api/acp.rb'
48
+ - 'lib/legion/api/auth_saml.rb'
47
49
 
48
50
  Metrics/AbcSize:
49
51
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.105] - 2026-03-21
4
+
5
+ ### Added
6
+ - `Legion::API::Routes::AuthSaml` — SAML 2.0 SP authentication flow
7
+ - `GET /api/auth/saml/metadata` — generates SP metadata XML (delegates to `OneLogin::RubySaml::Metadata`)
8
+ - `GET /api/auth/saml/login` — initiates IdP redirect via `OneLogin::RubySaml::Authrequest`
9
+ - `POST /api/auth/saml/acs` — validates SAML assertion, extracts claims (nameid, email, displayName, groups), maps groups to Legion RBAC roles, and issues a Legion JWT
10
+ - Routes are only registered when `OneLogin::RubySaml` is defined and `auth.saml.enabled` is true
11
+ - Claims mapping delegates to `Legion::Rbac::ClaimsMapper.groups_to_roles` when available, falls back to `['worker']`
12
+ - Configuration via `Legion::Settings.dig(:auth, :saml)` — keys: `idp_sso_url`, `idp_cert`, `sp_entity_id`, `sp_acs_url`, `group_map`, `default_role`, `want_assertions_signed`, `want_assertions_encrypted`
13
+ - `Legion::Registry` review workflow: `submit_for_review`, `approve`, `reject`, `deprecate`, `pending_reviews`, `usage_stats` class methods
14
+ - `Legion::Registry::Entry` gains `status`, `review_notes`, `reject_reason`, `successor`, `sunset_date`, and timestamp fields; `deprecated?` and `pending_review?` predicates
15
+ - `legion marketplace submit NAME` — submit extension for review
16
+ - `legion marketplace review` — list extensions pending review
17
+ - `legion marketplace approve NAME [--notes TEXT]` — approve an extension
18
+ - `legion marketplace reject NAME [--reason TEXT]` — reject an extension
19
+ - `legion marketplace deprecate NAME [--successor NAME] [--sunset-date DATE]` — mark extension as deprecated
20
+ - `legion marketplace stats NAME` — show usage statistics (install count, active instances, downloads)
21
+ - `Legion::API::Routes::Marketplace` — full REST API: `GET /api/marketplace`, `GET /api/marketplace/:name`, `POST /api/marketplace/:name/submit`, `POST /api/marketplace/:name/approve`, `POST /api/marketplace/:name/reject`, `POST /api/marketplace/:name/deprecate`, `GET /api/marketplace/:name/stats`
22
+ - 123 new specs across registry, CLI, and API layers
23
+
24
+ ## [1.4.104] - 2026-03-21
25
+
26
+ ### Added
27
+ - `legion notebook read PATH` — parse and display a .ipynb notebook with Rouge syntax highlighting
28
+ - `legion notebook cells PATH` — list all cells with index numbers and line counts
29
+ - `legion notebook export PATH --format md|script` — export notebook to markdown or Python script
30
+ - `legion notebook create PATH --description "..."` — generate a new notebook from natural language via LLM (requires legion-llm)
31
+ - `Legion::Notebook::Parser` — parse .ipynb JSON into structured data (metadata, kernel, language, cells with outputs)
32
+ - `Legion::Notebook::Renderer` — display notebook cells in terminal with Rouge syntax highlighting
33
+ - `Legion::Notebook::Generator` — generate notebooks from natural language; strips LLM markdown fences; validates .ipynb structure
34
+
3
35
  ## [1.4.103] - 2026-03-21
4
36
 
5
37
  ### Added
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module AuthSaml
7
+ def self.registered(app)
8
+ return unless saml_enabled?
9
+
10
+ app.helpers do
11
+ define_method(:saml_settings) { Routes::AuthSaml.build_saml_settings }
12
+ end
13
+
14
+ register_metadata(app)
15
+ register_login(app)
16
+ register_acs(app)
17
+ end
18
+
19
+ def self.saml_enabled?
20
+ return false unless defined?(OneLogin::RubySaml)
21
+
22
+ cfg = resolve_saml_config
23
+ cfg.is_a?(Hash) && cfg[:enabled]
24
+ end
25
+
26
+ def self.resolve_saml_config
27
+ return {} unless defined?(Legion::Settings)
28
+
29
+ auth = Legion::Settings[:auth]
30
+ saml = auth.is_a?(Hash) ? auth[:saml] : nil
31
+ return saml if saml.is_a?(Hash)
32
+
33
+ {}
34
+ rescue StandardError
35
+ {}
36
+ end
37
+
38
+ def self.build_saml_settings
39
+ cfg = resolve_saml_config
40
+
41
+ settings = OneLogin::RubySaml::Settings.new
42
+ settings.idp_sso_service_url = cfg[:idp_sso_url]
43
+ settings.idp_cert = cfg[:idp_cert]
44
+ settings.sp_entity_id = cfg[:sp_entity_id]
45
+ settings.assertion_consumer_service_url = cfg[:sp_acs_url]
46
+ settings.name_identifier_format = cfg[:name_id_format] ||
47
+ 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
48
+ settings.security[:authn_requests_signed] = false
49
+ settings.security[:want_assertions_signed] = cfg.fetch(:want_assertions_signed, true)
50
+ settings.security[:want_assertions_encrypted] = cfg.fetch(:want_assertions_encrypted, false)
51
+ settings
52
+ end
53
+
54
+ def self.register_metadata(app)
55
+ app.get '/api/auth/saml/metadata' do
56
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
57
+
58
+ meta = OneLogin::RubySaml::Metadata.new
59
+ content_type 'application/xml'
60
+ meta.generate(saml_settings, true)
61
+ end
62
+ end
63
+
64
+ def self.register_login(app)
65
+ app.get '/api/auth/saml/login' do
66
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
67
+
68
+ cfg = Routes::AuthSaml.resolve_saml_config
69
+ unless cfg[:idp_sso_url] && cfg[:sp_entity_id]
70
+ halt 500, json_error('saml_misconfigured', 'auth.saml.idp_sso_url and sp_entity_id are required',
71
+ status_code: 500)
72
+ end
73
+
74
+ auth_request = OneLogin::RubySaml::Authrequest.new
75
+ redirect auth_request.create(saml_settings)
76
+ end
77
+ end
78
+
79
+ def self.register_acs(app)
80
+ app.post '/api/auth/saml/acs' do
81
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
82
+
83
+ unless params['SAMLResponse']
84
+ halt 400, json_error('missing_saml_response', 'SAMLResponse parameter is required',
85
+ status_code: 400)
86
+ end
87
+
88
+ response = OneLogin::RubySaml::Response.new(
89
+ params['SAMLResponse'],
90
+ settings: saml_settings
91
+ )
92
+
93
+ unless response.is_valid?
94
+ errors = response.errors.join(', ')
95
+ halt 401, json_error('saml_invalid', "SAML assertion is invalid: #{errors}", status_code: 401)
96
+ end
97
+
98
+ claims = Routes::AuthSaml.extract_claims(response)
99
+ roles = Routes::AuthSaml.map_roles(claims[:groups])
100
+
101
+ ttl = 28_800
102
+ token = Legion::API::Token.issue_human_token(
103
+ msid: claims[:nameid],
104
+ name: claims[:display_name],
105
+ roles: roles,
106
+ ttl: ttl
107
+ )
108
+
109
+ json_response({
110
+ access_token: token,
111
+ token_type: 'Bearer',
112
+ expires_in: ttl,
113
+ roles: roles,
114
+ name: claims[:display_name]
115
+ })
116
+ end
117
+ end
118
+
119
+ def self.extract_claims(response)
120
+ attrs = response.attributes
121
+
122
+ email = first_attr(attrs, 'email', 'mail', 'emailAddress',
123
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress')
124
+ display_name = first_attr(attrs, 'displayName', 'name',
125
+ 'http://schemas.microsoft.com/identity/claims/displayname',
126
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name')
127
+ groups = multi_attr(attrs, 'groups',
128
+ 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups')
129
+
130
+ {
131
+ nameid: response.nameid,
132
+ email: email,
133
+ display_name: display_name || email,
134
+ groups: groups
135
+ }
136
+ end
137
+
138
+ def self.map_roles(groups)
139
+ if defined?(Legion::Rbac::ClaimsMapper) && Legion::Rbac::ClaimsMapper.respond_to?(:groups_to_roles)
140
+ cfg = resolve_saml_config
141
+ group_map = cfg[:group_map] || {}
142
+ default_role = cfg[:default_role] || 'worker'
143
+ Legion::Rbac::ClaimsMapper.groups_to_roles(groups, group_map: group_map, default_role: default_role)
144
+ else
145
+ ['worker']
146
+ end
147
+ end
148
+
149
+ class << self
150
+ private
151
+
152
+ def first_attr(attrs, *names)
153
+ names.each do |n|
154
+ v = safe_attr(attrs, n)
155
+ return v if v
156
+ end
157
+ nil
158
+ end
159
+
160
+ def multi_attr(attrs, *names)
161
+ names.each do |n|
162
+ v = attrs.multi(n)
163
+ return Array(v) if v
164
+ rescue StandardError
165
+ nil
166
+ end
167
+ []
168
+ end
169
+
170
+ def safe_attr(attrs, name)
171
+ attrs[name]
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ private :register_metadata, :register_login, :register_acs
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'legion/registry'
5
+
6
+ module Legion
7
+ class API < Sinatra::Base
8
+ module Routes
9
+ module Marketplace
10
+ module Helpers
11
+ def parse_sunset_date(date_str)
12
+ return nil if date_str.nil? || date_str.empty?
13
+
14
+ Date.parse(date_str.to_s)
15
+ rescue ArgumentError
16
+ nil
17
+ end
18
+ end
19
+
20
+ def self.registered(app)
21
+ app.helpers Helpers
22
+ register_collection(app)
23
+ register_member(app)
24
+ register_review_actions(app)
25
+ register_stats(app)
26
+ end
27
+
28
+ def self.register_collection(app)
29
+ app.get '/api/marketplace' do
30
+ query = params[:q] || params[:query]
31
+ entries = query ? Legion::Registry.search(query) : Legion::Registry.all
32
+ entries = entries.select { |e| e.status.to_s == params[:status] } if params[:status]
33
+ entries = entries.select { |e| e.risk_tier == params[:tier] } if params[:tier]
34
+
35
+ paginated = entries.slice((page_offset)..(page_offset + page_limit - 1)) || []
36
+ content_type :json
37
+ status 200
38
+ Legion::JSON.dump({
39
+ data: paginated.map(&:to_h),
40
+ meta: response_meta.merge(total: entries.size, limit: page_limit, offset: page_offset)
41
+ })
42
+ end
43
+ end
44
+
45
+ def self.register_member(app)
46
+ app.get '/api/marketplace/:name' do
47
+ entry = Legion::Registry.lookup(params[:name])
48
+ unless entry
49
+ halt 404, { 'Content-Type' => 'application/json' },
50
+ Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
51
+ end
52
+
53
+ json_response(entry.to_h.merge(stats: Legion::Registry.usage_stats(params[:name])))
54
+ end
55
+ end
56
+
57
+ def self.register_review_actions(app) # rubocop:disable Metrics/AbcSize
58
+ app.post '/api/marketplace/:name/submit' do
59
+ begin
60
+ Legion::Registry.submit_for_review(params[:name])
61
+ rescue ArgumentError => e
62
+ halt 404, { 'Content-Type' => 'application/json' },
63
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
64
+ end
65
+ json_response({ name: params[:name], status: 'pending_review' }, status_code: 202)
66
+ end
67
+
68
+ app.post '/api/marketplace/:name/approve' do
69
+ body = parse_request_body
70
+ begin
71
+ Legion::Registry.approve(params[:name], notes: body[:notes])
72
+ rescue ArgumentError => e
73
+ halt 404, { 'Content-Type' => 'application/json' },
74
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
75
+ end
76
+ entry = Legion::Registry.lookup(params[:name])
77
+ json_response({ name: params[:name], status: 'approved', entry: entry.to_h })
78
+ end
79
+
80
+ app.post '/api/marketplace/:name/reject' do
81
+ body = parse_request_body
82
+ begin
83
+ Legion::Registry.reject(params[:name], reason: body[:reason])
84
+ rescue ArgumentError => e
85
+ halt 404, { 'Content-Type' => 'application/json' },
86
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
87
+ end
88
+ entry = Legion::Registry.lookup(params[:name])
89
+ json_response({ name: params[:name], status: 'rejected', entry: entry.to_h })
90
+ end
91
+
92
+ app.post '/api/marketplace/:name/deprecate' do
93
+ body = parse_request_body
94
+ sunset = begin
95
+ body[:sunset_date] ? Date.parse(body[:sunset_date].to_s) : nil
96
+ rescue ArgumentError
97
+ nil
98
+ end
99
+ begin
100
+ Legion::Registry.deprecate(params[:name], successor: body[:successor], sunset_date: sunset)
101
+ rescue ArgumentError => e
102
+ halt 404, { 'Content-Type' => 'application/json' },
103
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
104
+ end
105
+ entry = Legion::Registry.lookup(params[:name])
106
+ json_response({ name: params[:name], status: 'deprecated', entry: entry.to_h })
107
+ end
108
+ end
109
+
110
+ def self.register_stats(app)
111
+ app.get '/api/marketplace/:name/stats' do
112
+ data = Legion::Registry.usage_stats(params[:name])
113
+ unless data
114
+ halt 404, { 'Content-Type' => 'application/json' },
115
+ Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
116
+ end
117
+
118
+ json_response(data)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
data/lib/legion/api.rb CHANGED
@@ -30,6 +30,7 @@ require_relative 'api/rbac'
30
30
  require_relative 'api/auth'
31
31
  require_relative 'api/auth_worker'
32
32
  require_relative 'api/auth_human'
33
+ require_relative 'api/auth_saml'
33
34
  require_relative 'api/capacity'
34
35
  require_relative 'api/audit'
35
36
  require_relative 'api/metrics'
@@ -40,6 +41,7 @@ require_relative 'api/workflow'
40
41
  require_relative 'api/governance'
41
42
  require_relative 'api/acp'
42
43
  require_relative 'api/prompts'
44
+ require_relative 'api/marketplace'
43
45
 
44
46
  module Legion
45
47
  class API < Sinatra::Base
@@ -114,6 +116,7 @@ module Legion
114
116
  register Routes::Auth
115
117
  register Routes::AuthWorker
116
118
  register Routes::AuthHuman
119
+ register Routes::AuthSaml
117
120
  register Routes::Capacity
118
121
  register Routes::Audit
119
122
  register Routes::Metrics
@@ -123,6 +126,7 @@ module Legion
123
126
  register Routes::Governance
124
127
  register Routes::Acp
125
128
  register Routes::Prompts
129
+ register Routes::Marketplace
126
130
 
127
131
  use Legion::API::Middleware::RequestLogger
128
132
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
@@ -9,70 +9,285 @@ module Legion
9
9
  true
10
10
  end
11
11
 
12
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+
15
+ # ──────────────────────────────────────────────────────────
16
+ # search
17
+ # ──────────────────────────────────────────────────────────
18
+
12
19
  desc 'search QUERY', 'Search extension registry'
13
20
  def search(query)
14
21
  require 'legion/registry'
22
+ out = formatter
15
23
  results = Legion::Registry.search(query)
24
+
16
25
  if results.empty?
17
- say "No extensions found matching '#{query}'", :yellow
26
+ out.warn("No extensions found matching '#{query}'")
18
27
  return
19
28
  end
20
29
 
21
- say "Found #{results.size} extension(s):", :green
22
- results.each do |e|
23
- status = e.approved? ? '[approved]' : "[#{e.airb_status}]"
24
- say " #{e.name.ljust(25)} #{e.version.to_s.ljust(10)} #{status} #{e.description}"
30
+ if options[:json]
31
+ out.json(results.map(&:to_h))
32
+ else
33
+ rows = results.map do |e|
34
+ status_label = e.approved? ? 'approved' : (e.status || e.airb_status).to_s
35
+ [e.name, e.version.to_s, status_label, (e.description || '')[0..60]]
36
+ end
37
+ out.table(%w[Name Version Status Description], rows)
25
38
  end
26
39
  end
27
40
 
41
+ # ──────────────────────────────────────────────────────────
42
+ # info
43
+ # ──────────────────────────────────────────────────────────
44
+
28
45
  desc 'info NAME', 'Show extension details'
29
46
  def info(name)
30
47
  require 'legion/registry'
48
+ out = formatter
31
49
  entry = Legion::Registry.lookup(name)
50
+
32
51
  unless entry
33
- say "Extension '#{name}' not found", :red
52
+ out.error("Extension '#{name}' not found")
34
53
  return
35
54
  end
36
55
 
37
- entry.to_h.each { |k, v| say " #{k}: #{v}" }
56
+ if options[:json]
57
+ out.json(entry.to_h)
58
+ else
59
+ out.header("Extension: #{entry.name}")
60
+ out.spacer
61
+ out.detail(entry.to_h.compact)
62
+ end
38
63
  end
39
64
 
65
+ # ──────────────────────────────────────────────────────────
66
+ # list
67
+ # ──────────────────────────────────────────────────────────
68
+
40
69
  desc 'list', 'List all registered extensions'
41
70
  option :approved, type: :boolean, desc: 'Show only approved extensions'
42
- option :tier, type: :string, desc: 'Filter by risk tier'
71
+ option :tier, type: :string, desc: 'Filter by risk tier'
72
+ option :status, type: :string, desc: 'Filter by review status'
43
73
  def list
44
74
  require 'legion/registry'
45
- extensions = if options[:approved]
46
- Legion::Registry.approved
47
- elsif options[:tier]
48
- Legion::Registry.by_risk_tier(options[:tier])
49
- else
50
- Legion::Registry.all
51
- end
75
+ out = formatter
76
+ extensions = build_extension_list
52
77
 
53
78
  if extensions.empty?
54
- say 'No extensions registered', :yellow
79
+ out.warn('No extensions registered')
55
80
  return
56
81
  end
57
82
 
58
- say "#{extensions.size} extension(s):", :green
59
- extensions.each do |e|
60
- say " #{e.name.ljust(25)} #{e.version.to_s.ljust(10)} [#{e.risk_tier}]"
83
+ if options[:json]
84
+ out.json(extensions.map(&:to_h))
85
+ else
86
+ rows = extensions.map { |e| [e.name, e.version.to_s, e.status.to_s, e.risk_tier] }
87
+ out.table(%w[Name Version Status Tier], rows)
88
+ puts " #{extensions.size} extension(s)"
61
89
  end
62
90
  end
63
91
 
92
+ # ──────────────────────────────────────────────────────────
93
+ # scan
94
+ # ──────────────────────────────────────────────────────────
95
+
64
96
  desc 'scan NAME', 'Run security scan on extension'
65
97
  def scan(name)
66
98
  require 'legion/registry/security_scanner'
99
+ out = formatter
67
100
  scanner = Legion::Registry::SecurityScanner.new
68
- result = scanner.scan(name: name)
101
+ result = scanner.scan(name: name)
69
102
 
70
- result[:checks].each do |check|
71
- color = check[:status] == :fail ? :red : :green
72
- say " #{check[:check]}: #{check[:status]} - #{check[:details]}", color
103
+ if options[:json]
104
+ out.json(result)
105
+ else
106
+ result[:checks].each do |check|
107
+ color = check[:status] == :fail ? :critical : :nominal
108
+ puts " #{out.colorize(check[:check].to_s.ljust(25), color)} #{check[:status]} - #{check[:details]}"
109
+ end
110
+ if result[:passed]
111
+ out.success('Scan PASSED')
112
+ else
113
+ out.error('Scan FAILED')
114
+ end
73
115
  end
116
+ end
117
+
118
+ # ──────────────────────────────────────────────────────────
119
+ # submit
120
+ # ──────────────────────────────────────────────────────────
121
+
122
+ desc 'submit NAME', 'Submit extension for review'
123
+ def submit(name)
124
+ require 'legion/registry'
125
+ out = formatter
126
+
127
+ Legion::Registry.submit_for_review(name)
128
+
129
+ if options[:json]
130
+ out.json(success: true, name: name, status: 'pending_review')
131
+ else
132
+ out.success("'#{name}' submitted for review")
133
+ end
134
+ rescue ArgumentError => e
135
+ out.error(e.message)
136
+ end
74
137
 
75
- say result[:passed] ? 'PASSED' : 'FAILED', result[:passed] ? :green : :red
138
+ # ──────────────────────────────────────────────────────────
139
+ # review
140
+ # ──────────────────────────────────────────────────────────
141
+
142
+ desc 'review', 'List extensions pending review'
143
+ def review
144
+ require 'legion/registry'
145
+ out = formatter
146
+ pending = Legion::Registry.pending_reviews
147
+
148
+ if pending.empty?
149
+ out.warn('No extensions pending review')
150
+ return
151
+ end
152
+
153
+ if options[:json]
154
+ out.json(pending.map(&:to_h))
155
+ else
156
+ rows = pending.map { |e| [e.name, e.version.to_s, e.author.to_s, e.submitted_at.to_s] }
157
+ out.table(%w[Name Version Author Submitted], rows)
158
+ puts " #{pending.size} pending review(s)"
159
+ end
160
+ end
161
+
162
+ # ──────────────────────────────────────────────────────────
163
+ # approve
164
+ # ──────────────────────────────────────────────────────────
165
+
166
+ desc 'approve NAME', 'Approve an extension'
167
+ option :notes, type: :string, desc: 'Reviewer notes'
168
+ def approve(name)
169
+ require 'legion/registry'
170
+ out = formatter
171
+
172
+ Legion::Registry.approve(name, notes: options[:notes])
173
+
174
+ if options[:json]
175
+ out.json(success: true, name: name, status: 'approved')
176
+ else
177
+ out.success("'#{name}' approved")
178
+ out.detail({ 'Notes' => options[:notes] }) if options[:notes]
179
+ end
180
+ rescue ArgumentError => e
181
+ out.error(e.message)
182
+ end
183
+
184
+ # ──────────────────────────────────────────────────────────
185
+ # reject
186
+ # ──────────────────────────────────────────────────────────
187
+
188
+ desc 'reject NAME', 'Reject an extension'
189
+ option :reason, type: :string, desc: 'Rejection reason'
190
+ def reject(name)
191
+ require 'legion/registry'
192
+ out = formatter
193
+
194
+ Legion::Registry.reject(name, reason: options[:reason])
195
+
196
+ if options[:json]
197
+ out.json(success: true, name: name, status: 'rejected')
198
+ else
199
+ out.success("'#{name}' rejected")
200
+ out.detail({ 'Reason' => options[:reason] }) if options[:reason]
201
+ end
202
+ rescue ArgumentError => e
203
+ out.error(e.message)
204
+ end
205
+
206
+ # ──────────────────────────────────────────────────────────
207
+ # deprecate
208
+ # ──────────────────────────────────────────────────────────
209
+
210
+ desc 'deprecate NAME', 'Mark an extension as deprecated'
211
+ option :successor, type: :string, desc: 'Replacement extension name'
212
+ option :sunset_date, type: :string, desc: 'Sunset date (YYYY-MM-DD)'
213
+ def deprecate(name)
214
+ require 'legion/registry'
215
+ out = formatter
216
+
217
+ sunset = parse_sunset_date(options[:sunset_date])
218
+ Legion::Registry.deprecate(name, successor: options[:successor], sunset_date: sunset)
219
+
220
+ if options[:json]
221
+ out.json(success: true, name: name, status: 'deprecated',
222
+ successor: options[:successor], sunset_date: options[:sunset_date])
223
+ else
224
+ out.success("'#{name}' marked as deprecated")
225
+ detail_hash = {}
226
+ detail_hash['Successor'] = options[:successor] if options[:successor]
227
+ detail_hash['Sunset Date'] = options[:sunset_date] if options[:sunset_date]
228
+ out.detail(detail_hash) unless detail_hash.empty?
229
+ end
230
+ rescue ArgumentError => e
231
+ out.error(e.message)
232
+ end
233
+
234
+ # ──────────────────────────────────────────────────────────
235
+ # stats
236
+ # ──────────────────────────────────────────────────────────
237
+
238
+ desc 'stats NAME', 'Show usage statistics for an extension'
239
+ def stats(name)
240
+ require 'legion/registry'
241
+ out = formatter
242
+ data = Legion::Registry.usage_stats(name)
243
+
244
+ unless data
245
+ out.error("Extension '#{name}' not found")
246
+ return
247
+ end
248
+
249
+ if options[:json]
250
+ out.json(data)
251
+ else
252
+ out.header("Usage Stats: #{name}")
253
+ out.spacer
254
+ out.detail({
255
+ 'Install Count' => data[:install_count].to_s,
256
+ 'Active Instances' => data[:active_instances].to_s,
257
+ 'Downloads (7d)' => data[:downloads_7d].to_s,
258
+ 'Downloads (30d)' => data[:downloads_30d].to_s,
259
+ 'Last Updated' => data[:last_updated].to_s
260
+ })
261
+ end
262
+ end
263
+
264
+ no_commands do
265
+ def formatter
266
+ @formatter ||= Output::Formatter.new(
267
+ json: options[:json],
268
+ color: !options[:no_color]
269
+ )
270
+ end
271
+
272
+ def build_extension_list
273
+ if options[:approved]
274
+ Legion::Registry.approved
275
+ elsif options[:tier]
276
+ Legion::Registry.by_risk_tier(options[:tier])
277
+ elsif options[:status]
278
+ Legion::Registry.all.select { |e| e.status.to_s == options[:status] }
279
+ else
280
+ Legion::Registry.all
281
+ end
282
+ end
283
+
284
+ def parse_sunset_date(date_str)
285
+ return nil if date_str.nil? || date_str.empty?
286
+
287
+ Date.parse(date_str)
288
+ rescue ArgumentError
289
+ nil
290
+ end
76
291
  end
77
292
  end
78
293
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require 'json'
5
+ require 'legion/cli/output'
6
+ require 'legion/cli/error'
7
+ require 'legion/cli/connection'
4
8
 
5
9
  module Legion
6
10
  module CLI
@@ -9,60 +13,207 @@ module Legion
9
13
  true
10
14
  end
11
15
 
12
- desc 'read PATH', 'Read and display a Jupyter notebook'
16
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
17
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
18
+ class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging'
19
+ class_option :config_dir, type: :string, desc: 'Config directory path'
20
+
21
+ desc 'read PATH', 'Parse and display a Jupyter notebook with syntax highlighting'
13
22
  def read(path)
14
- nb = parse_notebook(path)
15
- cells = nb['cells'] || []
16
-
17
- cells.each_with_index do |cell, i|
18
- type = cell['cell_type'] || 'unknown'
19
- source = Array(cell['source']).join
20
- say "--- Cell #{i + 1} [#{type}] ---", :yellow
21
- say source
22
- say ''
23
+ out = formatter
24
+ load_notebook(path, out)
25
+ color = !options[:no_color]
26
+
27
+ require 'legion/notebook/parser'
28
+ require 'legion/notebook/renderer'
29
+
30
+ parsed = Legion::Notebook::Parser.parse(path)
31
+ rendered = Legion::Notebook::Renderer.render_notebook(parsed, color: color)
32
+
33
+ if options[:json]
34
+ out.json(cells: parsed[:cells].length, kernel: parsed[:kernel], path: path)
35
+ else
36
+ puts rendered
37
+ out.spacer
38
+ count = parsed[:cells].length
39
+ puts "#{count} cell#{'s' unless count == 1} total"
23
40
  end
24
- say "#{cells.size} cells total", :green
41
+ rescue CLI::Error => e
42
+ formatter.error(e.message)
43
+ raise SystemExit, 1
25
44
  end
26
45
 
27
- desc 'export PATH', 'Export notebook cells as markdown or script'
28
- option :format, type: :string, default: 'markdown', enum: %w[markdown script]
29
- def export(path)
30
- nb = parse_notebook(path)
31
- cells = nb['cells'] || []
32
- lang = nb.dig('metadata', 'kernelspec', 'language') || 'python'
33
-
34
- case options[:format]
35
- when 'script'
36
- cells.select { |c| c['cell_type'] == 'code' }.each do |cell|
37
- say Array(cell['source']).join
38
- say ''
46
+ desc 'cells PATH', 'List all cells with index numbers and types'
47
+ def cells(path)
48
+ out = formatter
49
+ load_notebook(path, out)
50
+
51
+ require 'legion/notebook/parser'
52
+
53
+ parsed = Legion::Notebook::Parser.parse(path)
54
+ color = !options[:no_color]
55
+
56
+ if options[:json]
57
+ cell_list = parsed[:cells].each_with_index.map do |cell, i|
58
+ { index: i + 1, type: cell[:type], lines: cell[:source].lines.count }
39
59
  end
60
+ out.json(cells: cell_list, total: parsed[:cells].length)
40
61
  else
41
- cells.each do |cell|
42
- if cell['cell_type'] == 'code'
43
- say "```#{lang}"
44
- say Array(cell['source']).join
45
- say '```'
62
+ parsed[:cells].each_with_index do |cell, i|
63
+ lines = cell[:source].lines.count
64
+ plural = lines == 1 ? '' : 's'
65
+ label = " [#{(i + 1).to_s.rjust(2)}] #{cell[:type].to_s.ljust(8)} #{lines} line#{plural}"
66
+ if color
67
+ type_color = cell[:type] == 'code' ? "\e[36m" : "\e[33m"
68
+ puts "#{type_color}#{label}\e[0m"
46
69
  else
47
- say Array(cell['source']).join
70
+ puts label
48
71
  end
49
- say ''
50
72
  end
73
+ out.spacer
74
+ puts "Total: #{parsed[:cells].length} cell#{'s' unless parsed[:cells].length == 1}"
51
75
  end
76
+ rescue CLI::Error => e
77
+ formatter.error(e.message)
78
+ raise SystemExit, 1
52
79
  end
53
80
 
54
- private
81
+ desc 'export PATH', 'Export notebook to another format'
82
+ option :format, type: :string, default: 'md', enum: %w[md markdown script], desc: 'Export format: md or script'
83
+ option :output, type: :string, aliases: ['-o'], desc: 'Write to file instead of stdout'
84
+ def export(path)
85
+ out = formatter
86
+ load_notebook(path, out)
87
+
88
+ require 'legion/notebook/parser'
55
89
 
56
- def parse_notebook(path)
57
- unless File.exist?(path)
58
- say "File not found: #{path}", :red
90
+ parsed = Legion::Notebook::Parser.parse(path)
91
+ lang = parsed[:language]
92
+
93
+ content = case options[:format]
94
+ when 'script'
95
+ export_as_script(parsed[:cells], lang)
96
+ else
97
+ export_as_markdown(parsed[:cells], lang)
98
+ end
99
+
100
+ if options[:output]
101
+ File.write(options[:output], content)
102
+ out.success("Exported to #{options[:output]}")
103
+ elsif options[:json]
104
+ out.json(content: content, format: options[:format], path: path)
105
+ else
106
+ puts content
107
+ end
108
+ rescue CLI::Error => e
109
+ formatter.error(e.message)
110
+ raise SystemExit, 1
111
+ end
112
+
113
+ desc 'create PATH', 'Generate a Jupyter notebook from a natural language description (requires legion-llm)'
114
+ option :description, type: :string, aliases: ['-d'], desc: 'What the notebook should do'
115
+ option :kernel, type: :string, default: 'python3', desc: 'Kernel name (default: python3)'
116
+ option :model, type: :string, aliases: ['-m'], desc: 'LLM model override'
117
+ option :provider, type: :string, desc: 'LLM provider override'
118
+ def create(path)
119
+ out = formatter
120
+ setup_llm_connection(out)
121
+
122
+ require 'legion/notebook/generator'
123
+
124
+ description = options[:description]
125
+ if description.nil? || description.strip.empty?
126
+ out.error('--description is required for notebook creation')
59
127
  raise SystemExit, 1
60
128
  end
61
129
 
62
- ::JSON.parse(File.read(path))
63
- rescue ::JSON::ParserError => e
64
- say "Invalid notebook format: #{e.message}", :red
130
+ out.success("Generating notebook: #{description}") unless options[:json]
131
+
132
+ notebook_data = Legion::Notebook::Generator.generate(
133
+ description: description,
134
+ kernel: options[:kernel],
135
+ model: options[:model],
136
+ provider: options[:provider]
137
+ )
138
+
139
+ Legion::Notebook::Generator.write(path, notebook_data)
140
+ cell_count = Array(notebook_data['cells']).length
141
+
142
+ if options[:json]
143
+ out.json(path: path, cells: cell_count, kernel: options[:kernel])
144
+ else
145
+ out.success("Created #{path} (#{cell_count} cells)")
146
+ end
147
+ rescue ArgumentError, CLI::Error => e
148
+ formatter.error(e.message)
65
149
  raise SystemExit, 1
150
+ ensure
151
+ Connection.shutdown
152
+ end
153
+
154
+ no_commands do
155
+ def formatter
156
+ @formatter ||= Output::Formatter.new(
157
+ json: options[:json],
158
+ color: !options[:no_color]
159
+ )
160
+ end
161
+
162
+ def setup_llm_connection(out)
163
+ Connection.config_dir = options[:config_dir] if options[:config_dir]
164
+ Connection.log_level = options[:verbose] ? 'debug' : 'error'
165
+ Connection.ensure_llm
166
+ rescue CLI::Error => e
167
+ out.error(e.message)
168
+ raise SystemExit, 1
169
+ end
170
+
171
+ def load_notebook(path, out)
172
+ unless File.exist?(path)
173
+ out.error("File not found: #{path}")
174
+ raise SystemExit, 1
175
+ end
176
+
177
+ unless path.end_with?('.ipynb')
178
+ out.error("Expected a .ipynb file, got: #{File.basename(path)}")
179
+ raise SystemExit, 1
180
+ end
181
+
182
+ ::JSON.parse(File.read(path))
183
+ rescue ::JSON::ParserError => e
184
+ out.error("Invalid notebook JSON: #{e.message}")
185
+ raise SystemExit, 1
186
+ end
187
+
188
+ def export_as_markdown(cells, lang)
189
+ lines = []
190
+ cells.each do |cell|
191
+ if cell[:type] == 'code'
192
+ lines << "```#{lang}"
193
+ lines << cell[:source]
194
+ lines << '```'
195
+ else
196
+ lines << cell[:source]
197
+ end
198
+ lines << ''
199
+ end
200
+ lines.join("\n")
201
+ end
202
+
203
+ def export_as_script(cells, _lang)
204
+ lines = []
205
+ cells.each do |cell|
206
+ if cell[:type] == 'code'
207
+ lines << cell[:source]
208
+ else
209
+ cell[:source].each_line do |line|
210
+ lines << "# #{line.chomp}"
211
+ end
212
+ end
213
+ lines << ''
214
+ end
215
+ lines.join("\n")
216
+ end
66
217
  end
67
218
  end
68
219
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module Notebook
7
+ module Generator
8
+ NOTEBOOK_TEMPLATE = {
9
+ 'nbformat' => 4,
10
+ 'nbformat_minor' => 5,
11
+ 'metadata' => {
12
+ 'kernelspec' => {
13
+ 'display_name' => 'Python 3',
14
+ 'language' => 'python',
15
+ 'name' => 'python3'
16
+ },
17
+ 'language_info' => {
18
+ 'name' => 'python'
19
+ }
20
+ },
21
+ 'cells' => []
22
+ }.freeze
23
+
24
+ def self.generate(description:, kernel: 'python3', model: nil, provider: nil)
25
+ raise ArgumentError, 'legion-llm is required for notebook generation' unless defined?(Legion::LLM)
26
+
27
+ prompt = build_prompt(description, kernel)
28
+ response = call_llm(prompt, model: model, provider: provider)
29
+ parse_notebook_response(response)
30
+ end
31
+
32
+ def self.write(path, notebook_data)
33
+ File.write(path, ::JSON.pretty_generate(notebook_data))
34
+ end
35
+
36
+ def self.build_prompt(description, kernel)
37
+ <<~PROMPT
38
+ Generate a Jupyter notebook as valid JSON (.ipynb format) for the following task:
39
+
40
+ #{description}
41
+
42
+ Requirements:
43
+ - Use kernel: #{kernel}
44
+ - Include a markdown cell with a title and description at the top
45
+ - Include well-commented code cells
46
+ - Include markdown explanation cells between code sections
47
+ - Return ONLY the raw JSON, no markdown fences, no explanation
48
+
49
+ The JSON must follow the .ipynb format with these top-level keys:
50
+ nbformat, nbformat_minor, metadata, cells
51
+
52
+ Each cell must have: cell_type, metadata, source (array of strings), outputs (array), execution_count
53
+ PROMPT
54
+ end
55
+
56
+ def self.call_llm(prompt, model: nil, provider: nil)
57
+ kwargs = { messages: [{ role: 'user', content: prompt }] }
58
+ kwargs[:model] = model if model
59
+ kwargs[:provider] = provider.to_sym if provider
60
+ Legion::LLM.chat(**kwargs)
61
+ end
62
+
63
+ def self.parse_notebook_response(response)
64
+ content = response[:content].to_s.strip
65
+ # Strip markdown fences if the LLM wrapped the JSON
66
+ content = content.gsub(/\A```(?:json)?\n?/, '').gsub(/\n?```\z/, '').strip
67
+ data = ::JSON.parse(content)
68
+ validate_notebook!(data)
69
+ data
70
+ rescue ::JSON::ParserError => e
71
+ raise ArgumentError, "LLM returned invalid JSON: #{e.message}"
72
+ end
73
+
74
+ def self.validate_notebook!(data)
75
+ raise ArgumentError, 'Missing nbformat key' unless data.key?('nbformat')
76
+ raise ArgumentError, 'Missing cells key' unless data.key?('cells')
77
+ raise ArgumentError, 'cells must be an array' unless data['cells'].is_a?(Array)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module Notebook
7
+ module Parser
8
+ def self.parse(path)
9
+ data = ::JSON.parse(File.read(path))
10
+ {
11
+ metadata: data['metadata'],
12
+ kernel: data.dig('metadata', 'kernelspec', 'display_name'),
13
+ language: data.dig('metadata', 'kernelspec', 'language') || 'python',
14
+ cells: Array(data['cells']).map { |c| parse_cell(c) }
15
+ }
16
+ end
17
+
18
+ def self.parse_cell(cell)
19
+ {
20
+ type: cell['cell_type'],
21
+ source: Array(cell['source']).join,
22
+ outputs: Array(cell.fetch('outputs', [])).map { |o| parse_output(o) }
23
+ }
24
+ end
25
+
26
+ def self.parse_output(output)
27
+ text = case output['output_type']
28
+ when 'execute_result', 'display_data'
29
+ data = output.fetch('data', {})
30
+ Array(data.fetch('text/plain', [])).join
31
+ when 'error'
32
+ "#{output['ename']}: #{output['evalue']}"
33
+ else
34
+ Array(output.fetch('text', [])).join
35
+ end
36
+
37
+ {
38
+ output_type: output['output_type'],
39
+ text: text
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Notebook
5
+ module Renderer
6
+ RESET = "\e[0m"
7
+ BOLD = "\e[1m"
8
+ DIM = "\e[2m"
9
+ YELLOW = "\e[33m"
10
+ CYAN = "\e[36m"
11
+ GREEN = "\e[32m"
12
+ RED = "\e[31m"
13
+ RULE = "\e[2m#{'─' * 60}\e[0m".freeze
14
+
15
+ def self.render_notebook(notebook, color: true)
16
+ lines = []
17
+ kernel = notebook[:kernel]
18
+ lines << (color ? "#{BOLD}#{CYAN}Kernel: #{kernel}#{RESET}" : "Kernel: #{kernel}") if kernel
19
+
20
+ notebook[:cells].each_with_index do |cell, idx|
21
+ lines << ''
22
+ lines << render_cell_header(idx + 1, cell[:type], color)
23
+ lines << render_cell_source(cell, notebook[:language], color)
24
+ lines += render_cell_outputs(cell[:outputs], color) unless cell[:outputs].empty?
25
+ end
26
+
27
+ lines.join("\n")
28
+ end
29
+
30
+ def self.render_cell_header(index, type, color)
31
+ label = "[#{type}] Cell #{index}"
32
+ color ? "#{BOLD}#{YELLOW}#{label}#{RESET}" : label
33
+ end
34
+
35
+ def self.render_cell_source(cell, language, color)
36
+ return '' if cell[:source].empty?
37
+
38
+ if cell[:type] == 'code'
39
+ highlight(cell[:source], language, color)
40
+ else
41
+ color ? "#{DIM}#{cell[:source]}#{RESET}" : cell[:source]
42
+ end
43
+ end
44
+
45
+ def self.render_cell_outputs(outputs, color)
46
+ outputs.filter_map do |output|
47
+ next if output[:text].to_s.strip.empty?
48
+
49
+ prefix = color ? "#{DIM} => " : ' => '
50
+ suffix = color ? RESET : ''
51
+ "#{prefix}#{output[:text].strip}#{suffix}"
52
+ end
53
+ end
54
+
55
+ def self.highlight(code, language, color)
56
+ return code unless color
57
+
58
+ begin
59
+ require 'rouge'
60
+ lexer = Rouge::Lexer.find(language.to_s) || Rouge::Lexers::PlainText.new
61
+ formatter = Rouge::Formatters::Terminal256.new(Rouge::Themes::Monokai.new)
62
+ formatter.format(lexer.lex(code))
63
+ rescue LoadError
64
+ code
65
+ end
66
+ end
67
+
68
+ def self.rule(color)
69
+ color ? RULE : ('-' * 60)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -2,24 +2,37 @@
2
2
 
3
3
  module Legion
4
4
  module Registry
5
+ VALID_STATUSES = %i[pending_review approved rejected deprecated sunset active].freeze
6
+
5
7
  class Entry
6
8
  ATTRS = %i[name version author risk_tier permissions airb_status
7
- description homepage checksum capabilities].freeze
9
+ description homepage checksum capabilities
10
+ status review_notes reject_reason successor sunset_date
11
+ submitted_at approved_at rejected_at deprecated_at].freeze
8
12
 
9
13
  attr_reader(*ATTRS)
10
14
 
11
15
  def initialize(**attrs)
12
16
  ATTRS.each { |a| instance_variable_set(:"@#{a}", attrs[a]) }
13
- @risk_tier ||= 'low'
17
+ @risk_tier ||= 'low'
14
18
  @airb_status ||= 'pending'
15
19
  @capabilities ||= []
16
- @permissions ||= []
20
+ @permissions ||= []
21
+ @status ||= :active
17
22
  end
18
23
 
19
24
  def approved?
20
25
  airb_status == 'approved'
21
26
  end
22
27
 
28
+ def deprecated?
29
+ %i[deprecated sunset].include?(status)
30
+ end
31
+
32
+ def pending_review?
33
+ status == :pending_review
34
+ end
35
+
23
36
  def to_h
24
37
  ATTRS.to_h { |a| [a, send(a)] }
25
38
  end
@@ -62,11 +75,79 @@ module Legion
62
75
  @store = {}
63
76
  end
64
77
 
78
+ # Review workflow
79
+
80
+ def submit_for_review(name)
81
+ entry = find_or_raise(name)
82
+ update_entry(name, entry, status: :pending_review, submitted_at: Time.now.utc)
83
+ true
84
+ end
85
+
86
+ def approve(name, notes: nil)
87
+ entry = find_or_raise(name)
88
+ update_entry(name, entry,
89
+ status: :approved,
90
+ airb_status: 'approved',
91
+ review_notes: notes,
92
+ approved_at: Time.now.utc)
93
+ true
94
+ end
95
+
96
+ def reject(name, reason: nil)
97
+ entry = find_or_raise(name)
98
+ update_entry(name, entry,
99
+ status: :rejected,
100
+ reject_reason: reason,
101
+ rejected_at: Time.now.utc)
102
+ true
103
+ end
104
+
105
+ def deprecate(name, successor: nil, sunset_date: nil)
106
+ entry = find_or_raise(name)
107
+ update_entry(name, entry,
108
+ status: :deprecated,
109
+ successor: successor,
110
+ sunset_date: sunset_date,
111
+ deprecated_at: Time.now.utc)
112
+ true
113
+ end
114
+
115
+ def pending_reviews
116
+ store.values.select(&:pending_review?)
117
+ end
118
+
119
+ def usage_stats(name)
120
+ entry = lookup(name.to_s)
121
+ return nil unless entry
122
+
123
+ {
124
+ name: entry.name,
125
+ version: entry.version,
126
+ install_count: 0,
127
+ active_instances: 0,
128
+ last_updated: nil,
129
+ downloads_7d: 0,
130
+ downloads_30d: 0
131
+ }
132
+ end
133
+
65
134
  private
66
135
 
67
136
  def store
68
137
  @store ||= {}
69
138
  end
139
+
140
+ def find_or_raise(name)
141
+ entry = lookup(name.to_s)
142
+ raise ArgumentError, "Extension '#{name}' not found in registry" unless entry
143
+
144
+ entry
145
+ end
146
+
147
+ def update_entry(name, entry, **overrides)
148
+ attrs = entry.to_h.merge(overrides)
149
+ store[name.to_s] = Entry.new(**attrs)
150
+ end
70
151
  end
71
152
  end
72
153
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.103'
4
+ VERSION = '1.4.105'
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.103
4
+ version: 1.4.105
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -357,6 +357,7 @@ files:
357
357
  - lib/legion/api/audit.rb
358
358
  - lib/legion/api/auth.rb
359
359
  - lib/legion/api/auth_human.rb
360
+ - lib/legion/api/auth_saml.rb
360
361
  - lib/legion/api/auth_worker.rb
361
362
  - lib/legion/api/capacity.rb
362
363
  - lib/legion/api/catalog.rb
@@ -370,6 +371,7 @@ files:
370
371
  - lib/legion/api/hooks.rb
371
372
  - lib/legion/api/lex.rb
372
373
  - lib/legion/api/llm.rb
374
+ - lib/legion/api/marketplace.rb
373
375
  - lib/legion/api/metrics.rb
374
376
  - lib/legion/api/middleware/api_version.rb
375
377
  - lib/legion/api/middleware/auth.rb
@@ -609,6 +611,9 @@ files:
609
611
  - lib/legion/isolation.rb
610
612
  - lib/legion/lex.rb
611
613
  - lib/legion/metrics.rb
614
+ - lib/legion/notebook/generator.rb
615
+ - lib/legion/notebook/parser.rb
616
+ - lib/legion/notebook/renderer.rb
612
617
  - lib/legion/process.rb
613
618
  - lib/legion/readiness.rb
614
619
  - lib/legion/registry.rb