rooibos 0.5.0 → 0.6.1

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +57 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_rails_developers.md +17 -0
  53. data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
  54. data/doc/getting_started/for_react_developers.md +17 -0
  55. data/doc/getting_started/index.md +52 -0
  56. data/doc/getting_started/install.md +20 -0
  57. data/doc/getting_started/quickstart.md +9 -45
  58. data/doc/getting_started/ruby_primer.md +19 -0
  59. data/doc/getting_started/why_rooibos.md +20 -0
  60. data/doc/index.md +79 -11
  61. data/doc/scaling_up/async_patterns.md +20 -0
  62. data/doc/scaling_up/command_composition.md +20 -0
  63. data/doc/scaling_up/custom_commands.md +21 -0
  64. data/doc/scaling_up/fractal_architecture.md +20 -0
  65. data/doc/scaling_up/index.md +30 -0
  66. data/doc/scaling_up/message_routing.md +20 -0
  67. data/doc/scaling_up/ractor_safety.md +20 -0
  68. data/doc/scaling_up/testing.md +21 -0
  69. data/doc/troubleshooting/common_errors.md +20 -0
  70. data/doc/troubleshooting/debugging.md +21 -0
  71. data/doc/troubleshooting/index.md +23 -0
  72. data/doc/troubleshooting/performance.md +20 -0
  73. data/doc/tutorial/01_project_setup.md +44 -0
  74. data/doc/tutorial/02_hello_world.md +45 -0
  75. data/doc/tutorial/03_static_file_list.md +44 -0
  76. data/doc/tutorial/04_arrow_navigation.md +47 -0
  77. data/doc/tutorial/05_real_files.md +45 -0
  78. data/doc/tutorial/06_safe_refactoring.md +21 -0
  79. data/doc/tutorial/07_red_first_tdd.md +26 -0
  80. data/doc/tutorial/08_file_metadata.md +42 -0
  81. data/doc/tutorial/09_text_preview.md +44 -0
  82. data/doc/tutorial/10_directory_tree.md +42 -0
  83. data/doc/tutorial/11_pane_focus.md +40 -0
  84. data/doc/tutorial/12_sorting.md +41 -0
  85. data/doc/tutorial/13_filtering.md +43 -0
  86. data/doc/tutorial/14_toggle_hidden.md +41 -0
  87. data/doc/tutorial/15_text_input_widget.md +43 -0
  88. data/doc/tutorial/16_rename_files.md +42 -0
  89. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  90. data/doc/tutorial/18_progress_indicators.md +43 -0
  91. data/doc/tutorial/19_atomic_operations.md +42 -0
  92. data/doc/tutorial/20_external_editor.md +42 -0
  93. data/doc/tutorial/21_modal_overlays.md +41 -0
  94. data/doc/tutorial/22_error_handling.md +43 -0
  95. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  96. data/doc/tutorial/24_mouse_events.md +43 -0
  97. data/doc/tutorial/25_resize_events.md +43 -0
  98. data/doc/tutorial/26_loading_states.md +42 -0
  99. data/doc/tutorial/27_performance.md +43 -0
  100. data/doc/tutorial/28_color_schemes.md +47 -0
  101. data/doc/tutorial/29_configuration.md +124 -0
  102. data/doc/tutorial/30_going_further.md +17 -0
  103. data/doc/tutorial/index.md +17 -0
  104. data/examples/app_file_browser/app.rb +40 -0
  105. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  106. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  107. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  108. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  112. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  113. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  114. data/examples/verify_website_first_app/app.rb +85 -0
  115. data/examples/verify_website_hello_mvu/app.rb +31 -0
  116. data/examples/widget_command_system/app.rb +15 -13
  117. data/exe/rooibos +10 -0
  118. data/generate_tutorial_stubs.rb +126 -0
  119. data/lib/rooibos/cli/commands/new.rb +373 -0
  120. data/lib/rooibos/cli/commands/run.rb +98 -0
  121. data/lib/rooibos/cli.rb +78 -0
  122. data/lib/rooibos/command/all.rb +76 -23
  123. data/lib/rooibos/command/batch.rb +61 -34
  124. data/lib/rooibos/command/custom.rb +84 -1
  125. data/lib/rooibos/command/http.rb +121 -55
  126. data/lib/rooibos/command/lifecycle.rb +5 -5
  127. data/lib/rooibos/command/open.rb +93 -0
  128. data/lib/rooibos/command/outlet.rb +105 -3
  129. data/lib/rooibos/command/wait.rb +9 -6
  130. data/lib/rooibos/command.rb +114 -89
  131. data/lib/rooibos/message/batch.rb +39 -0
  132. data/lib/rooibos/message/canceled.rb +51 -0
  133. data/lib/rooibos/message/error.rb +48 -0
  134. data/lib/rooibos/message/open.rb +30 -0
  135. data/lib/rooibos/message.rb +84 -4
  136. data/lib/rooibos/router.rb +11 -14
  137. data/lib/rooibos/runtime.rb +40 -43
  138. data/lib/rooibos/shortcuts.rb +47 -0
  139. data/lib/rooibos/test_helper.rb +71 -6
  140. data/lib/rooibos/version.rb +1 -1
  141. data/lib/rooibos/welcome.rb +237 -0
  142. data/lib/rooibos.rb +4 -3
  143. data/mise.toml +1 -1
  144. data/rbs_collection.lock.yaml +2 -2
  145. data/sig/concurrent.rbs +4 -0
  146. data/sig/gem.rbs +20 -0
  147. data/sig/rooibos/cli.rbs +42 -0
  148. data/sig/rooibos/command.rbs +59 -7
  149. data/sig/rooibos/message.rbs +66 -2
  150. data/sig/rooibos/shortcuts.rbs +14 -0
  151. data/sig/rooibos/test_helper.rbs +6 -2
  152. data/sig/rooibos/welcome.rbs +75 -0
  153. data/tasks/install.rake +29 -0
  154. data/tasks/resources/build.yml.erb +2 -0
  155. metadata +274 -38
  156. data/doc/concepts/application_architecture.md +0 -197
  157. data/doc/concepts/application_testing.md +0 -49
  158. data/doc/concepts/async_work.md +0 -164
  159. data/doc/concepts/commands.md +0 -530
  160. data/doc/concepts/message_processing.md +0 -51
  161. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  162. data/doc/contributors/WIP/implementation_plan.md +0 -409
  163. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  164. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  165. data/doc/contributors/WIP/task.md +0 -36
  166. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  167. data/doc/contributors/kit-no-outlet.md +0 -238
  168. data/doc/contributors/priorities.md +0 -38
  169. data/doc/images/.gitkeep +0 -0
  170. data/exe/.gitkeep +0 -0
  171. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+
