ligarb 0.5.0 → 0.7.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require "securerandom"
5
6
  require "fileutils"
6
7
 
@@ -8,82 +9,103 @@ module Ligarb
8
9
  class ReviewStore
9
10
  def initialize(base_dir)
10
11
  @dir = File.join(base_dir, ".ligarb", "reviews")
12
+ @mutex = Mutex.new
11
13
  FileUtils.mkdir_p(@dir)
12
14
  end
13
15
 
14
16
  def list
15
- Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
16
- .compact
17
- .sort_by { |r| r["created_at"] }
18
- .map { |r| summary(r) }
17
+ @mutex.synchronize do
18
+ Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
19
+ .compact
20
+ .sort_by { |r| r["created_at"] }
21
+ .map { |r| summary(r) }
22
+ end
19
23
  end
20
24
 
21
25
  def get(id)
22
- path = file_path(id)
23
- return nil unless File.exist?(path)
24
- read_json(path)
26
+ @mutex.synchronize do
27
+ get_unlocked(id)
28
+ end
25
29
  end
26
30
 
27
31
  def create(context:, message:)
28
- id = SecureRandom.uuid
29
- now = Time.now.utc.iso8601
30
-
31
- review = {
32
- "id" => id,
33
- "status" => "open",
34
- "created_at" => now,
35
- "context" => context,
36
- "messages" => [
37
- { "role" => "user", "content" => message, "timestamp" => now }
38
- ]
39
- }
40
-
41
- write_json(id, review)
42
- review
32
+ @mutex.synchronize do
33
+ id = SecureRandom.uuid
34
+ now = Time.now.utc.iso8601
35
+
36
+ review = {
37
+ "id" => id,
38
+ "status" => "open",
39
+ "created_at" => now,
40
+ "context" => context,
41
+ "messages" => [
42
+ { "role" => "user", "content" => message, "timestamp" => now }
43
+ ]
44
+ }
45
+
46
+ write_json(id, review)
47
+ review
48
+ end
43
49
  end
44
50
 
45
51
  def add_message(id, role:, content:)
46
- review = get(id)
47
- return nil unless review
48
-
49
- review["messages"] << {
50
- "role" => role,
51
- "content" => content,
52
- "timestamp" => Time.now.utc.iso8601
53
- }
54
-
55
- write_json(id, review)
56
- review
52
+ @mutex.synchronize do
53
+ review = get_unlocked(id)
54
+ return nil unless review
55
+
56
+ review["messages"] << {
57
+ "role" => role,
58
+ "content" => content,
59
+ "timestamp" => Time.now.utc.iso8601
60
+ }
61
+
62
+ write_json(id, review)
63
+ review
64
+ end
57
65
  end
58
66
 
59
67
  def update_context_files(id, files)
60
- review = get(id)
61
- return nil unless review
62
-
63
- existing = review.dig("context", "uploaded_files") || []
64
- review["context"]["uploaded_files"] = existing + files
65
- write_json(id, review)
66
- review
68
+ @mutex.synchronize do
69
+ review = get_unlocked(id)
70
+ return nil unless review
71
+
72
+ existing = review.dig("context", "uploaded_files") || []
73
+ review["context"]["uploaded_files"] = existing + files
74
+ write_json(id, review)
75
+ review
76
+ end
67
77
  end
68
78
 
69
79
  def update_status(id, status)
70
- review = get(id)
71
- return nil unless review
72
-
73
- review["status"] = status
74
- write_json(id, review)
75
- review
80
+ @mutex.synchronize do
81
+ review = get_unlocked(id)
82
+ return nil unless review
83
+
84
+ review["status"] = status
85
+ write_json(id, review)
86
+ review
87
+ end
76
88
  end
77
89
 
78
90
  def delete(id)
79
- path = file_path(id)
80
- return false unless File.exist?(path)
81
- File.delete(path)
82
- true
91
+ @mutex.synchronize do
92
+ path = file_path(id)
93
+ return false unless File.exist?(path)
94
+ File.delete(path)
95
+ true
96
+ end
83
97
  end
84
98
 
85
99
  private
86
100
 
