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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +55 -0
  3. data/CLAUDE.md +171 -0
  4. data/DISTRIBUTION.md +287 -0
  5. data/INSTALL.md +242 -0
  6. data/LICENSE +21 -0
  7. data/README.md +371 -0
  8. data/Rakefile +50 -0
  9. data/config/base.yml +12 -0
  10. data/config/commands.yml +28 -0
  11. data/config/core_prompts/system_prompt.yml +66 -0
  12. data/config/core_prompts/test_guidance.yml +24 -0
  13. data/config/languages/elixir.yml +62 -0
  14. data/config/languages/javascript.yml +64 -0
  15. data/config/languages/kotlin.yml +64 -0
  16. data/config/languages/ruby.yml +66 -0
  17. data/config/languages/shell.yml +63 -0
  18. data/config/languages/typescript.yml +63 -0
  19. data/exe/parabot +22 -0
  20. data/lib/parabot/cli/argument_parser.rb +105 -0
  21. data/lib/parabot/cli/command_router.rb +114 -0
  22. data/lib/parabot/cli.rb +71 -0
  23. data/lib/parabot/commands/base.rb +108 -0
  24. data/lib/parabot/commands/custom_commands.rb +63 -0
  25. data/lib/parabot/commands/doctor.rb +196 -0
  26. data/lib/parabot/commands/init.rb +171 -0
  27. data/lib/parabot/commands/message.rb +25 -0
  28. data/lib/parabot/commands/start.rb +35 -0
  29. data/lib/parabot/commands/test.rb +43 -0
  30. data/lib/parabot/commands/version.rb +17 -0
  31. data/lib/parabot/commands.rb +15 -0
  32. data/lib/parabot/configuration.rb +199 -0
  33. data/lib/parabot/dry_run_logging.rb +22 -0
  34. data/lib/parabot/errors.rb +12 -0
  35. data/lib/parabot/language_detector.rb +158 -0
  36. data/lib/parabot/language_inference.rb +82 -0
  37. data/lib/parabot/logging_setup.rb +73 -0
  38. data/lib/parabot/messaging/adapter.rb +53 -0
  39. data/lib/parabot/messaging/adapter_factory.rb +33 -0
  40. data/lib/parabot/messaging/dry_run_adapter.rb +55 -0
  41. data/lib/parabot/messaging/tmux_adapter.rb +82 -0
  42. data/lib/parabot/system.rb +41 -0
  43. data/lib/parabot/test_runner.rb +179 -0
  44. data/lib/parabot/tmux_manager.rb +245 -0
  45. data/lib/parabot/version.rb +5 -0
  46. data/lib/parabot/yaml_text_assembler.rb +155 -0
  47. data/lib/parabot.rb +30 -0
  48. data/parabot.gemspec +44 -0
  49. data/scripts/build-distribution +122 -0
  50. data/scripts/install +152 -0
  51. metadata +221 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ class LanguageInference
