ligarb 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/assets/review.css +682 -0
- data/assets/review.js +684 -0
- data/assets/serve.js +97 -0
- data/assets/style.css +103 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +32 -4
- data/lib/ligarb/claude_runner.rb +313 -0
- data/lib/ligarb/cli.rb +207 -9
- data/lib/ligarb/config.rb +25 -1
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +133 -0
- data/lib/ligarb/server.rb +1218 -0
- data/lib/ligarb/template.rb +7 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +96 -18
- data/templates/book.html.erb +226 -32
- metadata +36 -1
data/lib/ligarb/template.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Ligarb
|
|
|
12
12
|
@css_path = File.join(ASSETS_DIR, "style.css")
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def render(config:, chapters:, structure:, assets:, index_entries: [])
|
|
15
|
+
def render(config:, chapters:, structure:, assets:, index_entries: [], bibliography: [])
|
|
16
16
|
css = File.read(@css_path)
|
|
17
17
|
template = File.read(@template_path)
|
|
18
18
|
|
|
@@ -34,12 +34,18 @@ module Ligarb
|
|
|
34
34
|
b.local_variable_set(:ai_generated, config.ai_generated)
|
|
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
|
+
b.local_variable_set(:bibliography, bibliography)
|
|
37
38
|
|
|
38
39
|
ERB.new(template, trim_mode: "-").result(b)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
44
|
+
# HTML-escape helper for ERB templates (available via binding)
|
|
45
|
+
def h(s)
|
|
46
|
+
ERB::Util.html_escape(s.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
# Build a sorted tree structure for the index.
|
|
44
50
|
# Returns: { "A" => [ { term: "Algorithm", refs: [...] },
|
|
45
51
|
# { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
|
data/lib/ligarb/version.rb
CHANGED
data/lib/ligarb/writer.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "json"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
|
|
6
7
|
module Ligarb
|
|
7
8
|
class Writer
|
|
9
|
+
class WriterError < RuntimeError; end
|
|
10
|
+
|
|
8
11
|
BRIEF_FIELDS_FOR_BOOK_YML = %w[author output_dir chapter_numbers style repository].freeze
|
|
9
12
|
|
|
10
13
|
def initialize(brief_path, no_build: false)
|
|
@@ -19,8 +22,7 @@ module Ligarb
|
|
|
19
22
|
book_yml_path = File.join(output_dir, "book.yml")
|
|
20
23
|
|
|
21
24
|
if File.exist?(book_yml_path)
|
|
22
|
-
|
|
23
|
-
exit 1
|
|
25
|
+
raise WriterError, "#{book_yml_path} already exists. Remove it first to regenerate."
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
FileUtils.mkdir_p(output_dir)
|
|
@@ -28,8 +30,7 @@ module Ligarb
|
|
|
28
30
|
run_claude(prompt)
|
|
29
31
|
|
|
30
32
|
unless File.exist?(book_yml_path)
|
|
31
|
-
|
|
32
|
-
exit 1
|
|
33
|
+
raise WriterError, "Claude did not generate book.yml in #{output_dir}"
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
puts "Book files generated in #{output_dir}"
|
|
@@ -39,6 +40,10 @@ module Ligarb
|
|
|
39
40
|
require_relative "builder"
|
|
40
41
|
Builder.new(book_yml_path).build
|
|
41
42
|
end
|
|
43
|
+
|
|
44
|
+
git_commit_initial(output_dir, brief["title"]) if git_available?(output_dir)
|
|
45
|
+
|
|
46
|
+
book_yml_path
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
def self.init_brief(directory = nil)
|
|
@@ -47,8 +52,7 @@ module Ligarb
|
|
|
47
52
|
path = File.join(target, "brief.yml")
|
|
48
53
|
|
|
49
54
|
if File.exist?(path)
|
|
50
|
-
|
|
51
|
-
exit 1
|
|
55
|
+
raise WriterError, "#{path} already exists."
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
FileUtils.mkdir_p(target)
|
|
@@ -88,28 +92,48 @@ module Ligarb
|
|
|
88
92
|
puts "Created #{path}"
|
|
89
93
|
puts "Created #{claude_md}" if created_claude_md
|
|
90
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
|
+
|
|
91
104
|
puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
private
|
|
95
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
|
+
|
|
96
119
|
def check_claude_installed!
|
|
120
|
+
claude_path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, "claude")) }
|
|
121
|
+
unless claude_path
|
|
122
|
+
raise WriterError, "'claude' command not found. Install Claude Code first."
|
|
123
|
+
end
|
|
97
124
|
unless system("claude", "--version", out: File::NULL, err: File::NULL)
|
|
98
|
-
|
|
99
|
-
exit 1
|
|
125
|
+
raise WriterError, "'claude' command was found but failed to run. Check your Claude Code installation."
|
|
100
126
|
end
|
|
101
127
|
end
|
|
102
128
|
|
|
103
129
|
def load_brief
|
|
104
130
|
unless File.exist?(@brief_path)
|
|
105
|
-
|
|
106
|
-
exit 1
|
|
131
|
+
raise WriterError, "#{@brief_path} not found."
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
brief = YAML.safe_load_file(@brief_path)
|
|
110
135
|
unless brief.is_a?(Hash) && brief["title"] && !brief["title"].empty?
|
|
111
|
-
|
|
112
|
-
exit 1
|
|
136
|
+
raise WriterError, "'title' is required in #{@brief_path}."
|
|
113
137
|
end
|
|
114
138
|
|
|
115
139
|
brief
|
|
@@ -148,6 +172,13 @@ module Ligarb
|
|
|
148
172
|
lines << "In book.yml, set: #{settings}"
|
|
149
173
|
end
|
|
150
174
|
|
|
175
|
+
sources = parse_brief_sources(brief)
|
|
176
|
+
if sources.any?
|
|
177
|
+
lines << ""
|
|
178
|
+
lines << "Reference sources (read these files for context):"
|
|
179
|
+
sources.each { |src| lines << "- #{src[:label]}: #{src[:path]}" }
|
|
180
|
+
end
|
|
181
|
+
|
|
151
182
|
lines << ""
|
|
152
183
|
lines << "Create all files in: #{abs_output_dir}"
|
|
153
184
|
lines << "In book.yml, always set: ai_generated: true"
|
|
@@ -156,18 +187,65 @@ module Ligarb
|
|
|
156
187
|
lines << "Each chapter: substantive content with multiple ## sections."
|
|
157
188
|
lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
|
|
158
189
|
lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
|
|
190
|
+
lines << ""
|
|
191
|
+
lines << "Create references.bib with real bibliography entries in BibTeX format."
|
|
192
|
+
lines << "In book.yml, set: bibliography: references.bib"
|
|
193
|
+
lines << "Cite references in chapter text using [text](#cite:key) syntax."
|
|
194
|
+
lines << "Search the web to find accurate bibliographic information (DOI, pages, volume, etc.)."
|
|
195
|
+
lines << "Do NOT fabricate references. Only include real, verifiable publications."
|
|
196
|
+
lines << "Use entry types: @article, @inproceedings, @book, @misc as appropriate."
|
|
197
|
+
lines << "Use UTF-8 directly for special characters (no LaTeX commands like \\c{c})."
|
|
198
|
+
lines << "BibTeX keys: authorsurname+year (e.g. knuth1984, cousot1977)."
|
|
159
199
|
|
|
160
200
|
lines.join("\n")
|
|
161
201
|
end
|
|
162
202
|
|
|
203
|
+
def parse_brief_sources(brief)
|
|
204
|
+
base_dir = File.dirname(@brief_path)
|
|
205
|
+
(brief["sources"] || []).map do |entry|
|
|
206
|
+
case entry
|
|
207
|
+
when String
|
|
208
|
+
{ path: File.expand_path(entry, base_dir), label: File.basename(entry) }
|
|
209
|
+
when Hash
|
|
210
|
+
path = entry["path"] or raise WriterError, "source entry missing 'path'"
|
|
211
|
+
{ path: File.expand_path(path, base_dir), label: entry.fetch("label", File.basename(path)) }
|
|
212
|
+
else
|
|
213
|
+
raise WriterError, "invalid source entry: #{entry.inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
163
218
|
def run_claude(prompt)
|
|
164
|
-
tools = "Write,Bash,WebFetch,WebSearch"
|
|
165
|
-
allowed = "Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
|
|
166
|
-
cmd = ["claude", "-p",
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
219
|
+
tools = "Read,Write,Bash,WebFetch,WebSearch"
|
|
220
|
+
allowed = "Read,Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
|
|
221
|
+
cmd = ["claude", "-p", prompt, "--tools", tools, "--allowedTools", allowed,
|
|
222
|
+
"--output-format", "stream-json", "--verbose"]
|
|
223
|
+
puts "Writing with Claude... (this may take a few minutes)"
|
|
224
|
+
unparsed_lines = []
|
|
225
|
+
IO.popen(cmd, err: [:child, :out]) do |io|
|
|
226
|
+
io.each_line do |line|
|
|
227
|
+
unless parse_stream_event(line)
|
|
228
|
+
unparsed_lines << line.rstrip
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
unless $?.success?
|
|
233
|
+
msg = unparsed_lines.reject(&:empty?).last(10).join("\n")
|
|
234
|
+
raise WriterError, "Claude process failed.#{"\n#{msg}" unless msg.empty?}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def parse_stream_event(line)
|
|
239
|
+
json = JSON.parse(line) rescue (return false)
|
|
240
|
+
case json["type"]
|
|
241
|
+
when "content_block_start"
|
|
242
|
+
tool = json.dig("content_block", "tool_use")
|
|
243
|
+
if tool
|
|
244
|
+
name = tool["name"]
|
|
245
|
+
puts " [#{name}]..." if name
|
|
246
|
+
end
|
|
170
247
|
end
|
|
248
|
+
true
|
|
171
249
|
end
|
|
172
250
|
end
|
|
173
251
|
end
|