Class: Ligarb::ClaudeRunner
- Inherits:
-
Object
- Object
- Ligarb::ClaudeRunner
- Defined in:
- lib/ligarb/claude_runner.rb
Constant Summary collapse
- PATCH_RE =
%r{<patch(?:\s+file="([^"]*)")?>\s*<<<[ \t]*\r?\n(.*?)\r?\n===[ \t]*\r?\n(.*?)\r?\n>>>[ \t]*\s*</patch>}m
Instance Method Summary collapse
-
#apply_patches(review) ⇒ Object
Extract patches from the last assistant message and apply them.
-
#extract_patches(review) ⇒ Object
Parse <patch> blocks from assistant messages.
-
#git_available? ⇒ Boolean
Check if git is available in the project directory.
-
#has_unmatched_patches?(review) ⇒ Boolean
Check if assistant messages contain <patch> tags that didn’t match PATCH_RE.
-
#initialize(config) ⇒ ClaudeRunner
constructor
A new instance of ClaudeRunner.
- #installed? ⇒ Boolean
-
#review_prompt(review) ⇒ Object
Build prompt for reviewing a comment on selected text.
-
#run(prompt) ⇒ Object
Run claude -p with the given prompt.
- #uploaded_files_prompt_section(ctx) ⇒ Object
Constructor Details
#initialize(config) ⇒ ClaudeRunner
Returns a new instance of ClaudeRunner.
11 12 13 |
# File 'lib/ligarb/claude_runner.rb', line 11 def initialize(config) @config = config end |
Instance Method Details
#apply_patches(review) ⇒ Object
Extract patches from the last assistant message and apply them. Supports cross-chapter patches via the file attribute. Uses transactional approach: all patches applied in memory first, then written to disk. On build failure, changes are rolled back.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/ligarb/claude_runner.rb', line 101 def apply_patches(review) patches = extract_patches(review) if patches.empty? hint = has_unmatched_patches?(review) ? " (patch tags found but format didn't match)" : "" return { "error" => "No patches found in the conversation#{hint}" } end default_source = review.dig("context", "source_file") # Group patches by target file file_patches = {} patches.each do |rel_path, old_text, new_text| target = if rel_path && !rel_path.empty? resolve_patch_file(rel_path) else default_source end next unless target file_patches[target] ||= [] file_patches[target] << [old_text, new_text] end return { "error" => "No valid target files found for patches" } if file_patches.empty? use_git = git_available? target_files = file_patches.keys # Check for uncommitted changes when git is available if use_git dirty = git_dirty_files(target_files) unless dirty.empty? return { "error" => "Cannot apply patches: uncommitted changes in #{dirty.join(", ")}" } end end # Phase 1: Apply all patches in memory applied = 0 total = patches.size results = {} # file => new_content backups = {} # file => original_content file_patches.each do |file, file_patch_list| next unless File.exist?(file) content = File.read(file) backups[file] = content file_patch_list.each do |old_text, new_text| if content.include?(old_text) content = content.sub(old_text, new_text) applied += 1 end end results[file] = content if content != backups[file] end return { "error" => "No patches matched the source files (0/#{total})" } if applied == 0 # Phase 2: Write all files at once results.each { |file, content| File.write(file, content) } # Phase 3: Rebuild config_path = File.join(@config.base_dir, "book.yml") require_relative "builder" begin Builder.new(config_path).build rescue SystemExit => e # Rollback on build failure if use_git git_rollback_files(results.keys) else backups.each { |file, content| File.write(file, content) if results.key?(file) } end return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed (rolled back): #{e.}" } end # Phase 4: Commit on success if use_git git_commit_patches(results.keys, review) end { "text" => "Applied #{applied}/#{total} patch(es) and rebuilt." } end |
#extract_patches(review) ⇒ Object
Parse <patch> blocks from assistant messages. Returns array of [file_path_or_nil, old_text, new_text].
201 202 203 204 205 206 207 208 209 210 |
# File 'lib/ligarb/claude_runner.rb', line 201 def extract_patches(review) (review["messages"] || []) .select { |m| m["role"] == "assistant" } .reverse .each do |msg| patches = msg["content"].scan(PATCH_RE) return patches unless patches.empty? end [] end |
#git_available? ⇒ Boolean
Check if git is available in the project directory.
220 221 222 223 |
# File 'lib/ligarb/claude_runner.rb', line 220 def git_available? system("git", "rev-parse", "--git-dir", chdir: @config.base_dir, out: File::NULL, err: File::NULL) end |
#has_unmatched_patches?(review) ⇒ Boolean
Check if assistant messages contain <patch> tags that didn’t match PATCH_RE.
213 214 215 216 217 |
# File 'lib/ligarb/claude_runner.rb', line 213 def has_unmatched_patches?(review) (review["messages"] || []) .select { |m| m["role"] == "assistant" } .any? { |m| m["content"].include?("<patch") && m["content"].include?("</patch>") } end |
#installed? ⇒ Boolean
15 16 17 18 19 20 21 22 23 24 |
# File 'lib/ligarb/claude_runner.rb', line 15 def installed? claude_path = which("claude") return :not_found unless claude_path if system(claude_path, "--version", out: File::NULL, err: File::NULL) true else :version_failed end end |
#review_prompt(review) ⇒ Object
Build prompt for reviewing a comment on selected text. Asks Claude to include <patch> blocks with concrete replacements. Points Claude to book.yml so it can read chapters and bibliography as needed.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/ligarb/claude_runner.rb', line 47 def review_prompt(review) ctx = review["context"] source_file = ctx["source_file"] config_path = File.join(@config.base_dir, "book.yml") = review["messages"].map { |m| "#{m["role"]}: #{m["content"]}" }.join("\n\n") uploaded_section = uploaded_files_prompt_section(ctx) <<~PROMPT You are reviewing a book built with ligarb. <ligarb-spec> #{CLI.spec_text} </ligarb-spec> #{uploaded_section} Book configuration: #{config_path} Read this file first to understand the book structure (chapters, bibliography, sources, etc.). Then read the chapter files and other files as needed to respond to the comment. The comment was made on: #{source_file} The reader selected this text: "#{ctx["selected_text"]}" Under heading: #{ctx["heading_id"]} Conversation so far: #{} Respond to the reader's comment with a concise explanation, then provide concrete patches. Each patch must use this exact format: <patch file="relative/path/to/file.md"> <<< exact text to find in the source (copied verbatim) === replacement text >>> </patch> Rules: - The file attribute must be the path relative to the directory containing book.yml - The text between <<< and === must match the source file EXACTLY (whitespace included) - You may include multiple <patch> blocks for one or more files - If the comment applies to multiple chapters, read all relevant chapters and provide patches for each - When adding citations ([@key]), also add the corresponding entry to the bibliography file - Use ligarb Markdown features from the spec where appropriate - If no code change is needed (e.g. answering a question), omit the <patch> blocks - Refuse changes that would introduce security issues (e.g. injecting scripts, untrusted URLs, or arbitrary HTML) PROMPT end |
#run(prompt) ⇒ Object
Run claude -p with the given prompt. Returns the text response.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/ligarb/claude_runner.rb', line 27 def run(prompt) cmd = ["claude", "-p", "-", "--model", "opus", "--output-format", "json"] stdout, stderr, status = Open3.capture3(*cmd, stdin_data: prompt) unless status.success? return { "error" => "Claude process failed: #{stderr.strip}" } end begin result = JSON.parse(stdout) text = result["result"] || stdout { "text" => text } rescue JSON::ParserError { "text" => stdout.strip } end end |
#uploaded_files_prompt_section(ctx) ⇒ Object
187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/ligarb/claude_runner.rb', line 187 def uploaded_files_prompt_section(ctx) files = ctx["uploaded_files"] return "" unless files.is_a?(Array) && !files.empty? lines = ["\nUploaded reference files (read these for context):"] files.each do |f| lines << "- #{f["label"]}: #{f["path"]}" end lines << "" lines.join("\n") end |