shellfie 0.1.1 → 1.1.0
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/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
data/lib/shellfie/ansi_parser.rb
CHANGED
|
@@ -1,62 +1,70 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "strscan"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "ansi_colors"
|
|
6
|
+
require_relative "ansi_normalizer"
|
|
7
|
+
require_relative "errors"
|
|
4
8
|
|
|
5
9
|
module Shellfie
|
|
6
|
-
Segment = Struct.new(
|
|
10
|
+
Segment = Struct.new(
|
|
11
|
+
:text,
|
|
12
|
+
:foreground,
|
|
13
|
+
:background,
|
|
14
|
+
:bold,
|
|
15
|
+
:italic,
|
|
16
|
+
:underline,
|
|
17
|
+
:underline_style,
|
|
18
|
+
:underline_color,
|
|
19
|
+
:dim,
|
|
20
|
+
:reverse,
|
|
21
|
+
:strikethrough,
|
|
22
|
+
:overline,
|
|
23
|
+
:blink,
|
|
24
|
+
:conceal,
|
|
25
|
+
:link,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
)
|
|
7
28
|
|
|
8
29
|
class AnsiParser
|
|
9
|
-
ANSI_REGEX = /\e\[([0-9
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
93 => :bright_yellow,
|
|
24
|
-
94 => :bright_blue,
|
|
25
|
-
95 => :bright_magenta,
|
|
26
|
-
96 => :bright_cyan,
|
|
27
|
-
97 => :bright_white
|
|
28
|
-
}.freeze
|
|
29
|
-
|
|
30
|
-
BG_COLORS = {
|
|
31
|
-
40 => :black,
|
|
32
|
-
41 => :red,
|
|
33
|
-
42 => :green,
|
|
34
|
-
43 => :yellow,
|
|
35
|
-
44 => :blue,
|
|
36
|
-
45 => :magenta,
|
|
37
|
-
46 => :cyan,
|
|
38
|
-
47 => :white,
|
|
39
|
-
100 => :bright_black,
|
|
40
|
-
101 => :bright_red,
|
|
41
|
-
102 => :bright_green,
|
|
42
|
-
103 => :bright_yellow,
|
|
43
|
-
104 => :bright_blue,
|
|
44
|
-
105 => :bright_magenta,
|
|
45
|
-
106 => :bright_cyan,
|
|
46
|
-
107 => :bright_white
|
|
47
|
-
}.freeze
|
|
48
|
-
|
|
49
|
-
def initialize
|
|
30
|
+
ANSI_REGEX = /\e\[([0-9;:]*)m/
|
|
31
|
+
|
|
32
|
+
OSC_REGEX = /\e\](.*?)?(?:\a|\e\\)/m
|
|
33
|
+
MAX_LINK_BYTES = 2_048
|
|
34
|
+
MAX_OSC_BYTES = MAX_LINK_BYTES + 64
|
|
35
|
+
LINK_SCHEMES = %w[http https mailto].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(state_mode: :persistent, tab_width: 8, osc_policy: "ignore", graphics_policy: "ignore")
|
|
38
|
+
@state_mode = state_mode.to_sym
|
|
39
|
+
@tab_width = tab_width
|
|
40
|
+
@osc_policy = osc_policy.to_s
|
|
41
|
+
@graphics_policy = graphics_policy.to_s
|
|
42
|
+
@link = nil
|
|
43
|
+
@pending_osc = +""
|
|
50
44
|
reset_state
|
|
51
45
|
end
|
|
52
46
|
|
|
53
47
|
def parse(text)
|
|
48
|
+
if @state_mode == :line
|
|
49
|
+
reset_state
|
|
50
|
+
@link = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
input = @pending_osc + text.to_s
|
|
54
|
+
reject_terminal_graphics!(input)
|
|
55
|
+
input, @pending_osc = split_incomplete_osc(input)
|
|
54
56
|
segments = []
|
|
55
|
-
scanner = StringScanner.new(
|
|
57
|
+
scanner = StringScanner.new(AnsiNormalizer.normalize(input, tab_width: @tab_width, osc_policy: @osc_policy))
|
|
56
58
|
current_text = +""
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
if scanner.scan(
|
|
60
|
+
until scanner.eos?
|
|
61
|
+
if scanner.scan(OSC_REGEX)
|
|
62
|
+
unless current_text.empty?
|
|
63
|
+
segments << create_segment(current_text)
|
|
64
|
+
current_text = +""
|
|
65
|
+
end
|
|
66
|
+
process_osc(scanner[1])
|
|
67
|
+
elsif scanner.scan(ANSI_REGEX)
|
|
60
68
|
unless current_text.empty?
|
|
61
69
|
segments << create_segment(current_text)
|
|
62
70
|
current_text = +""
|
|
@@ -73,12 +81,26 @@ module Shellfie
|
|
|
73
81
|
|
|
74
82
|
private
|
|
75
83
|
|
|
84
|
+
def reject_terminal_graphics!(text)
|
|
85
|
+
return unless @graphics_policy == "error" && text.match?(AnsiNormalizer::GRAPHICS_CONTROL_PREFIX)
|
|
86
|
+
|
|
87
|
+
raise ValidationError, "Terminal graphics are not supported; use window.graphics_policy: ignore to discard them"
|
|
88
|
+
end
|
|
89
|
+
|
|
76
90
|
def reset_state
|
|
77
91
|
@foreground = nil
|
|
78
92
|
@background = nil
|
|
79
93
|
@bold = false
|
|
80
94
|
@italic = false
|
|
81
95
|
@underline = false
|
|
96
|
+
@underline_style = nil
|
|
97
|
+
@underline_color = nil
|
|
98
|
+
@dim = false
|
|
99
|
+
@reverse = false
|
|
100
|
+
@strikethrough = false
|
|
101
|
+
@overline = false
|
|
102
|
+
@blink = false
|
|
103
|
+
@conceal = false
|
|
82
104
|
end
|
|
83
105
|
|
|
84
106
|
def create_segment(text)
|
|
@@ -88,87 +110,144 @@ module Shellfie
|
|
|
88
110
|
background: @background,
|
|
89
111
|
bold: @bold,
|
|
90
112
|
italic: @italic,
|
|
91
|
-
underline: @underline
|
|
113
|
+
underline: @underline,
|
|
114
|
+
underline_style: @underline_style,
|
|
115
|
+
underline_color: @underline_color,
|
|
116
|
+
dim: @dim,
|
|
117
|
+
reverse: @reverse,
|
|
118
|
+
strikethrough: @strikethrough,
|
|
119
|
+
overline: @overline,
|
|
120
|
+
blink: @blink,
|
|
121
|
+
conceal: @conceal,
|
|
122
|
+
link: @link
|
|
92
123
|
)
|
|
93
124
|
end
|
|
94
125
|
|
|
95
126
|
def process_codes(codes_str)
|
|
96
127
|
return reset_state if codes_str.empty?
|
|
97
128
|
|
|
98
|
-
codes = codes_str
|
|
129
|
+
codes = sgr_codes(codes_str)
|
|
99
130
|
i = 0
|
|
100
131
|
|
|
101
132
|
while i < codes.length
|
|
102
133
|
code = codes[i]
|
|
103
134
|
|
|
135
|
+
if code.is_a?(Array) && code.first == :underline_style
|
|
136
|
+
@underline_style = %i[none single double curly dotted dashed][code.last]
|
|
137
|
+
@underline = @underline_style && @underline_style != :none
|
|
138
|
+
i += 1
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
104
142
|
case code
|
|
105
143
|
when 0
|
|
106
144
|
reset_state
|
|
107
145
|
when 1
|
|
108
146
|
@bold = true
|
|
147
|
+
when 2
|
|
148
|
+
@dim = true
|
|
109
149
|
when 3
|
|
110
150
|
@italic = true
|
|
111
151
|
when 4
|
|
112
152
|
@underline = true
|
|
153
|
+
@underline_style = :single
|
|
154
|
+
when 5, 6
|
|
155
|
+
@blink = true
|
|
156
|
+
when 8
|
|
157
|
+
@conceal = true
|
|
158
|
+
when 7
|
|
159
|
+
@reverse = true
|
|
160
|
+
when 9
|
|
161
|
+
@strikethrough = true
|
|
113
162
|
when 22
|
|
114
163
|
@bold = false
|
|
164
|
+
@dim = false
|
|
115
165
|
when 23
|
|
116
166
|
@italic = false
|
|
117
167
|
when 24
|
|
118
168
|
@underline = false
|
|
169
|
+
@underline_style = nil
|
|
170
|
+
when 25
|
|
171
|
+
@blink = false
|
|
172
|
+
when 27
|
|
173
|
+
@reverse = false
|
|
174
|
+
when 28
|
|
175
|
+
@conceal = false
|
|
176
|
+
when 29
|
|
177
|
+
@strikethrough = false
|
|
119
178
|
when 30..37, 90..97
|
|
120
|
-
@foreground = COLORS[code]
|
|
179
|
+
@foreground = AnsiColors::COLORS[code]
|
|
121
180
|
when 38
|
|
122
|
-
i, @foreground = parse_extended_color(codes, i)
|
|
181
|
+
i, @foreground = AnsiColors.parse_extended_color(codes, i)
|
|
123
182
|
when 39
|
|
124
183
|
@foreground = nil
|
|
125
184
|
when 40..47, 100..107
|
|
126
|
-
@background = BG_COLORS[code]
|
|
185
|
+
@background = AnsiColors::BG_COLORS[code]
|
|
127
186
|
when 48
|
|
128
|
-
i, @background = parse_extended_color(codes, i)
|
|
187
|
+
i, @background = AnsiColors.parse_extended_color(codes, i)
|
|
129
188
|
when 49
|
|
130
189
|
@background = nil
|
|
190
|
+
when 21
|
|
191
|
+
@underline = true
|
|
192
|
+
@underline_style = :double
|
|
193
|
+
when 53
|
|
194
|
+
@overline = true
|
|
195
|
+
when 55
|
|
196
|
+
@overline = false
|
|
197
|
+
when 58
|
|
198
|
+
i, @underline_color = AnsiColors.parse_extended_color(codes, i)
|
|
199
|
+
when 59
|
|
200
|
+
@underline_color = nil
|
|
131
201
|
end
|
|
132
202
|
|
|
133
203
|
i += 1
|
|
134
204
|
end
|
|
135
205
|
end
|
|
136
206
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
207
|
+
def sgr_codes(value)
|
|
208
|
+
value.split(";").flat_map do |field|
|
|
209
|
+
parts = field.split(":", -1)
|
|
210
|
+
next(field.empty? ? 0 : field.to_i) if parts.size == 1
|
|
211
|
+
|
|
212
|
+
code, mode = parts.values_at(0, 1).map(&:to_i)
|
|
213
|
+
if code == 4 && parts.size == 2 && parts.last.match?(/\A[0-5]\z/)
|
|
214
|
+
[[:underline_style, parts.last.to_i]]
|
|
215
|
+
elsif [38, 48, 58].include?(code) && mode == 2 && [5, 6].include?(parts.size) && parts.last(3).all? { |item| item.match?(/\A\d+\z/) }
|
|
216
|
+
[code, mode, *parts.last(3).map(&:to_i)]
|
|
217
|
+
elsif [38, 48, 58].include?(code) && mode == 5 && parts.size == 3 && parts.last.match?(/\A\d+\z/)
|
|
218
|
+
[code, mode, parts.last.to_i]
|
|
219
|
+
else
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
149
222
|
end
|
|
150
223
|
end
|
|
151
224
|
|
|
152
|
-
def
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
225
|
+
def process_osc(value)
|
|
226
|
+
return unless value.to_s.start_with?("8;")
|
|
227
|
+
|
|
228
|
+
uri = value.to_s.split(";", 3)[2].to_s
|
|
229
|
+
@link = uri.empty? ? nil : safe_link(uri)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def safe_link(value)
|
|
233
|
+
return if value.bytesize > MAX_LINK_BYTES || value.match?(/[\x00-\x1f\x7f]/)
|
|
234
|
+
|
|
235
|
+
uri = URI.parse(value)
|
|
236
|
+
value if LINK_SCHEMES.include?(uri.scheme&.downcase)
|
|
237
|
+
rescue URI::InvalidURIError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def split_incomplete_osc(value)
|
|
242
|
+
start = value.rindex("\e]")
|
|
243
|
+
return [value, +""] unless start
|
|
244
|
+
|
|
245
|
+
tail = value[start..]
|
|
246
|
+
return [value, +""] if tail.match?(OSC_REGEX)
|
|
247
|
+
return [value[0...start], tail] if tail.bytesize <= MAX_OSC_BYTES
|
|
248
|
+
|
|
249
|
+
[value[0...start], +""]
|
|
172
250
|
end
|
|
251
|
+
|
|
173
252
|
end
|
|
174
253
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "output_writer"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
|
|
8
|
+
module Shellfie
|
|
9
|
+
class Cassette
|
|
10
|
+
MAX_BYTES = 32 * 1_048_576
|
|
11
|
+
MAX_EVENTS = 10_000
|
|
12
|
+
TOP_LEVEL_KEYS = %i[version title columns rows events captures exit_status].freeze
|
|
13
|
+
EVENT_KEYS = %i[text delay visible status screen].freeze
|
|
14
|
+
|
|
15
|
+
def self.write(path, session)
|
|
16
|
+
OutputWriter.write(path, extension: "json") do |temporary_path|
|
|
17
|
+
json = JSON.pretty_generate(session.to_h)
|
|
18
|
+
raise ResourceLimitError, "Cassette is too large (max #{MAX_BYTES} bytes)" if json.bytesize > MAX_BYTES
|
|
19
|
+
|
|
20
|
+
File.write(temporary_path, json)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.read(path)
|
|
25
|
+
raise ParseError, "Cassette file not found: #{path}" unless File.file?(path)
|
|
26
|
+
content = File.open(path, "rb") { |file| file.read(MAX_BYTES + 1) }
|
|
27
|
+
raise ParseError, "Cassette is too large (max #{MAX_BYTES} bytes)" if content.bytesize > MAX_BYTES
|
|
28
|
+
|
|
29
|
+
raw = JSON.parse(content, symbolize_names: true)
|
|
30
|
+
validate!(raw)
|
|
31
|
+
|
|
32
|
+
Session.new(
|
|
33
|
+
columns: raw[:columns],
|
|
34
|
+
rows: raw[:rows],
|
|
35
|
+
title: raw[:title],
|
|
36
|
+
events: raw[:events] || [],
|
|
37
|
+
captures: raw[:captures] || {},
|
|
38
|
+
exit_status: raw[:exit_status]
|
|
39
|
+
)
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
raise ParseError, "Invalid cassette: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate!(raw)
|
|
45
|
+
raise ParseError, "Cassette must be a JSON object" unless raw.is_a?(Hash)
|
|
46
|
+
raise ParseError, "Unsupported cassette version" unless raw[:version] == 1
|
|
47
|
+
raise ParseError, "Unknown cassette key" unless (raw.keys - TOP_LEVEL_KEYS).empty?
|
|
48
|
+
raise ParseError, "Cassette title must be a string" unless raw[:title].is_a?(String)
|
|
49
|
+
unless raw[:columns].is_a?(Integer) && raw[:columns].between?(1, 500) &&
|
|
50
|
+
raw[:rows].is_a?(Integer) && raw[:rows].between?(1, 200)
|
|
51
|
+
raise ParseError, "Invalid cassette dimensions"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
events = raw[:events] || []
|
|
55
|
+
raise ParseError, "Cassette events must be an array" unless events.is_a?(Array)
|
|
56
|
+
raise ParseError, "Cassette has too many events" if events.size > MAX_EVENTS
|
|
57
|
+
events.each_with_index do |event, index|
|
|
58
|
+
unless event.is_a?(Hash) && (event.keys - EVENT_KEYS).empty? && event[:text].is_a?(String) &&
|
|
59
|
+
event[:delay].is_a?(Numeric) && event[:delay].finite? && event[:delay] >= 0 &&
|
|
60
|
+
[true, false].include?(event.fetch(:visible, true)) &&
|
|
61
|
+
(!event.key?(:screen) || event[:screen].is_a?(Array) && event[:screen].all?(String)) &&
|
|
62
|
+
(event[:status].nil? || event[:status].is_a?(Integer) && event[:status].between?(0, 255))
|
|
63
|
+
raise ParseError, "Invalid cassette event at index #{index}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
captures = raw[:captures] || {}
|
|
68
|
+
unless captures.is_a?(Hash) && captures.all? { |name, lines| name.is_a?(Symbol) && lines.is_a?(Array) && lines.all?(String) }
|
|
69
|
+
raise ParseError, "Cassette captures must map names to arrays of strings"
|
|
70
|
+
end
|
|
71
|
+
unless raw[:exit_status].nil? || raw[:exit_status].is_a?(Integer) && raw[:exit_status].between?(0, 255)
|
|
72
|
+
raise ParseError, "Invalid cassette exit status"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/shellfie/cli.rb
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require_relative "../shellfie"
|
|
5
|
+
require_relative "cli_generate"
|
|
6
|
+
require_relative "cli_info"
|
|
7
|
+
require_relative "cli_run"
|
|
8
|
+
require_relative "cli_authoring"
|
|
9
|
+
require_relative "dependency_checker"
|
|
10
|
+
require_relative "transcript_renderer"
|
|
5
11
|
|
|
6
12
|
module Shellfie
|
|
7
13
|
class CLI
|
|
8
|
-
|
|
14
|
+
include CLIGenerate
|
|
15
|
+
include CLIInfo
|
|
16
|
+
include CLIRun
|
|
17
|
+
include CLIAuthoring
|
|
18
|
+
|
|
19
|
+
COMMANDS = %w[generate run record replay new format compile schema completion watch init themes validate inspect doctor version help].freeze
|
|
9
20
|
|
|
10
21
|
def initialize(args)
|
|
11
|
-
@args = args
|
|
22
|
+
@args = args.dup
|
|
12
23
|
@options = {}
|
|
13
24
|
end
|
|
14
25
|
|
|
@@ -22,194 +33,91 @@ module Shellfie
|
|
|
22
33
|
run_generate
|
|
23
34
|
when "init"
|
|
24
35
|
run_init
|
|
36
|
+
when "run"
|
|
37
|
+
run_session
|
|
38
|
+
when "record"
|
|
39
|
+
run_session(record: true)
|
|
40
|
+
when "replay"
|
|
41
|
+
replay_session
|
|
42
|
+
when "new"
|
|
43
|
+
run_new
|
|
44
|
+
when "format"
|
|
45
|
+
run_format
|
|
46
|
+
when "compile"
|
|
47
|
+
run_compile
|
|
48
|
+
when "schema"
|
|
49
|
+
run_schema
|
|
50
|
+
when "completion"
|
|
51
|
+
run_completion
|
|
52
|
+
when "watch"
|
|
53
|
+
run_watch
|
|
25
54
|
when "themes"
|
|
26
55
|
run_themes
|
|
27
56
|
when "validate"
|
|
28
57
|
run_validate
|
|
58
|
+
when "inspect"
|
|
59
|
+
run_inspect
|
|
60
|
+
when "doctor"
|
|
61
|
+
run_doctor
|
|
29
62
|
when "version", "-v", "--version"
|
|
30
63
|
run_version
|
|
31
64
|
when "help", "-h", "--help"
|
|
32
65
|
show_help
|
|
33
66
|
else
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
warn_error "Unknown command: #{command}"
|
|
68
|
+
warn_error "Run 'shellfie help' for usage information."
|
|
36
69
|
exit 1
|
|
37
70
|
end
|
|
38
71
|
rescue Shellfie::Error => e
|
|
39
|
-
|
|
72
|
+
if @options[:validation_format] && @options[:validation_format] != "text"
|
|
73
|
+
emit_validation_report(valid: false, error: e)
|
|
74
|
+
else
|
|
75
|
+
warn_error "Error: #{e.message}"
|
|
76
|
+
end
|
|
40
77
|
exit determine_exit_code(e)
|
|
78
|
+
rescue OptionParser::ParseError => e
|
|
79
|
+
warn_error "Error: #{e.message}"
|
|
80
|
+
exit 1
|
|
81
|
+
rescue SystemCallError => e
|
|
82
|
+
warn_error "Error: #{e.message}"
|
|
83
|
+
exit 5
|
|
41
84
|
end
|
|
42
85
|
|
|
43
86
|
private
|
|
44
87
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
opts.on("-t", "--theme NAME", "Override theme (macos, ubuntu, windows)") do |theme|
|
|
54
|
-
@options[:theme] = theme
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
opts.on("-a", "--animate", "Generate animated GIF") do
|
|
58
|
-
@options[:animate] = true
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
opts.on("-s", "--scale FACTOR", "Output scale (1, 2, 3)") do |scale|
|
|
62
|
-
@options[:scale] = scale.to_i
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
opts.on("-w", "--width PIXELS", Integer, "Override width") do |width|
|
|
66
|
-
@options[:width] = width
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
opts.on("--no-shadow", "Disable shadow effect") do
|
|
70
|
-
@options[:shadow] = false
|
|
71
|
-
end
|
|
88
|
+
def canonical_output_path(path)
|
|
89
|
+
expanded = File.expand_path(path)
|
|
90
|
+
missing = []
|
|
91
|
+
current = expanded
|
|
92
|
+
until File.exist?(current)
|
|
93
|
+
parent = File.dirname(current)
|
|
94
|
+
break if parent == current
|
|
72
95
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
opts.on("--no-header", "Disable window header (headless mode)") do
|
|
78
|
-
@options[:headless] = true
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
parser.parse!(@args)
|
|
83
|
-
|
|
84
|
-
input_file = @args.shift
|
|
85
|
-
raise ConfigError, "Input file is required" unless input_file
|
|
86
|
-
raise ConfigError, "Output file is required (use -o option)" unless @options[:output]
|
|
87
|
-
|
|
88
|
-
config = Parser.parse(input_file)
|
|
89
|
-
|
|
90
|
-
if @options[:theme] || @options[:width] || @options[:headless]
|
|
91
|
-
config = Config.new(
|
|
92
|
-
theme: @options[:theme] || config.theme,
|
|
93
|
-
title: config.title,
|
|
94
|
-
window: config.window.merge(@options[:width] ? { width: @options[:width] } : {}),
|
|
95
|
-
font: config.font,
|
|
96
|
-
lines: config.lines,
|
|
97
|
-
animation: config.animation,
|
|
98
|
-
frames: config.frames,
|
|
99
|
-
headless: @options[:headless] || config.headless
|
|
100
|
-
)
|
|
96
|
+
missing.unshift(File.basename(current))
|
|
97
|
+
current = parent
|
|
101
98
|
end
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
output = generator.generate(
|
|
106
|
-
@options[:output],
|
|
107
|
-
scale: @options[:scale] || 1,
|
|
108
|
-
shadow: @options[:shadow] != false
|
|
109
|
-
)
|
|
110
|
-
else
|
|
111
|
-
renderer = Renderer.new(config)
|
|
112
|
-
output = renderer.render(
|
|
113
|
-
@options[:output],
|
|
114
|
-
scale: @options[:scale] || 1,
|
|
115
|
-
shadow: @options[:shadow] != false,
|
|
116
|
-
transparent: @options[:transparent] || false
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
puts "Generated: #{output}"
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def run_init
|
|
124
|
-
sample_config = <<~YAML
|
|
125
|
-
# Shellfie configuration file
|
|
126
|
-
theme: macos
|
|
127
|
-
title: "Terminal — zsh"
|
|
128
|
-
|
|
129
|
-
window:
|
|
130
|
-
width: 600
|
|
131
|
-
padding: 20
|
|
132
|
-
|
|
133
|
-
lines:
|
|
134
|
-
- prompt: "$ "
|
|
135
|
-
command: "gem install shellfie"
|
|
136
|
-
|
|
137
|
-
- output: |
|
|
138
|
-
Fetching shellfie-#{VERSION}.gem
|
|
139
|
-
Successfully installed shellfie-#{VERSION}
|
|
140
|
-
1 gem installed
|
|
141
|
-
|
|
142
|
-
- prompt: "$ "
|
|
143
|
-
command: "shellfie --version"
|
|
144
|
-
|
|
145
|
-
- output: "shellfie #{VERSION}"
|
|
146
|
-
YAML
|
|
147
|
-
|
|
148
|
-
puts sample_config
|
|
99
|
+
File.join(File.realpath(current), *missing)
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
expanded
|
|
149
102
|
end
|
|
150
103
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
puts " macos - macOS Terminal style (red/yellow/green buttons, left side)"
|
|
155
|
-
puts " ubuntu - Ubuntu Terminal style (buttons on right side)"
|
|
156
|
-
puts " windows - Windows Terminal style (square corners, icons)"
|
|
157
|
-
puts
|
|
158
|
-
puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
|
|
104
|
+
def configuration_version(path)
|
|
105
|
+
raw = YamlSafety.load_file(path, max_bytes: Parser::MAX_INCLUDE_BYTES)
|
|
106
|
+
raw.is_a?(Hash) ? raw[:version] : nil
|
|
159
107
|
end
|
|
160
108
|
|
|
161
|
-
def
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
config = Parser.parse(input_file)
|
|
166
|
-
puts "✓ Configuration is valid"
|
|
167
|
-
puts " Theme: #{config.theme}"
|
|
168
|
-
puts " Title: #{config.title}"
|
|
169
|
-
puts " Lines: #{config.lines.size}"
|
|
170
|
-
puts " Mode: #{config.animated? ? "animated" : "static"}"
|
|
109
|
+
def replaceable_png_sequence_directory?(path)
|
|
110
|
+
entries = Dir.children(path)
|
|
111
|
+
entries.empty? || (entries.include?("timeline.json") &&
|
|
112
|
+
entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) })
|
|
171
113
|
end
|
|
172
114
|
|
|
173
|
-
def
|
|
174
|
-
|
|
115
|
+
def path_within?(path, directory)
|
|
116
|
+
path == directory || path.start_with?("#{directory}#{File::SEPARATOR}")
|
|
175
117
|
end
|
|
176
118
|
|
|
177
|
-
def
|
|
178
|
-
puts
|
|
179
|
-
Shellfie - Terminal screenshot-style image generator
|
|
180
|
-
|
|
181
|
-
Usage: shellfie <command> [options]
|
|
182
|
-
shf <command> [options]
|
|
183
|
-
|
|
184
|
-
Commands:
|
|
185
|
-
generate Generate image from configuration file
|
|
186
|
-
init Output sample configuration
|
|
187
|
-
themes List available themes
|
|
188
|
-
validate Validate configuration file
|
|
189
|
-
version Show version
|
|
190
|
-
help Show this help
|
|
191
|
-
|
|
192
|
-
Generate Options:
|
|
193
|
-
-o, --output PATH Output file path (required)
|
|
194
|
-
-t, --theme NAME Override theme (macos, ubuntu, windows)
|
|
195
|
-
-a, --animate Generate animated GIF
|
|
196
|
-
-s, --scale FACTOR Output scale (1, 2, 3)
|
|
197
|
-
-w, --width PIXELS Override width
|
|
198
|
-
--no-shadow Disable shadow effect
|
|
199
|
-
--no-header Disable window header (headless mode)
|
|
200
|
-
--transparent Transparent background
|
|
201
|
-
|
|
202
|
-
Examples:
|
|
203
|
-
shellfie generate config.yml -o terminal.png
|
|
204
|
-
shellfie generate config.yml -o demo.gif --animate
|
|
205
|
-
shellfie generate config.yml -o retina.png --scale 2
|
|
206
|
-
shellfie init > my-config.yml
|
|
207
|
-
shellfie themes
|
|
208
|
-
|
|
209
|
-
# Short form
|
|
210
|
-
shf generate config.yml -o terminal.png
|
|
211
|
-
shf init > config.yml
|
|
212
|
-
HELP
|
|
119
|
+
def warn_error(message)
|
|
120
|
+
$stderr.puts message
|
|
213
121
|
end
|
|
214
122
|
|
|
215
123
|
def determine_exit_code(error)
|
|
@@ -220,6 +128,10 @@ module Shellfie
|
|
|
220
128
|
3
|
|
221
129
|
when DependencyError
|
|
222
130
|
4
|
|
131
|
+
when FileSystemError
|
|
132
|
+
5
|
|
133
|
+
when ExecutionError
|
|
134
|
+
6
|
|
223
135
|
else
|
|
224
136
|
1
|
|
225
137
|
end
|