smol 1.0.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.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class Config
5
+ include Coercion
6
+
7
+ def initialize
8
+ @settings = {}
9
+ @values = {}
10
+ end
11
+
12
+ def setting(key, default:, type: :string, desc: nil)
13
+ @settings[key] = { default: default, type: type, desc: desc }
14
+ end
15
+
16
+ def [](key)
17
+ return @values[key] if @values.key?(key)
18
+
19
+ setting = @settings[key]
20
+ raise ArgumentError, "unknown config key: #{key}" unless setting
21
+
22
+ env_key = key.to_s.upcase
23
+ raw = ENV.fetch(env_key, setting[:default].to_s)
24
+
25
+ @values[key] = coerce_value(raw, setting[:type])
26
+ end
27
+
28
+ def set(key, value)
29
+ raise ArgumentError, "unknown config key: #{key}" unless @settings.key?(key)
30
+
31
+ @values[key] = coerce_value(value.to_s, @settings[key][:type])
32
+ end
33
+
34
+ def settings
35
+ @settings.dup
36
+ end
37
+
38
+ def to_h
39
+ @settings.keys.each_with_object({}) { |k, h| h[k] = self[k] }
40
+ end
41
+
42
+ def each(&block)
43
+ return enum_for(:each) unless block
44
+
45
+ @settings.each_key { |k| yield k, self[k], @settings[k] }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module ConfigDisplay
5
+ include Output
6
+ using Colors
7
+
8
+ private
9
+
10
+ def show_config
11
+ out.puts "config:".bold
12
+ @app.config.each do |key, value, setting|
13
+ line = " #{key}: #{value}"
14
+ line += " - #{setting[:desc]}" if setting[:desc]
15
+ out.puts line.dim
16
+ end
17
+ end
18
+
19
+ def set_config(key, value)
20
+ if key.nil? || value.nil?
21
+ warning "usage: config:set <key> <value>"
22
+ return
23
+ end
24
+
25
+ @app.config.set(key.to_sym, value)
26
+ success "#{key} = #{@app.config[key.to_sym]}"
27
+ rescue ArgumentError => e
28
+ failure e.message
29
+ end
30
+ end
31
+ end
data/lib/smol/input.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module Input
5
+ using Colors
6
+ extend self
7
+
8
+ def confirm(question, default: nil)
9
+ hint = case default
10
+ when true then "[Y/n]"
11
+ when false then "[y/N]"
12
+ else "[y/n]"
13
+ end
14
+
15
+ Smol.output.print "#{question} #{hint} ".yellow
16
+ response = Smol.input.gets&.strip&.downcase
17
+
18
+ case response
19
+ when "", nil
20
+ default
21
+ when "y", "yes"
22
+ true
23
+ when "n", "no"
24
+ false
25
+ else
26
+ default
27
+ end
28
+ end
29
+
30
+ def ask(question, default: nil)
31
+ prompt = default ? "#{question} [#{default}]" : question
32
+ Smol.output.print "#{prompt}: ".yellow
33
+ response = Smol.input.gets&.strip
34
+
35
+ if response.nil? || response.empty?
36
+ default
37
+ else
38
+ response
39
+ end
40
+ end
41
+
42
+ def choose(question, choices, default: nil)
43
+ Smol.output.puts question.yellow
44
+ choices.each_with_index do |choice, i|
45
+ marker = (i + 1) == default ? "*" : " "
46
+ Smol.output.puts "#{marker} #{i + 1}) #{choice}"
47
+ end
48
+
49
+ Smol.output.print "choice: ".yellow
50
+ response = Smol.input.gets&.strip
51
+
52
+ if response.nil? || response.empty?
53
+ default ? choices[default - 1] : nil
54
+ else
55
+ idx = response.to_i - 1
56
+ idx >= 0 && idx < choices.size ? choices[idx] : nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module Output
5
+ using Colors
6
+ extend self
7
+
8
+ def out
9
+ Smol.output
10
+ end
11
+
12
+ def banner(text)
13
+ out.puts text.red
14
+ end
15
+
16
+ def header(text)
17
+ out.puts text.bold
18
+ end
19
+
20
+ def desc(text)
21
+ out.puts text.dim
22
+ end
23
+
24
+ def nl
25
+ out.puts
26
+ end
27
+
28
+ def info(text)
29
+ out.puts text
30
+ end
31
+
32
+ def success(text)
33
+ out.puts text.green.bold
34
+ end
35
+
36
+ def failure(text)
37
+ out.puts text.red.bold
38
+ end
39
+
40
+ def warning(text)
41
+ out.puts text.yellow
42
+ end
43
+
44
+ def hint(text)
45
+ out.puts text.dim
46
+ end
47
+
48
+ def label(text)
49
+ out.puts text.yellow
50
+ end
51
+
52
+ def verbose(text)
53
+ return unless Smol.verbose?
54
+
55
+ out.puts text.dim
56
+ end
57
+
58
+ def debug(text)
59
+ return unless Smol.debug?
60
+
61
+ out.puts "[debug] #{text}".dim
62
+ end
63
+
64
+ def check_result(name, result)
65
+ status = result.passed? ? "pass".green.bold : "fail".red.bold
66
+ out.puts "#{status}: #{name}"
67
+ out.puts " #{result.message}"
68
+ end
69
+
70
+ def table(rows, headers: nil, indent: 0)
71
+ return if rows.empty?
72
+
73
+ all_rows = headers ? [headers] + rows : rows
74
+ col_widths = table_column_widths(all_rows)
75
+ prefix = " " * indent
76
+
77
+ if headers
78
+ header_line = table_format_row(headers, col_widths)
79
+ out.puts "#{prefix}#{header_line}".bold
80
+ out.puts "#{prefix}#{"-" * header_line.length}"
81
+ end
82
+
83
+ rows.each do |row|
84
+ out.puts "#{prefix}#{table_format_row(row, col_widths)}"
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def table_column_widths(rows)
91
+ return [] if rows.empty?
92
+
93
+ num_cols = rows.map(&:size).max
94
+ (0...num_cols).map do |i|
95
+ rows.map { |row| row[i].to_s.length }.max
96
+ end
97
+ end
98
+
99
+ def table_format_row(row, widths)
100
+ row.each_with_index.map do |cell, i|
101
+ cell.to_s.ljust(widths[i] || 0)
102
+ end.join(" ")
103
+ end
104
+ end
105
+ end
data/lib/smol/repl.rb ADDED
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class REPL
5
+ include Output
6
+ include ConfigDisplay
7
+ using Colors
8
+
9
+ def initialize(app, prompt:, history: true, history_file: nil, parent: nil)
10
+ @app = app
11
+ @prompt = prompt
12
+ @history = history
13
+ @history_file = history_file || File.expand_path("~/.smol_#{prompt}_history")
14
+ @parent = parent
15
+ end
16
+
17
+ def run
18
+ setup_readline if @history
19
+ load_history if @history
20
+
21
+ show_boot_message
22
+
23
+ loop do
24
+ input = read_input
25
+ break if input.nil?
26
+
27
+ input = input.strip
28
+ next if input.empty?
29
+
30
+ args = input.split(/\s+/)
31
+
32
+ case args.first
33
+ when "exit", "quit", "q"
34
+ break
35
+ when "back"
36
+ break if @parent
37
+ warning "not in a sub-app"
38
+ when "help", "h", "?"
39
+ help
40
+ when "config", "c"
41
+ show_config
42
+ when "config:set"
43
+ set_config(args[1], args[2])
44
+ else
45
+ # Check if entering a mounted sub-app
46
+ mount = @app.find_mount(args.first)
47
+ if mount && args.size == 1
48
+ enter_subapp(mount, args.first)
49
+ else
50
+ dispatch(args)
51
+ end
52
+ end
53
+
54
+ nl
55
+ end
56
+
57
+ save_history if @history
58
+ hint "goodbye"
59
+ end
60
+
61
+ private
62
+
63
+ def show_boot_message
64
+ case @app.boot
65
+ when :help
66
+ show_boot_help
67
+ when :minimal
68
+ show_boot_minimal
69
+ when :none
70
+ # nothing
71
+ else
72
+ show_boot_help
73
+ end
74
+ end
75
+
76
+ def show_boot_help
77
+ banner @app.banner
78
+ nl
79
+ info @prompt.bold + " - interactive mode"
80
+ nl
81
+ help
82
+ nl
83
+ show_config
84
+ nl
85
+ end
86
+
87
+ def show_boot_minimal
88
+ banner @app.banner
89
+ nl
90
+ info @prompt.bold + " - interactive mode"
91
+ hint "type 'help' for commands, 'exit' to quit"
92
+ nl
93
+ show_config
94
+ nl
95
+ end
96
+
97
+ def enter_subapp(app_class, name)
98
+ sub_repl = REPL.new(
99
+ app_class,
100
+ prompt: "#{@prompt}:#{name}",
101
+ history: false,
102
+ parent: self
103
+ )
104
+ sub_repl.run
105
+ end
106
+
107
+ def dispatch(args)
108
+ cmd_name, *cmd_args = args
109
+ klass = @app.find_command(cmd_name)
110
+
111
+ if klass.nil?
112
+ warning "unknown command: #{cmd_name}"
113
+ hint "type 'help' for available commands"
114
+ return
115
+ end
116
+
117
+ positional, opts = klass.parse_options(cmd_args)
118
+
119
+ if klass.args.size > positional.size
120
+ warning "usage: #{klass.usage}"
121
+ return
122
+ end
123
+
124
+ klass.new.call(*positional, **opts)
125
+ end
126
+
127
+ def help
128
+ out.puts "commands:".bold
129
+
130
+ grouped = @app.commands.group_by(&:group)
131
+ ungrouped = grouped.delete(nil) || []
132
+
133
+ ungrouped.each do |cmd|
134
+ print_command(cmd)
135
+ end
136
+
137
+ grouped.keys.sort.each do |group_name|
138
+ nl
139
+ out.puts " #{group_name}:".bold
140
+ grouped[group_name].each do |cmd|
141
+ print_command(cmd, indent: 2)
142
+ end
143
+ end
144
+
145
+ if @app.mounts.any?
146
+ nl
147
+ out.puts " sub-apps:".bold
148
+ @app.mounts.each do |name, app_class|
149
+ out.puts " #{name.ljust(28)}enter #{app_class.banner.empty? ? name : app_class.banner}"
150
+ end
151
+ end
152
+
153
+ nl
154
+ out.puts " config, c".ljust(32) + "show current config"
155
+ out.puts " config:set <key> <value>".ljust(32) + "set a config value"
156
+ out.puts " help, h, ?".ljust(32) + "show this help"
157
+ out.puts " back".ljust(32) + "return to parent app" if @parent
158
+ out.puts " exit, quit, q".ljust(32) + "exit"
159
+ end
160
+
161
+ def print_command(cmd, indent: 0)
162
+ prefix = " " * (indent + 1)
163
+ out.puts "#{prefix}#{cmd.usage.ljust(30 - indent * 2)}#{cmd.desc}"
164
+ out.puts "#{prefix} aliases: #{cmd.aliases.join(', ')}".dim if cmd.aliases.any?
165
+ end
166
+
167
+ def setup_readline
168
+ Readline.completion_proc = proc do |input|
169
+ commands = @app.commands.flat_map { |c| [c.command_name.to_s] + c.aliases.map(&:to_s) }
170
+ builtins = %w[help h ? config c config:set exit quit q]
171
+ (commands + builtins).grep(/^#{Regexp.escape(input)}/)
172
+ end
173
+ end
174
+
175
+ def load_history
176
+ return unless File.exist?(@history_file)
177
+
178
+ File.readlines(@history_file).each do |line|
179
+ Readline::HISTORY << line.chomp
180
+ end
181
+ rescue StandardError
182
+ # ignore history load errors
183
+ end
184
+
185
+ def save_history
186
+ File.open(@history_file, "w") do |f|
187
+ Readline::HISTORY.to_a.last(1000).each do |line|
188
+ f.puts line
189
+ end
190
+ end
191
+ rescue StandardError
192
+ # ignore history save errors
193
+ end
194
+
195
+ def read_input
196
+ if @history
197
+ prompt_str = "#{@prompt}> "
198
+ line = Readline.readline(prompt_str, true)
199
+ Readline::HISTORY.pop if line&.strip&.empty?
200
+ line
201
+ else
202
+ Smol.output.print "#{@prompt}> ".yellow
203
+ Smol.input.gets
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,3 @@
1
+ module Smol
2
+ VERSION = "1.0.0"
3
+ end
data/lib/smol.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "readline"
5
+
6
+ require "smol/version"
7
+ require "smol/colors"
8
+ require "smol/coercion"
9
+ require "smol/app_lookup"
10
+ require "smol/check_result"
11
+ require "smol/output"
12
+ require "smol/input"
13
+ require "smol/config"
14
+ require "smol/config_display"
15
+ require "smol/check"
16
+ require "smol/command"
17
+ require "smol/app"
18
+ require "smol/repl"
19
+ require "smol/cli"
20
+
21
+ module Smol
22
+ class Error < StandardError; end
23
+
24
+ class << self
25
+ attr_writer :output, :input, :logger, :verbose, :quiet
26
+
27
+ def output
28
+ @output ||= $stdout
29
+ end
30
+
31
+ def input
32
+ @input ||= $stdin
33
+ end
34
+
35
+ def logger
36
+ @logger ||= Logger.new($stderr, level: Logger::WARN)
37
+ end
38
+
39
+ def verbose?
40
+ @verbose || ENV["VERBOSE"] == "1" || ENV["VERBOSE"] == "true"
41
+ end
42
+
43
+ def quiet?
44
+ @quiet || ENV["QUIET"] == "1" || ENV["QUIET"] == "true"
45
+ end
46
+
47
+ def debug?
48
+ @debug || ENV["DEBUG"] == "1" || ENV["DEBUG"] == "true"
49
+ end
50
+
51
+ def debug=(value)
52
+ @debug = value
53
+ end
54
+ end
55
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smol
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Josh Brody
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: readline
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Build CLI tools with commands, checks, and configuration. Supports both
41
+ single-command execution and interactive REPL mode.
42
+ email:
43
+ - gems@josh.mn
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - LICENSE.txt
50
+ - README.md
51
+ - Rakefile
52
+ - lib/smol.rb
53
+ - lib/smol/app.rb
54
+ - lib/smol/app_lookup.rb
55
+ - lib/smol/check.rb
56
+ - lib/smol/check_result.rb
57
+ - lib/smol/cli.rb
58
+ - lib/smol/coercion.rb
59
+ - lib/smol/colors.rb
60
+ - lib/smol/command.rb
61
+ - lib/smol/config.rb
62
+ - lib/smol/config_display.rb
63
+ - lib/smol/input.rb
64
+ - lib/smol/output.rb
65
+ - lib/smol/repl.rb
66
+ - lib/smol/version.rb
67
+ homepage: https://github.com/joshmn/smol
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ allowed_push_host: https://rubygems.org
72
+ homepage_uri: https://github.com/joshmn/smol
73
+ source_code_uri: https://github.com/joshmn/smol
74
+ changelog_uri: https://github.com/joshmn/smol/blob/main/CHANGELOG.md
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '2.4'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.1
90
+ specification_version: 4
91
+ summary: A small, zero-dependency CLI and REPL framework for Ruby
92
+ test_files: []