6
+ # Parse tutorial structure from documentation_plan.md
7
+ TUTORIAL_FILES = {
8
+ "index.md" => { title: "Tutorial: Build a File Browser", story: nil },
9
+ "01_project_setup.md" => { title: "Project Setup", story: "-4" },
10
+ "02_hello_world.md" => { title: "Hello World", story: "-3" },
11
+ "03_static_file_list.md" => { title: "Static File List", story: "-2" },
12
+ "04_arrow_navigation.md" => { title: "Arrow Navigation", story: "-1" },
13
+ "05_real_files.md" => { title: "Real Files", story: "0" },
14
+ "06_safe_refactoring.md" => { title: "Safe Refactoring", story: "4a" },
15
+ "07_red_first_tdd.md" => { title: "Red-First TDD", story: "4b" },
16
+ "08_file_metadata.md" => { title: "File Metadata", story: "5" },
17
+ "09_text_preview.md" => { title: "Text Preview", story: "6" },
18
+ "10_directory_tree.md" => { title: "Directory Tree", story: "7" },
19
+ "11_pane_focus.md" => { title: "Pane Focus", story: "8" },
20
+ "12_sorting.md" => { title: "Sorting", story: "9" },
21
+ "13_filtering.md" => { title: "Filtering", story: "10" },
22
+ "14_toggle_hidden.md" => { title: "Toggle Hidden Files", story: "11" },
23
+ "15_text_input_widget.md" => { title: "Text Input Widget", story: "12" },
24
+ "16_rename_files.md" => { title: "Rename Files", story: "13" },
25
+ "17_confirmation_dialogs.md" => { title: "Confirmation Dialogs", story: "14" },
26
+ "18_progress_indicators.md" => { title: "Progress Indicators", story: "15" },
27
+ "19_atomic_operations.md" => { title: "Atomic Operations", story: "16" },
28
+ "20_external_editor.md" => { title: "External Editor", story: "17" },
29
+ "21_modal_overlays.md" => { title: "Modal Overlays", story: "18" },
30
+ "22_error_handling.md" => { title: "Error Handling", story: "19" },
31
+ "23_terminal_capabilities.md" => { title: "Terminal Capabilities", story: "23" },
32
+ "24_mouse_events.md" => { title: "Mouse Events", story: "20" },
33
+ "25_resize_events.md" => { title: "Resize Events", story: "21" },
34
+ "26_loading_states.md" => { title: "Loading States", story: "22" },
35
+ "27_performance.md" => { title: "Performance", story: "24" },
36
+ "28_color_schemes.md" => { title: "Color Schemes", story: "26" },
37
+ "29_configuration.md" => { title: "Configuration", story: "27" },
38
+ "30_going_further.md" => { title: "Going Further", story: nil },
39
+ }
40
+
41
+ # Parse stories from file_browser_stories.md
42
+ stories_content = File.read("doc/contributors/specs/file_browser_stories.md")
43
+
44
+ # Extract each story
45
+ stories = {}
46
+ current_story = nil
47
+ current_content = []
48
+
49
+ stories_content.each_line do |line|
50
+ if line =~ /^## Story (-?\d+[ab]?): (.+)$/
51
+ # Save previous story
52
+ if current_story
53
+ stories[current_story] = current_content.join
54
+ end
55
+
56
+ current_story = $1
57
+ current_content = [line]
58
+ elsif current_story
59
+ current_content << line
60
+
61
+ # Stop at the next story or end of stories section
62
+ if line =~ /^---$/ && current_content.size > 5
63
+ # Check if next section is another story or implementation notes
64
+ peek_ahead = stories_content.lines[stories_content.lines.index(line) + 1]
65
+ if peek_ahead && !peek_ahead.start_with?("## Story")
66
+ stories[current_story] = current_content.join
67
+ break if peek_ahead.start_with?("## Implementation Notes")
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # Save last story
74
+ stories[current_story] = current_content.join if current_story
75
+
76
+ # Create tutorial directory
77
+ FileUtils.mkdir_p("doc/tutorial")
78
+
79
+ # Generate stub files
80
+ TUTORIAL_FILES.each do |filename, meta|
81
+ filepath = "doc/tutorial/#{filename}"
82
+
83
+ # Determine previous/next files
84
+ files_list = TUTORIAL_FILES.keys
85
+ current_index = files_list.index(filename)
86
+ prev_file = (current_index > 0) ? files_list[current_index - 1] : nil
87
+ next_file = (current_index < files_list.size - 1) ? files_list[current_index + 1] : nil
88
+
89
+ prev_title = prev_file ? TUTORIAL_FILES[prev_file][:title] : nil
90
+ next_title = next_file ? TUTORIAL_FILES[next_file][:title] : nil
91
+
92
+ # Get story content if applicable
93
+ story_section = ""
94
+ if meta[:story]
95
+ story_key = meta[:story]
96
+ if stories[story_key]
97
+ story_section = "\n## User Stories\n\n#{stories[story_key]}\n"
98
+ end
99
+ end
100
+
101
+ # Generate stub content
102
+ content = <<~MARKDOWN
103
+ <!--
104
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
105
+ SPDX-License-Identifier: CC-BY-SA-4.0
106
+ -->
107
+
108
+ # #{meta[:title]}
109
+
110
+
111
+ By the end of this guide, you will:
112
+
113
+ - TODO: Write learning objectives
114
+
115
+ > ⚠️ **This page is a stub.** Help us write it! See the [Documentation Plan](../contributors/documentation_plan.md) and [Style Guide](../contributors/documentation_style.md).
116
+ #{story_section}
117
+ ---
118
+
119
+ #{prev_file ? "[**Previous:** #{prev_title}](./#{prev_file})" : ''} | #{next_file ? "[**Next:** #{next_title}](./#{next_file})" : ''}
120
+ MARKDOWN
121
+
122
+ File.write(filepath, content)
123
+ puts "Created #{filepath}"
124
+ end
125
+
126
+ puts "\nDone! Created #{TUTORIAL_FILES.size} tutorial stub files."
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "open3"
9
+ require "fileutils"
10
+ require "pathname"
11
+ require "optparse"
12
+
13
+ module Rooibos
14
+ module CLI
15
+ module Commands # :nodoc:
16
+ # Scaffolds a new Rooibos TUI application.
17
+ #
18
+ # Starting a TUI project from scratch is tedious. Gem structure,
19
+ # test setup, executable wiring, and dependency management all
20
+ # take time before you write your first line of application code.
21
+ #
22
+ # This command delegates to +bundle gem+ for the boilerplate, then
23
+ # customizes the result for Rooibos. It creates a working Model-View-Update
24
+ # skeleton with a passing test.
25
+ #
26
+ # Use it to bootstrap new projects.
27
+ #
28
+ # === Example
29
+ #
30
+ # rooibos new my_app
31
+ # rooibos new my_app --no-git
32
+ # rooibos new my_app --test=rspec # warns about TestHelper
33
+ module New
34
+ # Default flags for bundle gem.
35
+ # Note: We use --no-bundle so we can add rooibos to Gemfile first.
36
+ BUNDLE_GEM_DEFAULTS = %w[
37
+ --exe
38
+ --no-coc
39
+ --changelog
40
+ --no-ext
41
+ --git
42
+ --no-mit
43
+ --test=minitest
44
+ --no-ci
45
+ --linter=rubocop
46
+ --no-bundle
47
+ ].freeze
48
+
49
+ # Runs the new command.
50
+ #
51
+ # [argv] Command-line arguments (expects app name as first element).
52
+ def self.call(argv)
53
+ options = parse_options(argv)
54
+
55
+ if options[:help]
56
+ puts usage
57
+ exit(0)
58
+ end
59
+
60
+ if argv.empty?
61
+ warn "Error: Missing application name"
62
+ warn usage
63
+ exit(1)
64
+ end
65
+
66
+ app_name = argv.shift
67
+ passthrough_args = argv # Remaining args passed to bundle gem
68
+
69
+ create_app(app_name, passthrough_args, options)
70
+ end
71
+
72
+ # Returns command-specific usage.
73
+ def self.usage
74
+ <<~USAGE
75
+ Usage: rooibos new <appname> [options]
76
+
77
+ Creates a new Rooibos TUI application using `bundle gem`.
78
+
79
+ Arguments:
80
+ <appname> Name of the application to create
81
+
82
+ Options:
83
+ --help, -h Show this help
84
+
85
+ Bundle Gem Defaults (can be overridden):
86
+ --exe Create executable (enabled by default)
87
+ --no-coc No Code of Conduct (default)
88
+ --changelog Generate CHANGELOG.md (default)
89
+ --no-ext No native extension (default)
90
+ --git Initialize git repo (default)
91
+ --no-mit No MIT license (default)
92
+ --test=minitest Use Minitest (default, recommended)
93
+ --no-ci No CI config (default)
94
+ --linter=rubocop Use RuboCop (default)
95
+ --bundle Run bundle install (default)
96
+
97
+ Any bundle gem option can be passed through, for example:
98
+ rooibos new my_app --no-git
99
+ rooibos new my_app --test=rspec # warns about TestHelper
100
+
101
+ Note: Rooibos::TestHelper is only verified to work with Minitest.
102
+ USAGE
103
+ end
104
+
105
+ def self.parse_options(argv)
106
+ options = { help: false, test_framework: "minitest", skip_bundle: false }
107
+
108
+ # Extract --help before OptionParser to avoid conflicts with passthrough
109
+ if argv.include?("--help") || argv.include?("-h")
110
+ argv.delete("--help")
111
+ argv.delete("-h")
112
+ options[:help] = true
113
+ end
114
+
115
+ # Detect test framework from passthrough args
116
+ argv.each do |arg|
117
+ if arg.start_with?("--test=")
118
+ options[:test_framework] = arg.sub("--test=", "")
119
+ end
120
+ end
121
+
122
+ # Detect if user wants to skip bundle install
123
+ if argv.include?("--no-bundle")
124
+ options[:skip_bundle] = true
125
+ end
126
+
127
+ options
128
+ end
129
+ private_class_method :parse_options
130
+
131
+ def self.create_app(app_name, passthrough_args, options)
132
+ puts "Creating new Rooibos application: #{app_name}"
133
+
134
+ # Warn about non-minitest frameworks
135
+ test_framework = options[:test_framework].to_s
136
+ if %w[rspec test-unit].include?(test_framework)
137
+ warn "Warning: Rooibos::TestHelper has not been verified to work with #{test_framework}."
138
+ warn " You may need to adapt the test helpers for your framework."
139
+ end
140
+
141
+ # Build bundle gem command
142
+ bundle_args = build_bundle_gem_args(passthrough_args)
143
+ cmd = ["bundle", "gem", app_name] + bundle_args
144
+
145
+ puts "Running: #{cmd.join(' ')}"
146
+ stdout, stderr, status = Open3.capture3(*cmd)
147
+ unless status.success?
148
+ warn "Error running bundle gem:"
149
+ warn stderr
150
+ exit(1)
151
+ end
152
+ puts stdout
153
+
154
+ # Determine the normalized gem name by looking at lib/
155
+ app_path = Pathname.new(app_name)
156
+ lib_path = app_path / "lib"
157
+ lib_files = Dir.glob(lib_path / "*.rb")
158
+ gem_name = lib_files.map { |f| File.basename(f, ".rb") }
159
+ .reject { |n| n.end_with?("_version") }
160
+ .first
161
+
162
+ unless gem_name
163
+ warn "Could not determine gem name from lib/ directory"
164
+ exit(1)
165
+ end
166
+
167
+ module_name = to_module_name(gem_name)
168
+
169
+ # Overwrite lib/<gem_name>.rb with Rooibos template
170
+ lib_file = lib_path / "#{gem_name}.rb"
171
+ File.write(lib_file.to_s, app_template(gem_name, module_name))
172
+ puts "Updated #{lib_file}"
173
+
174
+ # Update the bundler-created executable to call Rooibos.run
175
+ exe_file = app_path / "exe" / gem_name
176
+ if exe_file.exist?
177
+ File.write(exe_file.to_s, exe_template(gem_name, module_name))
178
+ puts "Updated #{exe_file}"
179
+ end
180
+
181
+ # Add rooibos as runtime dependency to gemspec (not Gemfile) for dev/prod parity
182
+ gemspec_files = Dir.glob(app_path / "*.gemspec")
183
+ if gemspec_files.any?
184
+ gemspec_file = gemspec_files.first
185
+ content = File.read(gemspec_file)
186
+
187
+ # Comment out placeholder lines that RubyGems 3.x validates
188
+ content = fix_gemspec_placeholders(content)
189
+
190
+ # Check if already has rooibos dependency
191
+ unless content.match?(/add_(?:runtime_)?dependency.*rooibos/)
192
+ # Use Gem::Version to handle prerelease versions correctly
193
+ segments = Gem::Version.new(Rooibos::VERSION).segments
194
+ minor_version = segments.first(2).map(&:to_s).join(".")
195
+ # Insert before the final 'end' of the Gem::Specification block
196
+ content = content.sub(
197
+ /^end\s*\z/m,
198
+ "\n # https://www.rooibos.run\n spec.add_runtime_dependency \"rooibos\", \"~> #{minor_version}\"\nend\n"
199
+ )
200
+ end
201
+
202
+ File.write(gemspec_file, content)
203
+ puts "Updated #{gemspec_file}"
204
+ end
205
+
206
+ # Run bundle install unless user passed --no-bundle
207
+ unless options[:skip_bundle]
208
+ Dir.chdir(app_path) do
209
+ puts "Running bundle install..."
210
+ system("bundle", "install")
211
+ end
212
+ end
213
+
214
+ # Update test infrastructure for Rooibos
215
+ if test_framework == "minitest"
216
+ # Add rooibos/test_helper to the project's test_helper.rb
217
+ test_helper = app_path / "test" / "test_helper.rb"
218
+ if test_helper.exist?
219
+ content = File.read(test_helper.to_s)
220
+ unless content.include?("rooibos/test_helper")
221
+ # Add after the gem require line
222
+ content = content.sub(
223
+ /require\s+["']#{gem_name}["']/,
224
+ "\\0\nrequire \"rooibos/test_helper\""
225
+ )
226
+ File.write(test_helper.to_s, content)
227
+ puts "Updated #{test_helper}"
228
+ end
229
+ end
230
+
231
+ # Replace test file with real Rooibos test
232
+ test_file = app_path / "test" / "test_#{gem_name}.rb"
233
+ if test_file.exist?
234
+ File.write(test_file.to_s, test_template(gem_name, module_name))
235
+ puts "Updated #{test_file}"
236
+ end
237
+ end
238
+
239
+ # Make initial git commit if git is enabled and bundle didn't
240
+ if git_enabled?(passthrough_args)
241
+ make_initial_commit(app_path)
242
+ end
243
+
244
+ puts "\nDone! Your Rooibos application is ready."
245
+ puts " cd #{app_name}"
246
+ puts " rooibos run"
247
+ end
248
+ private_class_method :create_app
249
+
250
+ def self.build_bundle_gem_args(passthrough_args)
251
+ # Start with defaults
252
+ args = BUNDLE_GEM_DEFAULTS.dup
253
+
254
+ # Override with user-provided args (later args win in bundle gem)
255
+ args + passthrough_args
256
+ end
257
+ private_class_method :build_bundle_gem_args
258
+
259
+ def self.git_enabled?(passthrough_args)
260
+ # Git is enabled by default unless --no-git is passed
261
+ !passthrough_args.include?("--no-git")
262
+ end
263
+ private_class_method :git_enabled?
264
+
265
+ def self.make_initial_commit(app_path)
266
+ Dir.chdir(app_path) do
267
+ # Check if there are uncommitted changes
268
+ _, _, status = Open3.capture3("git", "status", "--porcelain")
269
+ return unless status.success?
270
+
271
+ uncommitted, = Open3.capture3("git", "status", "--porcelain")
272
+ return if uncommitted.strip.empty?
273
+
274
+ # Stage and commit
275
+ system("git", "add", "-A", out: File::NULL, err: File::NULL)
276
+ system("git", "commit", "-m", "Hello, Rooibos!\n\nhttps://www.rooibos.run",
277
+ out: File::NULL, err: File::NULL)
278
+ puts "Created initial git commit"
279
+ end
280
+ end
281
+ private_class_method :make_initial_commit
282
+
283
+ def self.to_module_name(gem_name)
284
+ gem_name.split(/[-_]/).map(&:capitalize).join
285
+ end
286
+ private_class_method :to_module_name
287
+
288
+ # Fixes gemspec placeholder lines that RubyGems 3.x+ rejects.
289
+ # Required fields get valid placeholder values; optional metadata block is deleted.
290
+ def self.fix_gemspec_placeholders(content)
291
+ result = content
292
+
293
+ # Replace required field TODOs with valid placeholders
294
+ result = result.gsub(
295
+ /^(\s*spec\.summary\s*=\s*)"TODO:[^"]*"/,
296
+ '\1"A Rooibos TUI application"'
297
+ )
298
+ result = result.gsub(
299
+ /^(\s*spec\.description\s*=\s*)"TODO:[^"]*"/,
300
+ '\1"A terminal user interface application built with Rooibos"'
301
+ )
302
+ result = result.gsub(
303
+ /^(\s*spec\.homepage\s*=\s*)"TODO:[^"]*"/,
304
+ '\1"https://www.rooibos.run"'
305
+ )
306
+ result = result.gsub(
307
+ /^(\s*spec\.authors\s*=\s*)\["TODO:[^\]]*"\]/,
308
+ '\1["Author"]'
309
+ )
310
+ result = result.gsub(
311
+ /^(\s*spec\.email\s*=\s*)\["TODO:[^\]]*"\]/,
312
+ '\1["author@example.com"]'
313
+ )
314
+
315
+ # Delete the entire metadata block (optional and causes validation issues)
316
+ result.gsub(/^\s*spec\.metadata\["[^"]*"\]\s*=.*\n/, "")
317
+ end
318
+ private_class_method :fix_gemspec_placeholders
319
+
320
+ def self.exe_template(gem_name, module_name)
321
+ <<~RUBY
322
+ #!/usr/bin/env ruby
323
+ # frozen_string_literal: true
324
+
325
+ require "#{gem_name}"
326
+ Rooibos.run(#{module_name})
327
+ RUBY
328
+ end
329
+ private_class_method :exe_template
330
+
331
+ def self.app_template(gem_name, module_name)
332
+ <<~RUBY
333
+ # frozen_string_literal: true
334
+
335
+ require "rooibos"
336
+ require "rooibos/welcome"
337
+
338
+ # To get started, replace the following lines with your own code
339
+ # and remove the `require "rooibos/welcome"` line from above
340
+ module #{module_name}
341
+ Model = Rooibos::Welcome::Model
342
+ View = Rooibos::Welcome::View
343
+ Update = Rooibos::Welcome::Update
344
+ Init = Rooibos::Welcome::Init
345
+ end
346
+ RUBY
347
+ end
348
+ private_class_method :app_template
349
+
350
+ def self.test_template(gem_name, module_name)
351
+ <<~RUBY
352
+ # frozen_string_literal: true
353
+
354
+ require "test_helper"
355
+
356
+ class Test#{module_name} < Minitest::Test
357
+ include Rooibos::TestHelper
358
+
359
+ def test_it_exits_with_ctrl_c
360
+ with_test_terminal do
361
+ inject_key(:ctrl_c)
362
+ Rooibos.run(#{module_name})
363
+ assert true, "Should reach this point without hanging."
364
+ end
365
+ end
366
+ end
367
+ RUBY
368
+ end
369
+ private_class_method :test_template
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "pathname"
9
+ require "rbconfig"
10
+
11
+ module Rooibos
12
+ module CLI
13
+ module Commands
14
+ # Runs the Rooibos application in the current directory.
15
+ #
16
+ # Developers switch between editor and terminal constantly. Typing
17
+ # +bundle exec exe/my_app+ every time is tedious and error-prone.
18
+ #
19
+ # This command finds the executable in +exe/+ and runs it via bundler.
20
+ # It walks up the directory tree to find the project root, then
21
+ # executes the first executable it finds.
22
+ #
23
+ # Use it from any directory within your project.
24
+ #
25
+ # === Example
26
+ #
27
+ # rooibos run
28
+ module Run
29
+ # Runs the run command.
30
+ #
31
+ # [argv] Command-line arguments.
32
+ def self.call(argv)
33
+ if ["--help", "-h"].include?(argv.first)
34
+ puts usage
35
+ exit(0)
36
+ end
37
+
38
+ run_app
39
+ end
40
+
41
+ # Returns command-specific usage.
42
+ def self.usage # :nodoc:
43
+ <<~USAGE
44
+ Usage: rooibos run [options]
45
+
46
+ Runs the Rooibos application in the current directory.
47
+
48
+ Options:
49
+ --help, -h Show this help
50
+ USAGE
51
+ end
52
+ private_class_method :usage
53
+
54
+ def self.run_app # :nodoc:
55
+ project_root = find_project_root
56
+ unless project_root
57
+ warn "Error: Not in a Rooibos project directory"
58
+ warn "Could not find a .gemspec or Gemfile"
59
+ exit(1)
60
+ end
61
+
62
+ exe_dir = project_root / "exe"
63
+ unless exe_dir.directory?
64
+ warn "Error: No exe/ directory found"
65
+ exit(1)
66
+ end
67
+
68
+ executables = Dir.glob(exe_dir / "*").select { |f| File.executable?(f) }
69
+ if executables.empty?
70
+ warn "Error: No executable found in exe/"
71
+ exit(1)
72
+ end
73
+
74
+ executable = executables.first
75
+ puts "Running #{File.basename(executable)}..."
76
+
77
+ # Run with bundler/setup for gem activation, but skip bundle exec CLI
78
+ # to produce clean stack traces without bundler/thor frames
79
+ Dir.chdir(project_root)
80
+ exec(RbConfig.ruby, "-rbundler/setup", executable)
81
+ end
82
+ private_class_method :run_app
83
+
84
+ def self.find_project_root # :nodoc:
85
+ current = Pathname.pwd
86
+ loop do
87
+ return current if (current / "Gemfile").exist?
88
+ return current if Dir.glob(current / "*.gemspec").any?
89
+ parent = current.parent
90
+ return nil if parent == current
91
+ current = parent
92
+ end
93
+ end
94
+ private_class_method :find_project_root
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "version"
9
+ require_relative "cli/commands/new"
10
+ require_relative "cli/commands/run"
11
+
12
+ module Rooibos
13
+ # Entry point for the Rooibos command-line interface.
14
+ #
15
+ # Rooibos provides a CLI for common development tasks. Rather than
16
+ # remembering incantations for each tool, use a single command.
17
+ #
18
+ # This module dispatches to subcommands. It routes <tt>new</tt> to
19
+ # project scaffolding and <tt>run</tt> to application execution.
20
+ #
21
+ # Use it via the +rooibos+ executable.
22
+ #
23
+ # === Example
24
+ #
25
+ # # From terminal:
26
+ # rooibos new my_app
27
+ # cd my_app
28
+ # rooibos run
29
+ #
30
+ # # Programmatic access:
31
+ # Rooibos::CLI.call(["new", "my_app"])
32
+ # Rooibos::CLI.call(["run"])
33
+ module CLI
34
+ # Maps command names to handler modules.
35
+ COMMANDS = { # :nodoc:
36
+ "new" => Commands::New,
37
+ "run" => Commands::Run,
38
+ }.freeze
39
+
40
+ # Entry point for the CLI.
41
+ #
42
+ # [argv] Command-line arguments array.
43
+ def self.call(argv)
44
+ command_name = argv.shift
45
+
46
+ case command_name
47
+ when "--version", "-v"
48
+ puts "Rooibos #{Rooibos::VERSION}"
49
+ when "--help", "-h", nil
50
+ puts usage
51
+ else
52
+ command = COMMANDS[command_name]
53
+ if command
54
+ command.call(argv)
55
+ else
56
+ warn "Unknown command: #{command_name}"
57
+ warn usage
58
+ exit(1)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Returns the main usage message.
64
+ def self.usage
65
+ <<~USAGE
66
+ Usage: rooibos <command> [options]
67
+
68
+ Commands:
69
+ new <appname> Create a new Rooibos application
70
+ run Run the application in the current directory
71
+
72
+ Options:
73
+ --version, -v Show version
74
+ --help, -h Show this help
75
+ USAGE
76
+ end
77
+ end
78
+ end