5
+ EXTENSION_PATTERNS = {
6
+ "python" => [".py", ".pyx", ".pyi"],
7
+ "java" => [".java"],
8
+ "cpp" => [".cpp", ".cc", ".cxx", ".c++", ".hpp", ".h"],
9
+ "c" => [".c", ".h"],
10
+ "go" => [".go"],
11
+ "rust" => [".rs"],
12
+ "php" => [".php"],
13
+ "swift" => [".swift"],
14
+ "dart" => [".dart"],
15
+ "scala" => [".scala"],
16
+ "clojure" => [".clj", ".cljs", ".cljc"],
17
+ "haskell" => [".hs", ".lhs"],
18
+ "erlang" => [".erl", ".hrl"],
19
+ "csharp" => [".cs"],
20
+ "fsharp" => [".fs", ".fsx"],
21
+ "perl" => [".pl", ".pm"],
22
+ "lua" => [".lua"],
23
+ "r" => [".R", ".r"],
24
+ "matlab" => [".m"],
25
+ "julia" => [".jl"]
26
+ }.freeze
27
+
28
+ TEST_DIR_PATTERNS = {
29
+ "python" => %w[tests test],
30
+ "java" => ["src/test/java", "test"],
31
+ "go" => ["."], # Go tests alongside source
32
+ "rust" => ["tests"],
33
+ "php" => %w[tests test],
34
+ "swift" => ["Tests"],
35
+ "dart" => ["test"],
36
+ "scala" => ["src/test/scala", "test"],
37
+ "clojure" => ["test"],
38
+ "haskell" => ["test"],
39
+ "erlang" => ["test"],
40
+ "csharp" => %w[test tests],
41
+ "fsharp" => %w[test tests],
42
+ "perl" => %w[t test],
43
+ "lua" => %w[test spec],
44
+ "r" => %w[tests test],
45
+ "matlab" => ["test"],
46
+ "julia" => ["test"]
47
+ }.freeze
48
+
49
+ PROJECT_FILES = {
50
+ "python" => ["requirements.txt", "setup.py", "pyproject.toml", "Pipfile"],
51
+ "java" => ["pom.xml", "build.gradle", "build.gradle.kts"],
52
+ "go" => ["go.mod", "go.sum"],
53
+ "rust" => ["Cargo.toml", "Cargo.lock"],
54
+ "php" => ["composer.json", "composer.lock"],
55
+ "swift" => ["Package.swift"],
56
+ "dart" => ["pubspec.yaml"],
57
+ "scala" => ["build.sbt", "project/build.properties"],
58
+ "clojure" => ["project.clj", "deps.edn"],
59
+ "haskell" => ["*.cabal", "stack.yaml"],
60
+ "erlang" => ["rebar.config", "erlang.mk"],
61
+ "csharp" => ["*.csproj", "*.sln"],
62
+ "fsharp" => ["*.fsproj", "*.sln"],
63
+ "perl" => ["Makefile.PL", "Build.PL"],
64
+ "lua" => ["*.rockspec"],
65
+ "r" => %w[DESCRIPTION NAMESPACE],
66
+ "matlab" => ["*.prj"],
67
+ "julia" => ["Project.toml", "Manifest.toml"]
68
+ }.freeze
69
+
70
+ def self.infer_extensions(language)
71
+ EXTENSION_PATTERNS[language.downcase] || [".#{language}"]
72
+ end
73
+
74
+ def self.infer_test_dirs(language)
75
+ TEST_DIR_PATTERNS[language.downcase] || %w[test tests]
76
+ end
77
+
78
+ def self.infer_project_files(language)
79
+ PROJECT_FILES[language.downcase] || []
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_logger"
4
+ require "pathname"
5
+
6
+ module Parabot
7
+ class LoggingSetup
8
+ def self.setup_with_config(config)
9
+ new(config.log_level, config.log_file, config.method(:resolve_log_file_path),
10
+ config.method(:ensure_log_directory)).setup
11
+ end
12
+
13
+ def self.setup_default(level = :info, file = "stderr")
14
+ new(level, file).setup
15
+ end
16
+
17
+ def initialize(level = :info, file = "stderr", file_resolver = nil, dir_ensurer = nil)
18
+ @level = level
19
+ @file = file
20
+ @file_resolver = file_resolver
21
+ @dir_ensurer = dir_ensurer
22
+ end
23
+
24
+ def setup
25
+ SemanticLogger.default_level = @level
26
+ SemanticLogger.clear_appenders!
27
+
28
+ if %w[stderr stdout].include?(@file)
29
+ setup_stderr_logging
30
+ else
31
+ setup_file_logging
32
+ end
33
+ rescue StandardError => e
34
+ # Fall back to stderr logging if file logging fails
35
+ fallback_to_stderr(e)
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :level, :file, :file_resolver, :dir_ensurer
41
+
42
+ def setup_stderr_logging
43
+ SemanticLogger.add_appender(io: $stderr, formatter: custom_formatter)
44
+ end
45
+
46
+ def setup_file_logging
47
+ file_path = file_resolver ? file_resolver.call : @file
48
+ dir_ensurer&.call(file_path)
49
+ SemanticLogger.add_appender(file: file_path, formatter: :default)
50
+ end
51
+
52
+ def fallback_to_stderr(error)
53
+ SemanticLogger.clear_appenders!
54
+ SemanticLogger.add_appender(io: $stderr, formatter: custom_formatter)
55
+ SemanticLogger["Parabot::LoggingSetup"].warn("Failed to setup file logging: #{error.message}")
56
+ end
57
+
58
+ def custom_formatter
59
+ proc do |log|
60
+ reset_color = "\e[0m"
61
+ level_color = case log.level
62
+ when :trace, :debug then "\e[0;90m" # GRAY - Dark gray like shell
63
+ when :info then "\e[0;36m" # CYAN - Cyan like shell
64
+ when :warn then "\e[1;33m" # YELLOW - Bold yellow like shell
65
+ when :error, :fatal then "\e[0;31m" # RED - Red like shell
66
+ else "\e[0m" # Default
67
+ end
68
+ level_tag = "[#{log.level.to_s.upcase}]"
69
+ "#{level_color}#{level_tag}#{reset_color} #{log.message}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Messaging
5
+ # Base class for messaging adapters that can send messages to Claude
6
+ # in different ways (tmux, direct API calls, etc.)
7
+ class Adapter
8
+ # @abstract
9
+ # @param message [String] the message to send to Claude
10
+ # @return [Boolean] true if message was sent successfully
11
+ # @raise [NotImplementedError] if not implemented by subclass
12
+ def send_message(message)
13
+ raise NotImplementedError, "#{self.class} must implement #send_message"
14
+ end
15
+
16
+ # @abstract
17
+ # @param system_prompt [String, nil] optional system prompt
18
+ # @return [Boolean] true if Claude session was started successfully
19
+ # @raise [NotImplementedError] if not implemented by subclass
20
+ def start_session(system_prompt: nil)
21
+ raise NotImplementedError, "#{self.class} must implement #start_session"
22
+ end
23
+
24
+ # @abstract
25
+ # @return [Boolean] true if session was ended successfully
26
+ # @raise [NotImplementedError] if not implemented by subclass
27
+ def end_session
28
+ raise NotImplementedError, "#{self.class} must implement #end_session"
29
+ end
30
+
31
+ # @abstract
32
+ # @return [Boolean] true if abort command was sent successfully (usually ESC key)
33
+ # @raise [NotImplementedError] if not implemented by subclass
34
+ def abort
35
+ raise NotImplementedError, "#{self.class} must implement #abort"
36
+ end
37
+
38
+ # @abstract
39
+ # @return [Boolean] true if Claude session is available and active
40
+ # @raise [NotImplementedError] if not implemented by subclass
41
+ def session_active?
42
+ raise NotImplementedError, "#{self.class} must implement #session_active?"
43
+ end
44
+
45
+ private
46
+
47
+ def logger
48
+ require "semantic_logger"
49
+ SemanticLogger[self.class.name]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tmux_adapter"
4
+ require_relative "dry_run_adapter"
5
+
6
+ module Parabot
7
+ module Messaging
8
+ # Factory class to create appropriate messaging adapters
9
+ class AdapterFactory
10
+ # Available adapter types
11
+ ADAPTERS = {
12
+ tmux: TmuxAdapter,
13
+ dry_run: DryRunAdapter
14
+ }.freeze
15
+
16
+ # @param type [Symbol] the type of adapter to create (:tmux, :dry_run)
17
+ # @param kwargs [Hash] additional keyword arguments to pass to the adapter constructor
18
+ # @return [Messaging::Adapter] an instance of the requested adapter
19
+ # @raise [ArgumentError] if adapter type is not supported
20
+ def self.create(type = :tmux, **kwargs)
21
+ adapter_class = ADAPTERS[type]
22
+ raise ArgumentError, "Unsupported adapter type: #{type}. Available types: #{ADAPTERS.keys}" unless adapter_class
23
+
24
+ adapter_class.new(**kwargs)
25
+ end
26
+
27
+ # @return [Array<Symbol>] list of available adapter types
28
+ def self.available_types
29
+ ADAPTERS.keys
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+ require_relative "../dry_run_logging"
5
+
6
+ module Parabot
7
+ module Messaging
8
+ # Dry-run implementation of the MessagingAdapter that logs actions without executing them
9
+ class DryRunAdapter < Adapter
10
+ include DryRunLogging
11
+
12
+ def initialize(dry_run: true, **_kwargs)
13
+ @dry_run = dry_run
14
+ end
15
+
16
+ # @param message [String] the message to send to Claude
17
+ # @return [Boolean] always true (simulated success)
18
+ def send_message(message)
19
+ dry_run_log "Would send message to Claude:"
20
+ puts message
21
+ true
22
+ end
23
+
24
+ # @param system_prompt [String, nil] optional system prompt
25
+ # @return [Boolean] always true (simulated success)
26
+ def start_session(system_prompt: nil)
27
+ dry_run_log "Would start Claude session"
28
+ if system_prompt
29
+ dry_run_log "Would use system prompt: #{system_prompt[0..100]}#{'...' if system_prompt.length > 100}"
30
+ else
31
+ dry_run_log "Would start without system prompt"
32
+ end
33
+ true
34
+ end
35
+
36
+ # @return [Boolean] always true (simulated success)
37
+ def end_session
38
+ dry_run_log "Would end Claude session"
39
+ true
40
+ end
41
+
42
+ # @return [Boolean] always true (simulated success)
43
+ def abort
44
+ dry_run_log "Would send abort command (ESC key) to Claude"
45
+ true
46
+ end
47
+
48
+ # @return [Boolean] always true (simulated active session)
49
+ def session_active?
50
+ dry_run_log "Would check if Claude session is active"
51
+ true
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+ require_relative "../tmux_manager"
5
+
6
+ module Parabot
7
+ module Messaging
8
+ # Tmux implementation of the MessagingAdapter
9
+ class TmuxAdapter < Adapter
10
+ attr_reader :tmux_manager
11
+
12
+ def initialize(dry_run: false, **_kwargs)
13
+ @tmux_manager = TmuxManager.new(dry_run: dry_run)
14
+ end
15
+
16
+ # @param message [String] the message to send to Claude
17
+ # @return [Boolean] true if message was sent successfully
18
+ def send_message(message)
19
+ tmux_manager.send_to_claude(message)
20
+ end
21
+
22
+ # @param system_prompt [String, nil] optional system prompt
23
+ # @return [Boolean] true if Claude session was started successfully
24
+ def start_session(system_prompt: nil)
25
+ tmux_manager.start_claude_session(system_prompt: system_prompt)
26
+ end
27
+
28
+ # @return [Boolean] true if session was ended successfully
29
+ def end_session
30
+ session = tmux_manager.find_claude_session
31
+ return false unless session
32
+
33
+ target = tmux_manager.find_claude_target(session)
34
+
35
+ # Send Ctrl+C then exit command
36
+ send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} C-c")
37
+ sleep(0.2)
38
+ send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} exit Enter")
39
+
40
+ true
41
+ rescue TmuxError
42
+ false
43
+ end
44
+
45
+ # @return [Boolean] true if abort command was sent successfully (ESC key)
46
+ def abort
47
+ session = tmux_manager.find_claude_session
48
+ return false unless session
49
+
50
+ target = tmux_manager.find_claude_target(session)
51
+
52
+ # Send ESC key to abort current Claude response
53
+ send_cmd("tmux send-keys -t #{session}:#{target.tr(':', '.')} Escape")
54
+
55
+ true
56
+ rescue TmuxError
57
+ false
58
+ end
59
+
60
+ # @return [Boolean] true if Claude session is available and active
61
+ def session_active?
62
+ session = tmux_manager.find_claude_session
63
+ return false unless session
64
+
65
+ target = tmux_manager.find_claude_target(session)
66
+
67
+ # Check if the target pane has a Claude process running
68
+ window_pane = target.tr(":", ".")
69
+ result = tmux_manager.run_command("tmux list-panes -t #{session}:#{window_pane} -F '\#{pane_current_command}'")
70
+ result.success? && result.out.strip.match?(/claude/i)
71
+ rescue SystemCallError
72
+ false
73
+ end
74
+
75
+ private
76
+
77
+ def send_cmd(command)
78
+ tmux_manager.run_command(command)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,41 @@
1
+ require "semantic_logger"
2
+
3
+ module Parabot
4
+ class System
5
+ PARABOT_DIR = ".parabot".freeze
6
+
7
+ class << self
8
+ def logger
9
+ SemanticLogger["Parabot"]
10
+ end
11
+
12
+ def determine_path(filename, project_root)
13
+ return filename if Pathname.new(filename).absolute?
14
+
15
+ File.join(project_root, filename)
16
+ end
17
+
18
+ def determine_system_path(filename, project_root)
19
+ ensure_parabot_directory_in(project_root)
20
+ File.join(project_root, PARABOT_DIR, filename)
21
+ end
22
+
23
+ def determine_config_path(relative_path)
24
+ File.join(__dir__, "../../config", relative_path)
25
+ end
26
+
27
+ private
28
+
29
+ def ensure_parabot_directory_in(project_root)
30
+ path = File.join(project_root, PARABOT_DIR)
31
+
32
+ unless Dir.exist?(path)
33
+ FileUtils.mkdir_p(path)
34
+ logger.info("Created parabot directory: #{path}")
35
+ end
36
+ rescue SystemCallError => e
37
+ logger.warn("Cannot create parabot directory #{path}: #{e.message}")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "ostruct"
5
+ require "fileutils"
6
+ require "shellwords"
7
+ require "tempfile"
8
+ require_relative "configuration"
9
+ require_relative "dry_run_logging"
10
+ require_relative "system"
11
+
12
+ module Parabot
13
+ class TestRunner
14
+ include DryRunLogging
15
+
16
+ TestExecutionError = Class.new(StandardError)
17
+ TestResult = Struct.new(:exit_status, :output) do
18
+ def success?
19
+ exit_status.zero?
20
+ end
21
+ end
22
+
23
+ def initialize(messaging_adapter, language_detector, dry_run: false, config: nil, language: nil)
24
+ @config = config || Configuration.current
25
+ @dry_run = dry_run
26
+ @messaging_adapter = messaging_adapter
27
+ @language = Array(language).first
28
+ @language_detector = language_detector
29
+ end
30
+
31
+ def run_tests(args: [], send_to_claude: true)
32
+ test_command = determine_test_command_from_context(args)
33
+
34
+ logger.info("Running tests...")
35
+ logger.info("Executing: #{test_command}")
36
+
37
+ result = run_command(test_command)
38
+ test_results = format_test_results(test_command, result)
39
+ output_file = save_test_output(test_results)
40
+
41
+ TestResult.new(
42
+ exit_status: result.exit_status,
43
+ output: output_file || test_results
44
+ )
45
+ rescue SystemCallError => e
46
+ logger.error("Test execution failed: #{e.message}")
47
+ false
48
+ rescue StandardError => e
49
+ raise TestExecutionError, "Failed to run tests: #{e.message}"
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :config, :messaging_adapter, :language, :language_detector
55
+
56
+ def run_command(command)
57
+ dry_run_log(command)
58
+ return dry_run_stub if dry_run?
59
+
60
+ execute_test_command(command)
61
+ end
62
+
63
+ def determine_test_command_from_context(args)
64
+ detected_language = language || detect_language_from_context(args)
65
+
66
+ dry_run_log("Detected language: #{detected_language}")
67
+ build_command(detected_language, args)
68
+ end
69
+
70
+ def detect_language_from_context(args)
71
+ detect_from_file_extensions(args) \
72
+ || detect_from_directory_scanning(args) \
73
+ || language_detector.detect_all_languages.first
74
+ end
75
+
76
+ def detect_from_file_extensions(args)
77
+ extensions = args.flat_map { |arg| extract_extensions_from_path(arg) }.compact.uniq
78
+ language_detector.find_language_from_extensions(extensions)
79
+ end
80
+
81
+ def detect_from_directory_scanning(args)
82
+ directories = Array(args.select { |arg| File.directory?(arg) })
83
+
84
+ directories.each do |dir|
85
+ languages = language_detector.detect_all_languages(dir)
86
+ return languages.first unless languages.empty?
87
+ end
88
+
89
+ nil
90
+ end
91
+
92
+ def extract_extensions_from_path(path)
93
+ # Use System.determine_path to handle both absolute and relative paths
94
+ full_path = System.determine_path(path, config.project_root)
95
+
96
+ if File.exist?(full_path)
97
+ [File.extname(full_path)]
98
+ elsif path.include?("*") || path.include?("?")
99
+ # Handle glob patterns - use full_path for the glob too
100
+ Dir.glob(full_path).map { |f| File.extname(f) }.uniq
101
+ else
102
+ []
103
+ end.reject(&:empty?)
104
+ end
105
+
106
+ def build_command(language, args)
107
+ base_command = config.test_command(language)
108
+ args.any? ? "#{base_command} #{args.join(' ')}"
109
+ : base_command
110
+ end
111
+
112
+ def execute_test_command(command)
113
+ temp_file = Tempfile.new("parabot_test")
114
+
115
+ begin
116
+ script_command = "script -q -c #{command.shellescape} /dev/null 2>&1 | tee #{temp_file.path.shellescape}"
117
+
118
+ # Execute the streaming command
119
+ system(script_command)
120
+ exit_status = $?.exitstatus
121
+
122
+ # Read the captured output
123
+ temp_file.rewind
124
+ output = temp_file.read
125
+
126
+ TestResult.new(
127
+ exit_status: exit_status,
128
+ output: output
129
+ )
130
+ ensure
131
+ temp_file.close
132
+ temp_file.unlink
133
+ end
134
+ rescue SystemCallError => e
135
+ logger.error("Failed to execute test command: #{e.message}")
136
+
137
+ TestResult.new(
138
+ exit_status: 1,
139
+ output: "Error executing test: #{e.message}"
140
+ )
141
+ end
142
+
143
+ def format_test_results(command, result)
144
+ status = result.success? ? "passed" : "failed"
145
+
146
+ <<~RESULTS
147
+ Command: #{command}
148
+ Status: #{status}
149
+ Exit code: #{result.exit_status}
150
+
151
+ Output:
152
+ #{result.output}
153
+ RESULTS
154
+ end
155
+
156
+ def save_test_output(results_text)
157
+ output_file = Parabot::System.determine_system_path("tests_output", config.project_root)
158
+
159
+ File.write(output_file, results_text)
160
+ logger.info("Test results saved to #{output_file}")
161
+
162
+ output_file
163
+ rescue SystemCallError => e
164
+ logger.warn("Failed to save test output to file: #{e.message}")
165
+ nil
166
+ end
167
+
168
+ def dry_run_stub
169
+ TestResult.new(
170
+ exit_status: 0,
171
+ output: "DRY RUN: Mock test output\n\n3 examples, 0 failures\n\nFinished in 0.123 seconds"
172
+ )
173
+ end
174
+
175
+ def logger
176
+ Parabot::System.logger
177
+ end
178
+ end
179
+ end