legionio 1.4.103 → 1.4.104

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: 12afbe73936b06db9067ffa5e4fc445fe52c76687cd13aedbdb4b390a5515d7f
4
+ data.tar.gz: 960ca1400fe432d4ee1dbe51658afaa2e988982b178da080b8f20795bc42fa7e
5
5
  SHA512:
6
- metadata.gz: 6681f51110762da3d76aa93b1e1ee2a92814c22e101842ef588f3a4d9492f585bdbf9bb4244df59c320c89d0dc992e17ba388b2ad56015a533fd7dec5411d378
7
- data.tar.gz: ef6398f327e82cc91396d26fc855a6472eff22ee5f12e3ad225fbbbc06ee6df92912ef5c376dc9c0c838e0b6108bb3dbc7066f0372c820bb8e740d677fa70923
6
+ metadata.gz: 1ead15dbef330a1329a4b5a7b973235fa0d45d84b95cbaf5d7e0415ebde0d19154ef836fc6a84753780dd0270595bc087159a821b5cf06ed737b911a5707a054
7
+ data.tar.gz: 61edda120bb39a6868df425f5acb2ba39e602708c9959c5c4ad54ad8d6310ee15e72bbcb024f38907cc46136b5d28548af45e4752cb66df6b46b676fcb6a8a45
data/.rubocop.yml CHANGED
@@ -43,6 +43,7 @@ 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'
47
48
 
48
49
  Metrics/AbcSize:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.104] - 2026-03-21
4
+
5
+ ### Added
6
+ - `legion notebook read PATH` — parse and display a .ipynb notebook with Rouge syntax highlighting
7
+ - `legion notebook cells PATH` — list all cells with index numbers and line counts
8
+ - `legion notebook export PATH --format md|script` — export notebook to markdown or Python script
9
+ - `legion notebook create PATH --description "..."` — generate a new notebook from natural language via LLM (requires legion-llm)
10
+ - `Legion::Notebook::Parser` — parse .ipynb JSON into structured data (metadata, kernel, language, cells with outputs)
11
+ - `Legion::Notebook::Renderer` — display notebook cells in terminal with Rouge syntax highlighting
12
+ - `Legion::Notebook::Generator` — generate notebooks from natural language; strips LLM markdown fences; validates .ipynb structure
13
+
3
14
  ## [1.4.103] - 2026-03-21
4
15
 
5
16
  ### Added
@@ -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
@@ -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.104'
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.104
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -609,6 +609,9 @@ files:
609
609
  - lib/legion/isolation.rb
610
610
  - lib/legion/lex.rb
611
611
  - lib/legion/metrics.rb
612
+ - lib/legion/notebook/generator.rb
613
+ - lib/legion/notebook/parser.rb
614
+ - lib/legion/notebook/renderer.rb
612
615
  - lib/legion/process.rb
613
616
  - lib/legion/readiness.rb
614
617
  - lib/legion/registry.rb