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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../configuration"
4
+
5
+ module Parabot
6
+ module CLI
7
+ # Routes commands to appropriate handlers based on command type
8
+ class CommandRouter
9
+ def initialize(registry)
10
+ @registry = registry
11
+ end
12
+
13
+ def find_builtin_command(args)
14
+ # Get all registered command names from the registry
15
+ result = @registry.get([])
16
+ node = result.instance_variable_get(:@node)
17
+ children = node.instance_variable_get(:@children).keys
18
+ aliases = node.instance_variable_get(:@aliases).keys
19
+ builtin_commands = children + aliases
20
+
21
+ args.find { |arg| builtin_commands.include?(arg) }
22
+ end
23
+
24
+ def find_custom_command(args)
25
+ custom_commands = Configuration.current.custom_commands
26
+ args.find { |arg| custom_commands.key?(arg) || custom_commands.key?(arg.to_sym) }
27
+ end
28
+
29
+ def route_command(args, parser)
30
+ # First, extract options to get project_root before any configuration access
31
+ options = parser.extract_options(args)
32
+
33
+ # Now identify if we have known builtin commands anywhere in args
34
+ builtin_command = find_builtin_command(args)
35
+
36
+ # Load configuration with proper project root early (skip for init command and help)
37
+ unless builtin_command == "init" || parser.help_requested?(args)
38
+ CLI.ensure_configuration_loaded(options[:config], options[:project_root])
39
+ end
40
+
41
+ if builtin_command
42
+ handle_builtin_command(args, builtin_command, parser)
43
+ elsif args.empty? || parser.only_options?(args)
44
+ handle_help([], parser)
45
+ elsif parser.help_requested?(args)
46
+ handle_help(args, parser)
47
+ else
48
+ # Check for custom commands or treat as message
49
+ custom_command = find_custom_command(args)
50
+ if custom_command
51
+ handle_custom_command(args, custom_command, parser)
52
+ else
53
+ handle_message(args, parser)
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def handle_builtin_command(args, command, parser)
61
+ # Extract global options first
62
+ options = parser.extract_options(args)
63
+
64
+ # Remove global options, their values, and the command itself
65
+ remaining_args = parser.remove_options_and_values(args, command)
66
+ reordered_args = [command] + remaining_args
67
+
68
+ {
69
+ type: :builtin_command,
70
+ args: reordered_args,
71
+ options: options
72
+ }
73
+ end
74
+
75
+ def handle_custom_command(args, command, parser)
76
+ # Extract options and build message
77
+ options = parser.extract_options(args)
78
+ remaining_args = parser.remove_options_and_values(args, command)
79
+
80
+ custom_commands = Configuration.current.custom_commands
81
+ prompt = custom_commands[command] || custom_commands[command.to_sym]
82
+ message = [prompt, remaining_args.join(" ")].reject(&:empty?).join(" ")
83
+
84
+ {
85
+ type: :custom_command,
86
+ message: message,
87
+ options: options
88
+ }
89
+ end
90
+
91
+ def handle_message(args, parser)
92
+ # Treat entire input as message
93
+ options = parser.extract_options(args)
94
+ message_parts = parser.remove_options_and_values(args, nil) # No command to remove
95
+ message = message_parts.join(" ")
96
+
97
+ {
98
+ type: :message,
99
+ message: message,
100
+ options: options
101
+ }
102
+ end
103
+
104
+ def handle_help(args, parser)
105
+ options = parser.extract_options(args)
106
+ {
107
+ type: :help,
108
+ args: args.empty? ? ["help"] : args,
109
+ options: options
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "commands"
5
+ require_relative "configuration"
6
+ require_relative "logging_setup"
7
+ require_relative "cli/argument_parser"
8
+ require_relative "cli/command_router"
9
+
10
+ module Parabot
11
+ module CLI
12
+ module Commands
13
+ extend Dry::CLI::Registry
14
+
15
+ register "commands", Parabot::Commands::CustomCommands
16
+ register "doctor", Parabot::Commands::Doctor
17
+ register "init", Parabot::Commands::Init
18
+ register "message", Parabot::Commands::Message, aliases: ["m"]
19
+ register "start", Parabot::Commands::Start, aliases: ["s"]
20
+ register "test", Parabot::Commands::Test, aliases: ["t"]
21
+ register "version", Parabot::Commands::Version, aliases: ["-v", "--version"]
22
+ end
23
+
24
+ def self.start(args)
25
+ parser = ArgumentParser.new
26
+ router = CommandRouter.new(Commands)
27
+ result = router.route_command(args, parser) # Configuration is already loaded in route_command
28
+
29
+ args_with_options =
30
+ case result[:type]
31
+ when :builtin_command
32
+ build_args_with_options(result[:args], result[:options])
33
+ when :custom_command, :message
34
+ build_args_with_options(["message", result[:message]], result[:options])
35
+ when :help
36
+ result[:args]
37
+ end
38
+
39
+ execute_builtin_command(args_with_options)
40
+ end
41
+
42
+ def self.ensure_configuration_loaded(config_file = nil, project_root = nil)
43
+ return if Configuration.instance_variable_get(:@instance)
44
+
45
+ Configuration.load(config_file, project_root)
46
+ LoggingSetup.setup_with_config(Configuration.current)
47
+ end
48
+
49
+ def self.build_args_with_options(args, options)
50
+ result = args.dup
51
+
52
+ # Add global options back to args for dry-cli
53
+ result << "--dry-run" if options[:dry_run]
54
+ result << "--force" if options[:force]
55
+ result += ["--config", options[:config]] if options[:config]
56
+ result += ["--language", options[:language]] if options[:language]
57
+ result += ["--timeout", options[:timeout].to_s] if options[:timeout]
58
+ result += ["--project-root", options[:project_root]] if options[:project_root]
59
+
60
+ result
61
+ end
62
+
63
+ # Simplified CLI that delegates to separate components
64
+ # This maintains backward compatibility while improving architecture
65
+
66
+ def self.execute_builtin_command(args)
67
+ cli = Dry::CLI.new(Commands)
68
+ cli.call(arguments: args)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../messaging/adapter_factory"
5
+ require_relative "../dry_run_logging"
6
+
7
+ module Parabot
8
+ module Commands
9
+ class Base < Dry::CLI::Command
10
+ include DryRunLogging
11
+
12
+ # Global options available to all commands
13
+ option :config, aliases: ["c"], type: :string, desc: "Path to configuration file"
14
+ option :language, aliases: ["l"], type: :string, desc: "Force language detection (elixir, ruby, etc.)"
15
+ option :dry_run, type: :boolean, default: false, desc: "Show what would be done without executing"
16
+ option :timeout, aliases: ["t"], type: :integer, default: 30, desc: "Timeout in seconds"
17
+ option :project_root, aliases: ["p"], type: :string, default: ".",
18
+ desc: "Project root directory for configuration and file detection"
19
+
20
+ protected
21
+
22
+ def config
23
+ @config ||= Configuration.current
24
+ end
25
+
26
+ def logger
27
+ require "semantic_logger"
28
+ SemanticLogger[self.class.name]
29
+ end
30
+
31
+ def handle_dry_run(message)
32
+ return false unless dry_run?
33
+
34
+ dry_run_log(message)
35
+ show_dry_run_details if respond_to?(:show_dry_run_details, true)
36
+ true
37
+ end
38
+
39
+ def dry_run?
40
+ options[:dry_run]
41
+ end
42
+
43
+ def init(**options)
44
+ @options = options
45
+ @options[:project_root] ||= "." # Set default when not provided
46
+ @options[:language] = parse_language_option(@options[:language])
47
+
48
+ validate_git_repository
49
+ end
50
+
51
+ def validate_git_repository
52
+ return if git_repository?
53
+
54
+ warning_message = "Warning: Not running in a git repository. Some features may be limited."
55
+ dry_run_log(warning_message)
56
+ logger.warn(warning_message)
57
+ end
58
+
59
+ private
60
+
61
+ def options
62
+ @options ||= {}
63
+ end
64
+
65
+ def git_repository?
66
+ project_root = @options[:project_root] || "."
67
+ system("git -C #{project_root.shellescape} rev-parse --is-inside-work-tree > /dev/null 2>&1")
68
+ end
69
+
70
+ def parse_language_option(language_option)
71
+ language_option.to_s.split(",").map(&:strip).map(&:to_sym)
72
+ end
73
+
74
+ def language_detector
75
+ @language_detector ||= LanguageDetector.new(config)
76
+ end
77
+
78
+ def test_runner
79
+ @test_runner ||= TestRunner.new(messaging_adapter, language_detector, dry_run: dry_run?, config: config,
80
+ language: options[:language])
81
+ end
82
+
83
+ def messaging_adapter
84
+ @messaging_adapter ||= Messaging::AdapterFactory.create(config.messaging_adapter, dry_run: dry_run?)
85
+ end
86
+
87
+ def handle_unexpected_error(error)
88
+ logger.error("Unexpected error: #{error.message}")
89
+ logger.debug(error.backtrace.join("\n"))
90
+ end
91
+
92
+ def format_message(message)
93
+ <<~MESSAGE
94
+ Mandatory: Refer to @#{Parabot::System.determine_system_path('.parabot.md', @options[:project_root])} for complete instructions and test analysis. Read it unless you have it in your context already.
95
+ #{message}
96
+ MESSAGE
97
+ end
98
+
99
+ def send_message(message)
100
+ message = format_message(message)
101
+ logger.info("Sending message to Claude...")
102
+ dry_run_log "Would send message: #{message}"
103
+
104
+ messaging_adapter.send_message(message)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parabot
4
+ module Commands
5
+ class CustomCommands < Base
6
+ desc "List available custom commands"
7
+
8
+ def call(**)
9
+ return if handle_dry_run("Would list custom commands")
10
+
11
+ display_custom_commands
12
+ rescue ConfigurationError => e
13
+ handle_configuration_error(e)
14
+ rescue StandardError => e
15
+ handle_unexpected_error(e)
16
+ end
17
+
18
+ private
19
+
20
+ def display_custom_commands
21
+ logger.info("Loading and validating custom commands...")
22
+ custom_commands = config.custom_commands
23
+
24
+ if custom_commands.any?
25
+ show_commands(custom_commands)
26
+ else
27
+ show_no_commands_message
28
+ end
29
+ end
30
+
31
+ def show_commands(custom_commands)
32
+ puts "Available custom commands:"
33
+ custom_commands.each do |command, description|
34
+ puts " #{command.to_s.ljust(15)} - #{truncated_description(description)}"
35
+ end
36
+ puts
37
+ logger.info("Found #{custom_commands.size} custom commands")
38
+ end
39
+
40
+ def truncated_description(description)
41
+ description.length > 60 ? "#{description[0..60]}..." : description
42
+ end
43
+
44
+ def show_no_commands_message
45
+ logger.error("No custom commands found in configuration")
46
+ puts "No custom commands configured."
47
+ puts "Add commands to your .parabot.yml file under the 'commands:' section."
48
+ exit(1)
49
+ end
50
+
51
+ def handle_configuration_error(error)
52
+ logger.error("Configuration error: #{error.message}")
53
+ exit(1)
54
+ end
55
+
56
+ def handle_unexpected_error(error)
57
+ logger.error("Unexpected error: #{error.message}")
58
+ logger.debug(error.backtrace.join("\n")) if options[:verbose]
59
+ exit(1)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../configuration"
5
+ require_relative "../cli"
6
+
7
+ module Parabot
8
+ module Commands
9
+ class Doctor < Dry::CLI::Command
10
+ desc "Diagnose configuration and environment health"
11
+
12
+ argument :file, required: false, desc: "Specific config file to validate"
13
+ option :plain, type: :boolean, default: false, desc: "Plain text output for logging or files"
14
+ option :project_root, aliases: ["p"], type: :string, desc: "Project root directory for configuration"
15
+
16
+ example [
17
+ "parabot doctor # Check default configuration and environment",
18
+ "parabot doctor custom.yml # Check specific config file",
19
+ "parabot doctor --plain # Plain text output for logging or files"
20
+ ]
21
+
22
+ def call(file: nil, plain: false, project_root: nil, **)
23
+ @plain_output = plain
24
+ log("Running Parabot health check...", :health_check)
25
+
26
+ begin
27
+ # Load configuration
28
+ config = Configuration.load(file, project_root)
29
+
30
+ log("Configuration loaded successfully", :ok)
31
+ validate_config_schema(config)
32
+
33
+ log("Health check completed!", :success)
34
+ rescue ConfigurationError => e
35
+ log("Configuration error: #{e.message}", :error)
36
+ exit 1
37
+ rescue StandardError => e
38
+ log("Unexpected error: #{e.message}", :fatal)
39
+ exit 1
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def validate_config_schema(config)
46
+ header("Schema Validation:")
47
+
48
+ # Check recommended sections
49
+ validate_section_presence(config, "languages")
50
+ validate_section_presence(config, "commands")
51
+
52
+ # Validate specific sections
53
+ validate_test_commands_schema(config)
54
+ validate_custom_commands_schema(config)
55
+ validate_languages_schema(config)
56
+ end
57
+
58
+ def validate_section_presence(config, section_name)
59
+ if config.config.respond_to?(section_name) && config.config.public_send(section_name)
60
+ log("#{section_name} section found", :ok)
61
+ else
62
+ log("Missing recommended section: #{section_name}", :warn)
63
+ end
64
+ end
65
+
66
+ def validate_test_commands_schema(config)
67
+ return unless config.config.languages
68
+
69
+ header("Test Commands:")
70
+ test_commands = {}
71
+
72
+ # Extract test commands from language configurations
73
+ config.config.languages.to_h.each do |lang, lang_config|
74
+ if lang_config&.[]("test_command") || lang_config&.[](:test_command)
75
+ test_command = lang_config["test_command"] || lang_config[:test_command]
76
+ test_commands[lang] = test_command
77
+ end
78
+ end
79
+
80
+ if test_commands.empty?
81
+ log("No test commands configured in any language", :warn)
82
+ return
83
+ end
84
+
85
+ test_commands.each do |lang, command|
86
+ next unless command.is_a?(String) && !command.empty?
87
+
88
+ cmd_name = command.split.first
89
+ if system("which #{cmd_name} > /dev/null 2>&1")
90
+ log("#{lang}: #{command}", :ok)
91
+ else
92
+ log("#{lang}: #{command} (command '#{cmd_name}' not found in PATH)", :error)
93
+ end
94
+ end
95
+ end
96
+
97
+ def validate_custom_commands_schema(config)
98
+ return unless config.config.commands
99
+
100
+ header("Custom Commands:")
101
+ commands = config.config.commands.to_h
102
+
103
+ if commands.empty?
104
+ log("No custom commands configured", :info)
105
+ return
106
+ end
107
+
108
+ commands.each do |cmd, value|
109
+ if value.is_a?(String) && !value.empty?
110
+ log("#{cmd}: #{value.length > 50 ? "#{value[0..47]}..." : value}", :ok)
111
+ else
112
+ log("#{cmd}: invalid command definition", :error)
113
+ end
114
+ end
115
+ end
116
+
117
+ def validate_languages_schema(config)
118
+ return unless config.config.languages
119
+
120
+ header("Languages:")
121
+ languages = config.config.languages
122
+
123
+ if languages.to_h.empty?
124
+ log("No language configurations found", :info)
125
+ return
126
+ end
127
+
128
+ languages.each do |lang, lang_config|
129
+ next unless lang_config
130
+
131
+ status_items = []
132
+ status_items << "system_prompt" if lang_config["system_prompt"] || lang_config[:system_prompt]
133
+ status_items << "test_guidance" if lang_config["test_guidance"] || lang_config[:test_guidance]
134
+
135
+ file_extensions = lang_config["file_extensions"] || lang_config[:file_extensions]
136
+ if file_extensions&.any?
137
+ extensions = Array(file_extensions).join(", ")
138
+ status_items << "extensions: #{extensions}"
139
+ end
140
+
141
+ if status_items.any?
142
+ log("#{lang}: #{status_items.join(', ')}", :ok)
143
+ else
144
+ log("#{lang}: incomplete configuration", :warn)
145
+ end
146
+ end
147
+ end
148
+
149
+ # Doctor-specific output methods
150
+ def log(message, icon = nil)
151
+ formatted = format_with_icon(message, icon)
152
+ Kernel.puts formatted
153
+ end
154
+
155
+ def header(message)
156
+ Kernel.puts "" unless plain_output? # Add blank line before header
157
+ log(message, :info)
158
+ end
159
+
160
+ def format_with_icon(message, icon)
161
+ return message unless icon
162
+
163
+ # Use plain output if explicitly enabled
164
+ if plain_output?
165
+ text_icon = icon_to_text(icon)
166
+ text_icon ? "#{text_icon} #{message}" : message
167
+ else
168
+ emoji_icon = icon_to_emoji(icon)
169
+ emoji_icon ? "#{emoji_icon} #{message}" : message
170
+ end
171
+ end
172
+
173
+ def icon_to_emoji(icon)
174
+ case icon
175
+ when :success then "🎉"
176
+ when :error then "❌"
177
+ when :warn then "⚠️"
178
+ when :health_check then "🩺"
179
+ when :fatal then "💥"
180
+ when :ok then "🟢"
181
+ end
182
+ end
183
+
184
+ def icon_to_text(icon)
185
+ case icon
186
+ when :health_check then "[HEALTH CHECK]"
187
+ else "[#{icon.to_s.upcase}]"
188
+ end
189
+ end
190
+
191
+ def plain_output?
192
+ @plain_output || false
193
+ end
194
+ end
195
+ end
196
+ end