101
+ def get_unlocked(id)
102
+ path = file_path(id)
103
+ return nil unless File.exist?(path)
104
+ review = read_json(path)
105
+ review["file_path"] = path if review
106
+ review
107
+ end
108
+
87
109
  def file_path(id)
88
110
  File.join(@dir, "#{id}.json")
89
111
  end
data/lib/ligarb/server.rb CHANGED
@@ -14,7 +14,7 @@ module Ligarb
14
14
 
15
15
  BookEntry = Struct.new(:slug, :config, :config_path, :build_dir, :store, :claude, keyword_init: true)
16
16
 
17
- def initialize(config_paths, port: 3000)
17
+ def initialize(config_paths, port: 3000, multi: false)
18
18
  @port = port
19
19
  @assets_dir = File.join(File.dirname(__FILE__), "..", "..", "assets")
20
20
  @sse_clients = [] # [[slug, queue], ...]
@@ -37,7 +37,11 @@ module Ligarb
37
37
  )
38
38
  end
39
39
 
40
- @multi = @books.size > 1
40
+ @multi = multi || @books.size > 1
41
+
42
+ @operation_queue = Queue.new
43
+ @operation_thread = Thread.new { operation_worker }
44
+ @operation_thread.abort_on_exception = true
41
45
  end
42
46
 
43
47
  def start
@@ -49,6 +53,7 @@ module Ligarb
49
53
 
50
54
  server = WEBrick::HTTPServer.new(
51
55
  Port: @port,
56
+ BindAddress: "127.0.0.1",
52
57
  Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
53
58
  AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]],
54
59
  RequestBodyMaxSize: 50 * 1024 * 1024
@@ -77,6 +82,27 @@ module Ligarb
77
82
 
78
83
  private
79
84
 
85
+ # ── Operation Queue ──
86
+
87
+ def operation_worker
88
+ loop do
89
+ job = @operation_queue.pop
90
+ job.call
91
+ end
92
+ end
93
+
94
+ def enqueue_sync(&block)
95
+ result_queue = Queue.new
96
+ @operation_queue.push(-> {
97
+ result_queue.push(block.call)
98
+ })
99
+ result_queue.pop
100
+ end
101
+
102
+ def enqueue_async(&block)
103
+ @operation_queue.push(-> { block.call })
104
+ end
105
+
80
106
  # ── SSE (Server-Sent Events) ──
81
107
 
82
108
  def close_sse_clients
@@ -220,7 +246,8 @@ module Ligarb
220
246
  file_path = File.join(build_dir, path)
221
247
  file_path = File.realpath(file_path) rescue nil
222
248
 
223
- if file_path && file_path.start_with?(File.realpath(build_dir)) && File.file?(file_path)
249
+ real_build_dir = File.realpath(build_dir)
250
+ if file_path && (file_path.start_with?(real_build_dir + "/") || file_path == real_build_dir) && File.file?(file_path)
224
251
  res.body = File.binread(file_path)
225
252
  res["Content-Type"] = mime_type(file_path)
226
253
  else
@@ -238,7 +265,7 @@ module Ligarb
238
265
 
239
266
  api_base = @multi ? "/_ligarb/#{book.slug}" : "/_ligarb"
240
267
  page_base = @multi ? "/#{book.slug}/" : "/"
241
- config_tag = %(<script>window._ligarbAPI='#{api_base}';window._ligarbBase='#{page_base}';</script>)
268
+ config_tag = %(<script>window._ligarbAPI='#{escape_js_string(api_base)}';window._ligarbBase='#{escape_js_string(page_base)}';</script>)
242
269
 
