Class: Ligarb::ClaudeRunner

Inherits:
Object
  • Object
show all
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

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.message}" }
  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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

  • (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")

  messages = 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:
    #{messages}

    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