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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +248 -228
  4. data/lib/shellfie/animation_frame_builder.rb +202 -0
  5. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  6. data/lib/shellfie/animation_timeline.rb +28 -0
  7. data/lib/shellfie/ansi_colors.rb +94 -0
  8. data/lib/shellfie/ansi_line_buffer.rb +103 -0
  9. data/lib/shellfie/ansi_normalizer.rb +61 -0
  10. data/lib/shellfie/ansi_parser.rb +162 -83
  11. data/lib/shellfie/cassette.rb +76 -0
  12. data/lib/shellfie/cli.rb +75 -163
  13. data/lib/shellfie/cli_authoring.rb +233 -0
  14. data/lib/shellfie/cli_generate.rb +401 -0
  15. data/lib/shellfie/cli_info.rb +239 -0
  16. data/lib/shellfie/cli_run.rb +167 -0
  17. data/lib/shellfie/config.rb +112 -25
  18. data/lib/shellfie/config_defaults.rb +83 -0
  19. data/lib/shellfie/config_validation.rb +289 -0
  20. data/lib/shellfie/dependency_checker.rb +147 -0
  21. data/lib/shellfie/errors.rb +12 -1
  22. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  23. data/lib/shellfie/font_resolver.rb +68 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +231 -89
  26. data/lib/shellfie/gif_palette.rb +105 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/html_renderer.rb +54 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +146 -0
  31. data/lib/shellfie/output_writer.rb +46 -0
  32. data/lib/shellfie/parser.rb +183 -30
  33. data/lib/shellfie/parser_validation.rb +178 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +115 -0
  37. data/lib/shellfie/render_segment.rb +71 -0
  38. data/lib/shellfie/renderer.rb +96 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +195 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  43. data/lib/shellfie/session.rb +111 -0
  44. data/lib/shellfie/session_config.rb +562 -0
  45. data/lib/shellfie/session_runner.rb +689 -0
  46. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  47. data/lib/shellfie/svg_renderer.rb +222 -0
  48. data/lib/shellfie/terminal_screen.rb +389 -0
  49. data/lib/shellfie/text_metrics.rb +155 -0
  50. data/lib/shellfie/theme_data.rb +80 -0
  51. data/lib/shellfie/theme_registry.rb +131 -0
  52. data/lib/shellfie/themes/base.rb +10 -1
  53. data/lib/shellfie/themes/configured.rb +61 -0
  54. data/lib/shellfie/themes/macos.rb +3 -1
  55. data/lib/shellfie/themes/ubuntu.rb +2 -1
  56. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  57. data/lib/shellfie/transcript_renderer.rb +92 -0
  58. data/lib/shellfie/version.rb +1 -1
  59. data/lib/shellfie/yaml_safety.rb +147 -0
  60. data/lib/shellfie.rb +44 -3
  61. data/schema/shellfie-v1.schema.json +153 -0
  62. data/schema/shellfie-v2.schema.json +262 -0
  63. metadata +58 -20
  64. data/.rspec +0 -3
  65. data/Rakefile +0 -8
  66. data/examples/animation.yml +0 -33
  67. data/examples/colored.yml +0 -20
  68. data/examples/demo.gif +0 -0
  69. data/examples/demo.png +0 -0
  70. data/examples/demo_animation.yml +0 -31
  71. data/examples/headless.png +0 -0
  72. data/examples/headless.yml +0 -16
  73. data/examples/scrolling.yml +0 -48
  74. data/examples/simple.yml +0 -21
  75. data/examples/theme_macos.png +0 -0
  76. data/examples/theme_ubuntu.png +0 -0
  77. data/examples/theme_windows.png +0 -0
  78. data/shellfie.gemspec +0 -32
@@ -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(:text, :foreground, :background, :bold, :italic, :underline, keyword_init: true)
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;]*)m/
10
-
11
- COLORS = {
12
- 30 => :black,
13
- 31 => :red,
14
- 32 => :green,
15
- 33 => :yellow,
16
- 34 => :blue,
17
- 35 => :magenta,
18
- 36 => :cyan,
19
- 37 => :white,
20
- 90 => :bright_black,
21
- 91 => :bright_red,
22
- 92 => :bright_green,
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(text)
57
+ scanner = StringScanner.new(AnsiNormalizer.normalize(input, tab_width: @tab_width, osc_policy: @osc_policy))
56
58
  current_text = +""
57
59
 
58
- while !scanner.eos?
59
- if scanner.scan(ANSI_REGEX)
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.split(";").map(&:to_i)
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 parse_extended_color(codes, i)
138
- return [i, nil] if codes[i + 1].nil?
139
-
140
- case codes[i + 1]
141
- when 5
142
- color_index = codes[i + 2]
143
- [i + 2, color_256(color_index)]
144
- when 2
145
- r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
146
- [i + 4, format("#%02x%02x%02x", r, g, b)]
147
- else
148
- [i, nil]
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 color_256(index)
153
- return nil unless index
154
-
155
- if index < 16
156
- standard_colors = %i[
157
- black red green yellow blue magenta cyan white
158
- bright_black bright_red bright_green bright_yellow
159
- bright_blue bright_magenta bright_cyan bright_white
160
- ]
161
- standard_colors[index]
162
- elsif index < 232
163
- index -= 16
164
- r = (index / 36) * 51
165
- g = ((index % 36) / 6) * 51
166
- b = (index % 6) * 51
167
- format("#%02x%02x%02x", r, g, b)
168
- else
169
- gray = (index - 232) * 10 + 8
170
- format("#%02x%02x%02x", gray, gray, gray)
171
- end
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
- COMMANDS = %w[generate init themes validate version help].freeze
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
- puts "Unknown command: #{command}"
35
- puts "Run 'shellfie help' for usage information."
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
- puts "Error: #{e.message}"
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 run_generate
46
- parser = OptionParser.new do |opts|
47
- opts.banner = "Usage: shellfie generate INPUT_FILE [options]"
48
-
49
- opts.on("-o", "--output PATH", "Output file path (required)") do |path|
50
- @options[:output] = path
51
- end
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
- opts.on("--transparent", "Transparent background") do
74
- @options[:transparent] = true
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
- if @options[:animate] || config.animated?
104
- generator = GifGenerator.new(config)
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 run_themes
152
- puts "Available themes:"
153
- puts
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 run_validate
162
- input_file = @args.shift
163
- raise ConfigError, "Input file is required" unless input_file
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 run_version
174
- puts "shellfie #{VERSION}"
115
+ def path_within?(path, directory)
116
+ path == directory || path.start_with?("#{directory}#{File::SEPARATOR}")
175
117
  end
176
118
 
177
- def show_help
178
- puts <<~HELP
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