parabot 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.
- checksums.yaml +7 -0
- data/.rubocop.yml +55 -0
- data/CLAUDE.md +171 -0
- data/DISTRIBUTION.md +287 -0
- data/INSTALL.md +242 -0
- data/LICENSE +21 -0
- data/README.md +371 -0
- data/Rakefile +50 -0
- data/config/base.yml +12 -0
- data/config/commands.yml +28 -0
- data/config/core_prompts/system_prompt.yml +66 -0
- data/config/core_prompts/test_guidance.yml +24 -0
- data/config/languages/elixir.yml +62 -0
- data/config/languages/javascript.yml +64 -0
- data/config/languages/kotlin.yml +64 -0
- data/config/languages/ruby.yml +66 -0
- data/config/languages/shell.yml +63 -0
- data/config/languages/typescript.yml +63 -0
- data/exe/parabot +22 -0
- data/lib/parabot/cli/argument_parser.rb +105 -0
- data/lib/parabot/cli/command_router.rb +114 -0
- data/lib/parabot/cli.rb +71 -0
- data/lib/parabot/commands/base.rb +108 -0
- data/lib/parabot/commands/custom_commands.rb +63 -0
- data/lib/parabot/commands/doctor.rb +196 -0
- data/lib/parabot/commands/init.rb +171 -0
- data/lib/parabot/commands/message.rb +25 -0
- data/lib/parabot/commands/start.rb +35 -0
- data/lib/parabot/commands/test.rb +43 -0
- data/lib/parabot/commands/version.rb +17 -0
- data/lib/parabot/commands.rb +15 -0
- data/lib/parabot/configuration.rb +199 -0
- data/lib/parabot/dry_run_logging.rb +22 -0
- data/lib/parabot/errors.rb +12 -0
- data/lib/parabot/language_detector.rb +158 -0
- data/lib/parabot/language_inference.rb +82 -0
- data/lib/parabot/logging_setup.rb +73 -0
- data/lib/parabot/messaging/adapter.rb +53 -0
- data/lib/parabot/messaging/adapter_factory.rb +33 -0
- data/lib/parabot/messaging/dry_run_adapter.rb +55 -0
- data/lib/parabot/messaging/tmux_adapter.rb +82 -0
- data/lib/parabot/system.rb +41 -0
- data/lib/parabot/test_runner.rb +179 -0
- data/lib/parabot/tmux_manager.rb +245 -0
- data/lib/parabot/version.rb +5 -0
- data/lib/parabot/yaml_text_assembler.rb +155 -0
- data/lib/parabot.rb +30 -0
- data/parabot.gemspec +44 -0
- data/scripts/build-distribution +122 -0
- data/scripts/install +152 -0
- metadata +221 -0
@@ -0,0 +1,245 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "ostruct"
|
5
|
+
require "shellwords"
|
6
|
+
require_relative "dry_run_logging"
|
7
|
+
|
8
|
+
module Parabot
|
9
|
+
class TmuxManager
|
10
|
+
include DryRunLogging
|
11
|
+
|
12
|
+
def initialize(dry_run: false)
|
13
|
+
@dry_run = dry_run
|
14
|
+
end
|
15
|
+
|
16
|
+
def find_claude_session
|
17
|
+
# First try to find by name pattern
|
18
|
+
sessions = list_sessions
|
19
|
+
claude_session = sessions.find { |session| session.match?(/claude/i) }
|
20
|
+
return claude_session if claude_session
|
21
|
+
|
22
|
+
# Then check for Claude processes in any session
|
23
|
+
sessions.each do |session|
|
24
|
+
panes = list_panes(session)
|
25
|
+
claude_pane = panes.find { |pane| pane[:command].match?(/claude/i) }
|
26
|
+
return session if claude_pane
|
27
|
+
end
|
28
|
+
|
29
|
+
nil
|
30
|
+
rescue SystemCallError
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def find_claude_target(session)
|
35
|
+
# First check current window for Claude
|
36
|
+
current_window = current_window_index(session)
|
37
|
+
claude_pane = find_claude_pane_in_window(session, current_window)
|
38
|
+
return "#{current_window}:#{claude_pane}" if claude_pane
|
39
|
+
|
40
|
+
# Check for window named claude
|
41
|
+
windows = list_windows(session)
|
42
|
+
claude_window = windows.find { |w| w[:name].match?(/claude/i) }
|
43
|
+
|
44
|
+
if claude_window
|
45
|
+
claude_pane = find_claude_pane_in_window(session, claude_window[:index])
|
46
|
+
return "#{claude_window[:index]}:#{claude_pane || 0}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check all windows for Claude process
|
50
|
+
windows.each do |window|
|
51
|
+
next if window[:index] == current_window
|
52
|
+
|
53
|
+
claude_pane = find_claude_pane_in_window(session, window[:index])
|
54
|
+
return "#{window[:index]}:#{claude_pane}" if claude_pane
|
55
|
+
end
|
56
|
+
|
57
|
+
# Default to current window, first pane
|
58
|
+
"#{current_window}:0"
|
59
|
+
rescue SystemCallError
|
60
|
+
"0:0"
|
61
|
+
end
|
62
|
+
|
63
|
+
def send_to_claude(message)
|
64
|
+
session = find_claude_session
|
65
|
+
raise TmuxError, "No tmux session with Claude found" unless session
|
66
|
+
|
67
|
+
target = find_claude_target(session)
|
68
|
+
logger.info("Found Claude in session '#{session}', target '#{target}'")
|
69
|
+
|
70
|
+
# Send the message
|
71
|
+
logger.info("Sending message to Claude...")
|
72
|
+
send_keys(session, target, "Escape")
|
73
|
+
wait_for_claude(0.1)
|
74
|
+
send_keys(session, target, message)
|
75
|
+
wait_for_claude(0.2)
|
76
|
+
send_keys(session, target, "Enter")
|
77
|
+
wait_for_claude(0.1)
|
78
|
+
send_keys(session, target, "Enter")
|
79
|
+
|
80
|
+
logger.info("Message sent successfully")
|
81
|
+
true
|
82
|
+
rescue SystemCallError => e
|
83
|
+
raise TmuxError, "Failed to send message to Claude: #{e.message}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def start_claude_session(system_prompt: nil)
|
87
|
+
current_session = current_session_name
|
88
|
+
raise TmuxError, "Not in a tmux session. Please run this command from within tmux." unless current_session
|
89
|
+
|
90
|
+
logger.info("Creating vertical pane (30% width)...")
|
91
|
+
|
92
|
+
# Create vertical pane on the left (30% of screen)
|
93
|
+
result = run_command("tmux split-window -h -l 30% -c '#{Dir.pwd}' -b")
|
94
|
+
raise TmuxError, "Failed to create tmux pane" unless result.success?
|
95
|
+
|
96
|
+
# Get the newly created pane
|
97
|
+
new_pane = run_command('tmux display-message -p \'#{pane_index}\'').out.strip
|
98
|
+
|
99
|
+
# Rename the pane for easier identification
|
100
|
+
run_command("tmux select-pane -t #{new_pane} -T claude")
|
101
|
+
|
102
|
+
# Start claude with system prompt if provided
|
103
|
+
claude_command = if system_prompt
|
104
|
+
escaped_prompt = system_prompt.shellescape
|
105
|
+
"claude --system-prompt #{escaped_prompt}"
|
106
|
+
else
|
107
|
+
"claude"
|
108
|
+
end
|
109
|
+
|
110
|
+
# Send keys to start claude (let tmux use current context like shell version)
|
111
|
+
run_command("tmux send-keys -t #{new_pane} #{claude_command.shellescape} Enter")
|
112
|
+
|
113
|
+
# Switch back to original pane
|
114
|
+
run_command("tmux select-pane -R")
|
115
|
+
|
116
|
+
logger.info("Claude session started in pane #{new_pane}")
|
117
|
+
logger.info("Use 'Ctrl-B + arrow keys' to navigate between panes")
|
118
|
+
true
|
119
|
+
rescue SystemCallError => e
|
120
|
+
raise TmuxError, "Failed to start Claude session: #{e.message}"
|
121
|
+
end
|
122
|
+
|
123
|
+
def list_sessions
|
124
|
+
result = run_command("tmux list-sessions")
|
125
|
+
return [] unless result.success?
|
126
|
+
|
127
|
+
result.out.lines.map do |line|
|
128
|
+
line.split(":").first.strip
|
129
|
+
end
|
130
|
+
rescue SystemCallError
|
131
|
+
[]
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def dry_run?
|
137
|
+
!!@dry_run
|
138
|
+
end
|
139
|
+
|
140
|
+
def current_session_name
|
141
|
+
result = run_command('tmux display-message -p \'#{session_name}\'')
|
142
|
+
result.success? ? result.out.strip : nil
|
143
|
+
rescue SystemCallError
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
|
147
|
+
def current_window_index(session)
|
148
|
+
command = "tmux display-message -t " + session + ' -p \'#{window_index}\''
|
149
|
+
result = run_command(command)
|
150
|
+
result.success? ? result.out.strip : "0"
|
151
|
+
rescue SystemCallError
|
152
|
+
"0"
|
153
|
+
end
|
154
|
+
|
155
|
+
def list_panes(session)
|
156
|
+
command = "tmux list-panes -t " + session + ' -F \'#{pane_index}:#{pane_current_command}\''
|
157
|
+
result = run_command(command)
|
158
|
+
return [] unless result.success?
|
159
|
+
|
160
|
+
result.out.lines.map do |line|
|
161
|
+
index, command = line.strip.split(":", 2)
|
162
|
+
{ index: index, command: command }
|
163
|
+
end
|
164
|
+
rescue SystemCallError
|
165
|
+
[]
|
166
|
+
end
|
167
|
+
|
168
|
+
def list_windows(session)
|
169
|
+
command = "tmux list-windows -t " + session + ' -F \'#{window_index}:#{window_name}\''
|
170
|
+
result = run_command(command)
|
171
|
+
return [] unless result.success?
|
172
|
+
|
173
|
+
result.out.lines.map do |line|
|
174
|
+
index, name = line.strip.split(":", 2)
|
175
|
+
{ index: index, name: name }
|
176
|
+
end
|
177
|
+
rescue SystemCallError
|
178
|
+
[]
|
179
|
+
end
|
180
|
+
|
181
|
+
def find_claude_pane_in_window(session, window)
|
182
|
+
command = "tmux list-panes -t " + session + ":" + window + ' -F \'#{pane_index}:#{pane_current_command}\''
|
183
|
+
panes = run_command(command)
|
184
|
+
return nil unless panes.success?
|
185
|
+
|
186
|
+
claude_pane = panes.out.lines.find { |line| line.match?(/claude/i) }
|
187
|
+
claude_pane&.split(":")&.first
|
188
|
+
rescue SystemCallError
|
189
|
+
nil
|
190
|
+
end
|
191
|
+
|
192
|
+
def send_keys(session, target, keys)
|
193
|
+
# Convert window:pane format to window.pane format for tmux
|
194
|
+
target_formatted = target.include?(":") ? target.tr(":", ".") : target
|
195
|
+
run_command("tmux send-keys -t #{session}:#{target_formatted} #{keys.shellescape}")
|
196
|
+
end
|
197
|
+
|
198
|
+
def wait_for_claude(seconds)
|
199
|
+
sleep(seconds) unless dry_run?
|
200
|
+
end
|
201
|
+
|
202
|
+
def run_command(command)
|
203
|
+
if dry_run?
|
204
|
+
dry_run_log(command)
|
205
|
+
# Return realistic mock output based on command type
|
206
|
+
mock_output = case command
|
207
|
+
when /list-sessions/
|
208
|
+
"main: 1 windows (created Mon Jan 1 12:00:00 2024)\nclaude: 1 windows (created Mon Jan 1 12:00:00 2024)"
|
209
|
+
when /display-message.*session_name/
|
210
|
+
"main"
|
211
|
+
when /display-message.*pane_index/
|
212
|
+
"1"
|
213
|
+
when /display-message.*window_index/
|
214
|
+
"0"
|
215
|
+
when /list-panes.*pane_index:pane_current_command/
|
216
|
+
"0:bash\n1:claude"
|
217
|
+
when /list-windows/
|
218
|
+
"0: main* (1 panes) [80x24] [layout active]\n1: claude- (1 panes) [80x24]"
|
219
|
+
else
|
220
|
+
""
|
221
|
+
end
|
222
|
+
|
223
|
+
OpenStruct.new(
|
224
|
+
out: mock_output,
|
225
|
+
err: "",
|
226
|
+
success?: true,
|
227
|
+
exit_status: 0
|
228
|
+
)
|
229
|
+
else
|
230
|
+
stdout, stderr, status = Open3.capture3(command)
|
231
|
+
OpenStruct.new(
|
232
|
+
out: stdout,
|
233
|
+
err: stderr,
|
234
|
+
success?: status.success?,
|
235
|
+
exit_status: status.exitstatus
|
236
|
+
)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def logger
|
241
|
+
require "semantic_logger"
|
242
|
+
SemanticLogger[self.class.name]
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "logger"
|
5
|
+
require_relative "system"
|
6
|
+
|
7
|
+
module Parabot
|
8
|
+
class YamlTextAssembler
|
9
|
+
def self.assemble_for_languages(detected_languages)
|
10
|
+
supported, unsupported = partition_language_support(detected_languages)
|
11
|
+
warn_about_unsupported_languages(unsupported) if unsupported.any?
|
12
|
+
|
13
|
+
# Convert symbols to strings for consistent processing
|
14
|
+
detected_strings = detected_languages.map(&:to_s)
|
15
|
+
|
16
|
+
# Build YAML as text directly to preserve formatting
|
17
|
+
build_yaml_text(detected_strings, supported, unsupported)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.supported_languages
|
21
|
+
@supported_languages ||= Dir.glob(System.determine_config_path("languages/*.yml"))
|
22
|
+
.map { |file| File.basename(file, ".yml") }
|
23
|
+
.sort
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.partition_language_support(languages)
|
27
|
+
string_languages = languages.map(&:to_s)
|
28
|
+
supported = string_languages.select { |lang| supported_languages.include?(lang) }
|
29
|
+
unsupported = string_languages - supported
|
30
|
+
[supported, unsupported]
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.warn_about_unsupported_languages(unsupported)
|
34
|
+
unsupported.each do |lang|
|
35
|
+
logger.warn("#{lang.capitalize} has minimal support - using basic TDD guidance")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.logger
|
40
|
+
@logger ||= Logger.new($stderr).tap { |l| l.level = Logger::WARN }
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.read_config_file(path)
|
44
|
+
File.read(System.determine_config_path(path))
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.indent_lines(content, spaces = 2)
|
48
|
+
prefix = " " * spaces
|
49
|
+
content.lines.map { |line| line.empty? ? line : "#{prefix}#{line}" }.join
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.build_yaml_block(key, content, style = "|")
|
53
|
+
"#{key}: #{style}\n" + indent_lines(content).chomp
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.build_yaml_text(_detected_languages, supported, unsupported)
|
57
|
+
sections = []
|
58
|
+
|
59
|
+
# Add YAML document start
|
60
|
+
sections << "---"
|
61
|
+
|
62
|
+
# Add base configuration
|
63
|
+
sections << read_config_file("base.yml")
|
64
|
+
sections << read_config_file("commands.yml")
|
65
|
+
sections << read_config_file("core_prompts/test_guidance.yml")
|
66
|
+
sections << read_config_file("core_prompts/system_prompt.yml")
|
67
|
+
|
68
|
+
# Add languages section
|
69
|
+
sections << build_languages_section(supported, unsupported)
|
70
|
+
|
71
|
+
sections.join("\n\n")
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.build_languages_section(supported, unsupported)
|
75
|
+
result = "languages:"
|
76
|
+
|
77
|
+
supported.each { |lang| result += build_supported_language(lang) }
|
78
|
+
unsupported.each { |lang| result += build_unsupported_language(lang) }
|
79
|
+
|
80
|
+
result
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.build_supported_language(lang)
|
84
|
+
lang_file = System.determine_config_path("languages/#{lang}.yml")
|
85
|
+
return "" unless File.exist?(lang_file)
|
86
|
+
|
87
|
+
lang_content = File.read(lang_file).strip
|
88
|
+
"\n #{lang}:\n" + indent_lines(lang_content, 4).rstrip
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.build_unsupported_language(lang)
|
92
|
+
result = "\n #{lang}:"
|
93
|
+
result += build_language_config_arrays(lang)
|
94
|
+
result += build_minimal_prompt_block(lang)
|
95
|
+
result
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.build_language_config_arrays(lang)
|
99
|
+
config = ""
|
100
|
+
config += "\n file_extensions:"
|
101
|
+
LanguageInference.infer_extensions(lang).each { |ext| config += "\n - \"#{ext}\"" }
|
102
|
+
config += "\n test_dir:"
|
103
|
+
LanguageInference.infer_test_dirs(lang).each { |dir| config += "\n - #{dir}" }
|
104
|
+
config += "\n project_files:"
|
105
|
+
LanguageInference.infer_project_files(lang).each { |file| config += "\n - #{file}" }
|
106
|
+
config
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.build_minimal_prompt_block(lang)
|
110
|
+
minimal_prompt = build_minimal_system_prompt(lang)
|
111
|
+
"\n system_prompt: |-\n" + indent_lines(minimal_prompt, 6).rstrip
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.build_minimal_system_prompt(lang)
|
115
|
+
<<~PROMPT
|
116
|
+
#{lang.upcase} TDD PATTERNS (MINIMAL SUPPORT):
|
117
|
+
- Follow test-first development: write failing test, make it pass, refactor
|
118
|
+
- Use descriptive test names that explain the behavior being tested
|
119
|
+
- Keep tests isolated and independent of each other
|
120
|
+
- Test both success and failure cases
|
121
|
+
- Mock external dependencies to ensure test reliability
|
122
|
+
|
123
|
+
#{lang.upcase} BASIC ANTI-PATTERNS TO AVOID:
|
124
|
+
- NEVER replace specific assertions with generic "does not raise/throw" checks
|
125
|
+
- NEVER convert detailed expectations to simple execution validation
|
126
|
+
- NEVER gut tests by removing behavior validation to make them pass
|
127
|
+
- ALWAYS fix root causes (configuration, mocking, environment) not symptoms
|
128
|
+
- ALWAYS understand what your test assertions are validating before changing them
|
129
|
+
|
130
|
+
DEBUGGING STEPS FOR #{lang.upcase}:
|
131
|
+
- Check project dependencies and build configuration
|
132
|
+
- Verify test framework setup and imports
|
133
|
+
- Use language-specific debugging tools and logging
|
134
|
+
- Ensure proper test isolation and cleanup
|
135
|
+
- Check for missing mocks or test data setup
|
136
|
+
|
137
|
+
⚠️ MINIMAL LANGUAGE SUPPORT:
|
138
|
+
This is basic TDD guidance for #{lang}. For comprehensive patterns, debugging tips,
|
139
|
+
and language-specific best practices, consider contributing full #{lang} support:
|
140
|
+
https://github.com/AlexParamonov/parabot#contributing-language-support
|
141
|
+
|
142
|
+
Current minimal support includes:
|
143
|
+
- Generic TDD workflow guidance
|
144
|
+
- Basic anti-pattern prevention
|
145
|
+
- Universal debugging approaches
|
146
|
+
|
147
|
+
Missing advanced support:
|
148
|
+
- #{lang}-specific testing frameworks and patterns
|
149
|
+
- Language-specific debugging techniques
|
150
|
+
- Framework-specific best practices
|
151
|
+
- Advanced #{lang} TDD patterns
|
152
|
+
PROMPT
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
data/lib/parabot.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "parabot/version"
|
4
|
+
require_relative "parabot/errors"
|
5
|
+
require_relative "parabot/configuration"
|
6
|
+
require_relative "parabot/language_detector"
|
7
|
+
require_relative "parabot/tmux_manager"
|
8
|
+
require_relative "parabot/test_runner"
|
9
|
+
require_relative "parabot/cli"
|
10
|
+
|
11
|
+
module Parabot
|
12
|
+
class Error < StandardError; end
|
13
|
+
|
14
|
+
# Main entry point for the CLI
|
15
|
+
def self.start(args = ARGV)
|
16
|
+
CLI.start(args)
|
17
|
+
rescue Parabot::Error => e
|
18
|
+
logger.error(e.message)
|
19
|
+
exit(1)
|
20
|
+
rescue StandardError => e
|
21
|
+
logger.error("Unexpected error: #{e.message}")
|
22
|
+
logger.debug(e.backtrace.join("\n"))
|
23
|
+
exit(1)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.logger
|
27
|
+
require "semantic_logger"
|
28
|
+
SemanticLogger["Parabot"]
|
29
|
+
end
|
30
|
+
end
|
data/parabot.gemspec
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/parabot/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "parabot"
|
7
|
+
spec.version = Parabot::VERSION
|
8
|
+
spec.authors = ["Alexander Paramonov"]
|
9
|
+
spec.email = ["alexander.n.paramonov@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "AI-powered Test-Driven Development parallel assistant with tmux integration"
|
12
|
+
spec.description = "Parabot is a multi-language TDD assistant that provides intelligent test-driven development support across various programming languages and testing frameworks. Features tmux integration, YAML configuration, and automatic project type detection."
|
13
|
+
spec.homepage = "https://github.com/AlexParamonov/parabot"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
(File.expand_path(f) == __FILE__) ||
|
25
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
26
|
+
end
|
27
|
+
end
|
28
|
+
spec.bindir = "exe"
|
29
|
+
spec.executables << "parabot"
|
30
|
+
spec.require_paths = ["lib"]
|
31
|
+
|
32
|
+
# Runtime dependencies
|
33
|
+
spec.add_dependency "dry-cli", "~> 1.1"
|
34
|
+
spec.add_dependency "config", "~> 5.0"
|
35
|
+
spec.add_dependency "semantic_logger", "~> 4.15"
|
36
|
+
|
37
|
+
# Development dependencies
|
38
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
39
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
40
|
+
spec.add_development_dependency "rubocop", "~> 1.21"
|
41
|
+
spec.add_development_dependency "rubocop-rspec", "~> 2.0"
|
42
|
+
spec.add_development_dependency "pry", "~> 0.14"
|
43
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
44
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
set -euo pipefail
|
3
|
+
|
4
|
+
#
|
5
|
+
# Parabot CLI Distribution Builder
|
6
|
+
#
|
7
|
+
# Creates a standalone executable for distribution to Linux/Mac systems
|
8
|
+
#
|
9
|
+
|
10
|
+
# Configuration
|
11
|
+
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
12
|
+
readonly PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
13
|
+
readonly BUILD_DIR="${BUILD_DIR:-$PROJECT_ROOT/build}"
|
14
|
+
readonly EXECUTABLE_NAME="parabot"
|
15
|
+
readonly SOURCE_EXECUTABLE="$PROJECT_ROOT/exe/parabot"
|
16
|
+
|
17
|
+
# Logging functions
|
18
|
+
log() { echo "🏗️ $1"; }
|
19
|
+
step() { echo "📦 $1"; }
|
20
|
+
success() { echo "✅ $1"; }
|
21
|
+
error() { echo "❌ $1" >&2; }
|
22
|
+
|
23
|
+
# Validation
|
24
|
+
validate_prerequisites() {
|
25
|
+
if [[ ! -f "$SOURCE_EXECUTABLE" ]]; then
|
26
|
+
error "Source executable not found: $SOURCE_EXECUTABLE"
|
27
|
+
exit 1
|
28
|
+
fi
|
29
|
+
|
30
|
+
if [[ ! -x "$SOURCE_EXECUTABLE" ]]; then
|
31
|
+
error "Source executable is not executable: $SOURCE_EXECUTABLE"
|
32
|
+
exit 1
|
33
|
+
fi
|
34
|
+
}
|
35
|
+
|
36
|
+
# Build process
|
37
|
+
build_distribution() {
|
38
|
+
log "Building Parabot CLI Distribution"
|
39
|
+
echo "=================================="
|
40
|
+
|
41
|
+
validate_prerequisites
|
42
|
+
|
43
|
+
# Clean build directory
|
44
|
+
step "Cleaning build directory..."
|
45
|
+
rm -rf "$BUILD_DIR"
|
46
|
+
mkdir -p "$BUILD_DIR"
|
47
|
+
|
48
|
+
# Copy the bundler-isolated executable
|
49
|
+
step "Creating standalone executable..."
|
50
|
+
cp "$SOURCE_EXECUTABLE" "$BUILD_DIR/$EXECUTABLE_NAME"
|
51
|
+
chmod +x "$BUILD_DIR/$EXECUTABLE_NAME"
|
52
|
+
}
|
53
|
+
|
54
|
+
# Test and report
|
55
|
+
test_executable() {
|
56
|
+
local executable="$BUILD_DIR/$EXECUTABLE_NAME"
|
57
|
+
|
58
|
+
step "Testing the executable..."
|
59
|
+
|
60
|
+
# Test version command
|
61
|
+
if "$executable" --version >/dev/null 2>&1; then
|
62
|
+
success "Version command works"
|
63
|
+
else
|
64
|
+
error "Version command failed"
|
65
|
+
return 1
|
66
|
+
fi
|
67
|
+
|
68
|
+
# Test help command (check output rather than exit code)
|
69
|
+
local help_output
|
70
|
+
help_output=$("$executable" --help 2>&1 || true)
|
71
|
+
if echo "$help_output" | grep -q "commands"; then
|
72
|
+
success "Help command works"
|
73
|
+
else
|
74
|
+
error "Help command failed"
|
75
|
+
return 1
|
76
|
+
fi
|
77
|
+
}
|
78
|
+
|
79
|
+
show_distribution_info() {
|
80
|
+
local file_size
|
81
|
+
file_size=$(du -h "$BUILD_DIR/$EXECUTABLE_NAME" | cut -f1)
|
82
|
+
|
83
|
+
success "Build complete!"
|
84
|
+
echo ""
|
85
|
+
echo "📊 Distribution Info:"
|
86
|
+
echo " Executable: $BUILD_DIR/$EXECUTABLE_NAME"
|
87
|
+
echo " Size: $file_size"
|
88
|
+
echo " Ruby Version Required: >= 3.0.0"
|
89
|
+
echo ""
|
90
|
+
echo "🚀 Installation Instructions:"
|
91
|
+
echo ""
|
92
|
+
echo " Local installation:"
|
93
|
+
echo " sudo cp $BUILD_DIR/$EXECUTABLE_NAME /usr/local/bin/"
|
94
|
+
echo ""
|
95
|
+
echo " User installation (if ~/bin is in PATH):"
|
96
|
+
echo " cp $BUILD_DIR/$EXECUTABLE_NAME ~/bin/"
|
97
|
+
echo ""
|
98
|
+
echo " Direct usage:"
|
99
|
+
echo " $BUILD_DIR/$EXECUTABLE_NAME --help"
|
100
|
+
echo ""
|
101
|
+
}
|
102
|
+
|
103
|
+
show_checklist() {
|
104
|
+
echo "🎉 Distribution ready for deployment!"
|
105
|
+
echo ""
|
106
|
+
echo "📋 Distribution checklist:"
|
107
|
+
echo " □ Test on target Linux system"
|
108
|
+
echo " □ Test on target macOS system"
|
109
|
+
echo " □ Verify Ruby 3.0+ requirement"
|
110
|
+
echo " □ Test all major commands"
|
111
|
+
echo " □ Document installation process"
|
112
|
+
}
|
113
|
+
|
114
|
+
# Main execution
|
115
|
+
main() {
|
116
|
+
build_distribution
|
117
|
+
test_executable || exit 1
|
118
|
+
show_distribution_info
|
119
|
+
show_checklist
|
120
|
+
}
|
121
|
+
|
122
|
+
main "$@"
|