243
270
  js_tags = [config_tag] + %w[serve.js review.js].map { |f|
244
271
  %(<script src="/_ligarb/assets/#{f}"></script>)
@@ -251,10 +278,13 @@ module Ligarb
251
278
 
252
279
  def serve_index_page(res)
253
280
  books_data = @books.values.sort_by { |b| b.config.title }.map { |book|
281
+ html_path = File.join(book.build_dir, "index.html")
282
+ mtime = File.exist?(html_path) ? File.mtime(html_path).strftime("%Y-%m-%d %H:%M") : nil
254
283
  {
255
284
  slug: book.slug,
256
285
  title: book.config.title,
257
286
  author: book.config.author.to_s,
287
+ updated_at: mtime,
258
288
  toc: build_toc(book)
259
289
  }
260
290
  }
@@ -267,7 +297,7 @@ module Ligarb
267
297
  <head>
268
298
  <meta charset="utf-8">
269
299
  <meta name="viewport" content="width=device-width, initial-scale=1">
270
- <title>ligarb</title>
300
+ <title>ligarb librarium</title>
271
301
  <style>
272
302
  * { margin: 0; padding: 0; box-sizing: border-box; }
273
303
  body { font-family: system-ui, -apple-system, sans-serif; color: #333; height: 100vh; display: flex; flex-direction: column; }
@@ -280,6 +310,7 @@ module Ligarb
280
310
  .idx-book.active { background: #eff6ff; border-left: 3px solid #2563eb; padding-left: 13px; }
281
311
  .idx-book-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
282
312
  .idx-book-author { font-size: 13px; color: #666; margin-top: 2px; }
313
+ .idx-book-updated { font-size: 12px; color: #999; margin-top: 2px; }
283
314
  .idx-badge { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
284
315
  .idx-badge-writing { background: #fff3e0; color: #e65100; animation: idx-pulse 1.5s ease-in-out infinite; }
285
316
  .idx-badge-done { background: #e8f5e9; color: #2e7d32; }
@@ -321,7 +352,7 @@ module Ligarb
321
352
  </style>
322
353
  </head>
323
354
  <body>
324
- <div class="idx-header">Books</div>
355
+ <div class="idx-header">Machinascripta</div>
325
356
  <div class="idx-container">
326
357
  <div class="idx-books">
327
358
  <div class="idx-books-list" id="idx-books"></div>
@@ -363,7 +394,8 @@ module Ligarb
363
394
  var job = writeJobs.find(function(j) { return j.slug === book.slug && j.status === 'done'; });
364
395
  if (job) badge = ' <span class="idx-badge idx-badge-done">New!</span>';
365
396
  el.innerHTML = '<div class="idx-book-title">' + esc(book.title) + badge + '</div>' +
366
- (book.author ? '<div class="idx-book-author">' + esc(book.author) + '</div>' : '');
397
+ (book.author ? '<div class="idx-book-author">' + esc(book.author) + '</div>' : '') +
398
+ (book.updated_at ? '<div class="idx-book-updated">' + esc(book.updated_at) + '</div>' : '');
367
399
  el.addEventListener('click', function() {
368
400
  if (activeEl) activeEl.classList.remove('active');
369
401
  el.classList.add('active');
@@ -606,8 +638,9 @@ module Ligarb
606
638
  </html>
607
639
  HTML
608
640
 
609
- html = html.sub("__BOOKS_JSON__", books_json)
610
- html = html.sub("__WRITE_JOBS_JSON__", write_jobs_json)
641
+ # Escape </ to prevent script injection inside <script> tag
642
+ html = html.sub("__BOOKS_JSON__", books_json.gsub("</", '<\/'))
643
+ html = html.sub("__WRITE_JOBS_JSON__", write_jobs_json.gsub("</", '<\/'))
611
644
  res.body = html
612
645
  res["Content-Type"] = "text/html; charset=utf-8"
613
646
  end
@@ -668,7 +701,32 @@ module Ligarb
668
701
 
669
702
  # ── API routing ──
670
703
 
704
+ def csrf_safe?(req)
705
+ return true if req.request_method == "GET"
706
+
707
+ origin = req["Origin"]
708
+ if origin && !origin.empty?
709
+ return origin == "http://localhost:#{@port}" ||
710
+ origin == "http://127.0.0.1:#{@port}"
711
+ end
712
+
713
+ referer = req["Referer"]
714
+ if referer && !referer.empty?
715
+ uri = URI.parse(referer) rescue nil
716
+ return uri && %w[localhost 127.0.0.1].include?(uri.host) && uri.port == @port
717
+ end
718
+
719
+ false
720
+ end
721
+
671
722
  def handle_api(req, res)
723
+ unless csrf_safe?(req)
724
+ res.status = 403
725
+ res["Content-Type"] = "application/json; charset=utf-8"
726
+ res.body = JSON.generate({ error: "CSRF check failed: request must originate from localhost" })
727
+ return
728
+ end
729
+
672
730
  method = req.request_method
673
731
  path = req.path.sub(%r{^/_ligarb}, "")
674
732
 
@@ -831,9 +889,51 @@ module Ligarb
831
889
  end
832
890
 
833
891
  log "Approve: applying patches for review #{id}"
834
- result = book.claude.apply_patches(review)
835
892
 
836
- if result["error"]
893
+ result = enqueue_sync { book.claude.apply_patches(review) }
894
+
895
+ if result["error"] && result["error"].include?("rebuild failed")
896
+ # Build failed — feed the error back to Claude for a retry
897
+ log "Approve: build failed, requesting fix from Claude"
898
+ error_msg = result["error"]
899
+ book.store.add_message(id, role: "assistant", content: "Error: #{error_msg}")
900
+ sse_broadcast("review_updated", { id: id }, slug: book.slug)
901
+
902
+ retry_msg = "The patches were applied but the build failed with this error:\n\n" \
903
+ "#{error_msg}\n\n" \
904
+ "Please provide corrected patches that fix this issue."
905
+ book.store.add_message(id, role: "user", content: retry_msg)
906
+ sse_broadcast("review_updated", { id: id }, slug: book.slug)
907
+
908
+ # Re-run Claude with the updated conversation
909
+ review = book.store.get(id)
910
+ prompt = book.claude.review_prompt(review)
911
+ claude_result = book.claude.run(prompt)
912
+
913
+ if claude_result["error"]
914
+ log "Approve: retry Claude error: #{claude_result["error"]}"
915
+ book.store.add_message(id, role: "assistant", content: "Error: #{claude_result["error"]}")
916
+ book.store.update_status(id, "open")
917
+ else
918
+ log "Approve: Claude provided fix, retrying patches"
919
+ book.store.add_message(id, role: "assistant", content: claude_result["text"])
920
+ sse_broadcast("review_updated", { id: id }, slug: book.slug)
921
+
922
+ # Retry applying patches with the new response
923
+ review = book.store.get(id)
924
+ retry_result = enqueue_sync { book.claude.apply_patches(review) }
925
+
926
+ if retry_result["error"]
927
+ log "Approve: retry failed: #{retry_result["error"]}"
928
+ book.store.add_message(id, role: "assistant", content: "Retry failed: #{retry_result["error"]}")
929
+ book.store.update_status(id, "open")
930
+ else
931
+ log "Approve: retry succeeded: #{retry_result["text"]}"
932
+ book.store.add_message(id, role: "assistant", content: retry_result["text"])
933
+ book.store.update_status(id, "applied")
934
+ end
935
+ end
936
+ elsif result["error"]
837
937
  log "Approve: error: #{result["error"]}"
838
938
  book.store.add_message(id, role: "assistant", content: "Error: #{result["error"]}")
839
939
  book.store.update_status(id, "open")
@@ -883,13 +983,17 @@ module Ligarb
883
983
  log "Review: starting Claude review for #{review_id}"
884
984
  begin
885
985
  review = book.store.get(review_id)
886
- return unless review
986
+ next unless review
887
987
 
888
- unless book.claude.installed?
988
+ case book.claude.installed?
989
+ when :not_found
889
990
  book.store.add_message(review_id, role: "assistant",
890
991
  content: "Error: 'claude' command not found. Install Claude Code to enable AI reviews.")
891
- sse_broadcast("review_updated", { id: review_id }, slug: book.slug)
892
- return
992
+ next
993
+ when :version_failed
994
+ book.store.add_message(review_id, role: "assistant",
995
+ content: "Error: 'claude' command was found but failed to run. Check your Claude Code installation.")
996
+ next
893
997
  end
894
998
 
895
999
  prompt = book.claude.review_prompt(review)
@@ -968,8 +1072,8 @@ module Ligarb
968
1072
 
969
1073
  sse_broadcast("write_updated", write_jobs_data, slug: nil)
970
1074
 
971
- # Background thread for writing
972
- Thread.new do
1075
+ # Run write job via operation queue (async)
1076
+ enqueue_async do
973
1077
  begin
974
1078
  log "Write: starting for '#{directory}' (title: #{title})"
975
1079
  require_relative "writer"
@@ -980,6 +1084,16 @@ module Ligarb
980
1084
  require_relative "builder"
981
1085
  Builder.new(book_yml_path).build
982
1086
 
1087
+ # Git init + commit for the generated book
1088
+ unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target_dir)
1089
+ gitignore = File.join(target_dir, ".gitignore")
1090
+ File.write(gitignore, "# Generated by ligarb - remove lines as needed\nbuild/\n.ligarb/\n") unless File.exist?(gitignore)
1091
+ system("git", "init", chdir: target_dir, out: File::NULL, err: File::NULL)
1092
+ end
1093
+ system("git", "add", "-A", chdir: target_dir, out: File::NULL, err: File::NULL)
1094
+ system("git", "commit", "-m", "[ligarb] Generate book: #{title}",
1095
+ chdir: target_dir, out: File::NULL, err: File::NULL)
1096
+
983
1097
  # Register the new book
984
1098
  config = Config.new(book_yml_path)
985
1099
  book = BookEntry.new(
@@ -1022,6 +1136,8 @@ module Ligarb
1022
1136
 
1023
1137
  # ── Helpers ──
1024
1138
 
1139
+ MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 # 10MB per file
1140
+
1025
1141
  def save_uploaded_files(files_array, base_dir)
1026
1142
  return [] unless files_array.is_a?(Array) && !files_array.empty?
1027
1143
 
@@ -1033,13 +1149,19 @@ module Ligarb
1033
1149
  data = file["data"].to_s
1034
1150
  next if name.empty? || data.empty?
1035
1151
 
1152
+ decoded = Base64.decode64(data)
1153
+ if decoded.bytesize > MAX_UPLOAD_FILE_SIZE
1154
+ log "Upload rejected: #{name} exceeds #{MAX_UPLOAD_FILE_SIZE} bytes"
1155
+ next
1156
+ end
1157
+
1036
1158
  # Sanitize filename
1037
1159
  safe_name = File.basename(name).gsub(/[^a-zA-Z0-9._-]/, "_")
1038
1160
  short_id = SecureRandom.hex(4)
1039
1161
  filename = "#{short_id}-#{safe_name}"
1040
1162
  dest = File.join(upload_dir, filename)
1041
1163
 
1042
- File.binwrite(dest, Base64.decode64(data))
1164
+ File.binwrite(dest, decoded)
1043
1165
  { "path" => File.expand_path(dest), "label" => name }
1044
1166
  end
1045
1167
  end
@@ -1072,6 +1194,11 @@ module Ligarb
1072
1194
  str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
1073
1195
  end
1074
1196
 
1197
+ def escape_js_string(str)
1198
+ str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub('"', '\\\\"')
1199
+ .gsub("\n", "\\n").gsub("\r", "\\r").gsub("</", '<\\/')
1200
+ end
1201
+
1075
1202
  def mime_type(path)
1076
1203
  case File.extname(path).downcase
1077
1204
  when ".html" then "text/html; charset=utf-8"
@@ -35,12 +35,72 @@ module Ligarb
35
35
  b.local_variable_set(:footer, config.effective_footer)
36
36
  b.local_variable_set(:index_tree, build_index_tree(index_entries, chapters))
37
37
  b.local_variable_set(:bibliography, bibliography)
38
+ b.local_variable_set(:multilang, false)
39
+ b.local_variable_set(:langs, [])
40
+
41
+ ERB.new(template, trim_mode: "-").result(b)
42
+ end
43
+
44
+ # Render a single HTML with all languages, switchable via JS
45
+ def render_multilang(langs:, assets:, hub_data:)
46
+ css = File.read(@css_path)
47
+ template = File.read(@template_path)
48
+
49
+ first = langs.first
50
+ first_config = first[:config]
51
+
52
+ custom_css = if first_config.style_path && File.exist?(first_config.style_path)
53
+ File.read(first_config.style_path)
54
+ end
55
+
56
+ # Build per-language template data
57
+ lang_data = langs.map do |ld|
58
+ cfg = ld[:config]
59
+ {
60
+ lang: ld[:lang],
61
+ title: cfg.title,
62
+ author: cfg.author,
63
+ language: cfg.language,
64
+ chapters: ld[:chapters],
65
+ structure: ld[:structure],
66
+ repository: cfg.repository,
67
+ appendix_label: cfg.appendix_label,
68
+ ai_generated: cfg.ai_generated,
69
+ footer: cfg.effective_footer,
70
+ index_tree: build_index_tree(ld[:index_entries], ld[:chapters]),
71
+ bibliography: ld[:bibliography],
72
+ }
73
+ end
74
+
75
+ b = binding
76
+ # Use first language's values as defaults for shared template vars
77
+ b.local_variable_set(:title, first_config.title)
78
+ b.local_variable_set(:author, first_config.author)
79
+ b.local_variable_set(:language, first_config.language)
80
+ b.local_variable_set(:chapters, first[:chapters])
81
+ b.local_variable_set(:structure, first[:structure])
82
+ b.local_variable_set(:css, css)
83
+ b.local_variable_set(:custom_css, custom_css)
84
+ b.local_variable_set(:assets, assets)
85
+ b.local_variable_set(:repository, first_config.repository)
86
+ b.local_variable_set(:appendix_label, first_config.appendix_label)
87
+ b.local_variable_set(:ai_generated, first_config.ai_generated)
88
+ b.local_variable_set(:footer, first_config.effective_footer)
89
+ b.local_variable_set(:index_tree, build_index_tree(first[:index_entries], first[:chapters]))
90
+ b.local_variable_set(:bibliography, first[:bibliography])
91
+ b.local_variable_set(:multilang, true)
92
+ b.local_variable_set(:langs, lang_data)
38
93
 
39
94
  ERB.new(template, trim_mode: "-").result(b)
40
95
  end
41
96
 
42
97
  private
43
98
 
99
+ # HTML-escape helper for ERB templates (available via binding)
100
+ def h(s)
101
+ ERB::Util.html_escape(s.to_s)
102
+ end
103
+
44
104
  # Build a sorted tree structure for the index.
45
105
  # Returns: { "A" => [ { term: "Algorithm", refs: [...] },
46
106
  # { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/ligarb/writer.rb CHANGED
@@ -41,6 +41,8 @@ module Ligarb
41
41
  Builder.new(book_yml_path).build
42
42
  end
43
43
 
44
+ git_commit_initial(output_dir, brief["title"]) if git_available?(output_dir)
45
+
44
46
  book_yml_path
45
47
  end
46
48
 
@@ -90,15 +92,38 @@ module Ligarb
90
92
  puts "Created #{path}"
91
93
  puts "Created #{claude_md}" if created_claude_md
92
94
  brief_arg = directory ? " #{path}" : ""
95
+ unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target)
96
+ gitignore = File.join(target, ".gitignore")
97
+ unless File.exist?(gitignore)
98
+ File.write(gitignore, "# Generated by ligarb - remove lines as needed\nbuild/\n.ligarb/\n")
99
+ end
100
+ system("git", "init", chdir: target)
101
+ puts "Initialized git repository"
102
+ end
103
+
93
104
  puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
94
105
  end
95
106
 
96
107
  private
97
108
 
109
+ def git_available?(dir)
110
+ system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: dir)
111
+ end
112
+
113
+ def git_commit_initial(dir, title)
114
+ system("git", "add", "-A", chdir: dir, out: File::NULL, err: File::NULL)
115
+ system("git", "commit", "-m", "[ligarb] Generate book: #{title}",
116
+ chdir: dir, out: File::NULL, err: File::NULL)
117
+ end
118
+
98
119
  def check_claude_installed!
99
- unless system("claude", "--version", out: File::NULL, err: File::NULL)
120
+ claude_path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, "claude")) }
121
+ unless claude_path
100
122
  raise WriterError, "'claude' command not found. Install Claude Code first."
101
123
  end
124
+ unless system("claude", "--version", out: File::NULL, err: File::NULL)
125
+ raise WriterError, "'claude' command was found but failed to run. Check your Claude Code installation."
126
+ end
102
127
  end
103
128
 
104
129
  def load_brief