openclacky 0.9.7 → 0.9.9
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/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
data/lib/clacky/cli.rb
CHANGED
|
@@ -103,8 +103,11 @@ module Clacky
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# Create new agent if no session loaded
|
|
106
|
-
agent
|
|
107
|
-
|
|
106
|
+
if agent.nil?
|
|
107
|
+
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile,
|
|
108
|
+
session_id: Clacky::SessionManager.generate_id, source: :manual)
|
|
109
|
+
agent.rename("CLI Session")
|
|
110
|
+
end
|
|
108
111
|
|
|
109
112
|
# Change to working directory
|
|
110
113
|
original_dir = Dir.pwd
|
|
@@ -314,6 +317,18 @@ module Clacky
|
|
|
314
317
|
end
|
|
315
318
|
end
|
|
316
319
|
|
|
320
|
+
CLI_DEFAULT_SESSION_NAME = "CLI Session"
|
|
321
|
+
|
|
322
|
+
# Auto-name a CLI session from the first user message, mirroring server-side logic.
|
|
323
|
+
# Renames when the agent has no history yet (i.e. first message of the session).
|
|
324
|
+
private def auto_name_session(agent, input)
|
|
325
|
+
return unless agent.history.empty?
|
|
326
|
+
|
|
327
|
+
auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 30]
|
|
328
|
+
auto_name += "…" if input.to_s.strip.length > 30
|
|
329
|
+
agent.rename(auto_name)
|
|
330
|
+
end
|
|
331
|
+
|
|
317
332
|
def validate_working_directory(path)
|
|
318
333
|
working_dir = path || Dir.pwd
|
|
319
334
|
|
|
@@ -348,7 +363,7 @@ module Clacky
|
|
|
348
363
|
def list_sessions
|
|
349
364
|
session_manager = Clacky::SessionManager.new
|
|
350
365
|
working_dir = validate_working_directory(options[:path])
|
|
351
|
-
sessions = session_manager.
|
|
366
|
+
sessions = session_manager.all_sessions(current_dir: working_dir, limit: 5)
|
|
352
367
|
|
|
353
368
|
if sessions.empty?
|
|
354
369
|
say "No sessions found.", :yellow
|
|
@@ -379,13 +394,18 @@ module Clacky
|
|
|
379
394
|
return nil
|
|
380
395
|
end
|
|
381
396
|
|
|
397
|
+
# Prefer the agent_profile stored in the session; only fall back to the
|
|
398
|
+
# CLI --agent flag when the session predates the agent_profile field.
|
|
399
|
+
restored_profile = session_data[:agent_profile].to_s
|
|
400
|
+
resolved_profile = restored_profile.empty? ? profile : restored_profile
|
|
401
|
+
|
|
382
402
|
# Don't print message here - will be shown by UI after banner
|
|
383
|
-
Clacky::Agent.from_session(client, agent_config, session_data, profile:
|
|
403
|
+
Clacky::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
|
|
384
404
|
end
|
|
385
405
|
|
|
386
406
|
def load_session_by_number(client, agent_config, session_manager, working_dir, identifier, profile:)
|
|
387
407
|
# Get a larger list to search through (for ID prefix matching)
|
|
388
|
-
sessions = session_manager.
|
|
408
|
+
sessions = session_manager.all_sessions(current_dir: working_dir, limit: 100)
|
|
389
409
|
|
|
390
410
|
if sessions.empty?
|
|
391
411
|
say "No sessions found.", :yellow
|
|
@@ -426,8 +446,13 @@ module Clacky
|
|
|
426
446
|
end
|
|
427
447
|
end
|
|
428
448
|
|
|
449
|
+
# Prefer the agent_profile stored in the session; fall back to CLI --agent flag
|
|
450
|
+
# for sessions that predate the agent_profile field.
|
|
451
|
+
restored_profile = session_data[:agent_profile].to_s
|
|
452
|
+
resolved_profile = restored_profile.empty? ? profile : restored_profile
|
|
453
|
+
|
|
429
454
|
# Don't print message here - will be shown by UI after banner
|
|
430
|
-
Clacky::Agent.from_session(client, agent_config, session_data, profile:
|
|
455
|
+
Clacky::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
|
|
431
456
|
end
|
|
432
457
|
|
|
433
458
|
# Handle agent error/interrupt with cleanup
|
|
@@ -467,6 +492,7 @@ module Clacky
|
|
|
467
492
|
plain_ui = Clacky::PlainUIController.new
|
|
468
493
|
agent.instance_variable_set(:@ui, plain_ui)
|
|
469
494
|
|
|
495
|
+
auto_name_session(agent, message)
|
|
470
496
|
agent.run(message, files: files)
|
|
471
497
|
session_manager&.save(agent.to_session_data(status: :success))
|
|
472
498
|
exit(0)
|
|
@@ -524,13 +550,14 @@ module Clacky
|
|
|
524
550
|
break
|
|
525
551
|
when "/clear"
|
|
526
552
|
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: nil, profile: profile,
|
|
527
|
-
session_id: Clacky::SessionManager.generate_id)
|
|
553
|
+
session_id: Clacky::SessionManager.generate_id, source: :manual)
|
|
528
554
|
agent.instance_variable_set(:@ui, json_ui)
|
|
529
555
|
json_ui.emit("info", message: "Session cleared. Starting fresh.")
|
|
530
556
|
next
|
|
531
557
|
end
|
|
532
558
|
|
|
533
559
|
files = input["files"] || []
|
|
560
|
+
auto_name_session(agent, content)
|
|
534
561
|
run_json_task(agent, json_ui, session_manager) { agent.run(content, files: files) }
|
|
535
562
|
when "exit"
|
|
536
563
|
break
|
|
@@ -667,7 +694,7 @@ module Clacky
|
|
|
667
694
|
# Clear output area
|
|
668
695
|
ui_controller.layout.clear_output
|
|
669
696
|
# Clear session by creating a new agent
|
|
670
|
-
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: ui_controller, profile: agent.agent_profile.name, session_id: Clacky::SessionManager.generate_id)
|
|
697
|
+
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: ui_controller, profile: agent.agent_profile.name, session_id: Clacky::SessionManager.generate_id, source: :manual)
|
|
671
698
|
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
672
699
|
# Update session bar with reset values
|
|
673
700
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
@@ -693,6 +720,8 @@ module Clacky
|
|
|
693
720
|
# Cancel idle timer if running (new input means user is active)
|
|
694
721
|
idle_timer.cancel
|
|
695
722
|
|
|
723
|
+
auto_name_session(agent, input)
|
|
724
|
+
|
|
696
725
|
# Run agent in background thread
|
|
697
726
|
current_task_thread = Thread.new do
|
|
698
727
|
begin
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Clacky DOC Parser — CLI interface
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ruby doc_parser.rb <file_path>
|
|
8
|
+
#
|
|
9
|
+
# Output:
|
|
10
|
+
# stdout — extracted text content (UTF-8)
|
|
11
|
+
# stderr — error messages
|
|
12
|
+
# exit 0 — success
|
|
13
|
+
# exit 1 — failure
|
|
14
|
+
#
|
|
15
|
+
# This file lives in ~/.clacky/parsers/ and can be modified by the LLM
|
|
16
|
+
# to add new capabilities (e.g. antiword, libreoffice conversion).
|
|
17
|
+
#
|
|
18
|
+
# VERSION: 1
|
|
19
|
+
|
|
20
|
+
require "open3"
|
|
21
|
+
|
|
22
|
+
MIN_CONTENT_BYTES = 20
|
|
23
|
+
|
|
24
|
+
# Use macOS textutil to convert .doc → txt
|
|
25
|
+
def try_textutil(path)
|
|
26
|
+
stdout, _stderr, status = Open3.capture3("textutil", "-convert", "txt", "-stdout", path)
|
|
27
|
+
return nil unless status.success?
|
|
28
|
+
text = stdout.strip
|
|
29
|
+
return nil if text.bytesize < MIN_CONTENT_BYTES
|
|
30
|
+
text
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
nil # textutil not available (non-macOS)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fallback: strings command — extracts printable ASCII sequences
|
|
36
|
+
def try_strings(path)
|
|
37
|
+
stdout, _stderr, status = Open3.capture3("strings", path)
|
|
38
|
+
return nil unless status.success?
|
|
39
|
+
lines = stdout.lines.select { |l| l.strip.length >= 4 }
|
|
40
|
+
return nil if lines.size < 3
|
|
41
|
+
lines.join
|
|
42
|
+
rescue Errno::ENOENT
|
|
43
|
+
nil # strings not available
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- main ---
|
|
47
|
+
|
|
48
|
+
path = ARGV[0]
|
|
49
|
+
|
|
50
|
+
if path.nil? || path.empty?
|
|
51
|
+
warn "Usage: ruby doc_parser.rb <file_path>"
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
unless File.exist?(path)
|
|
56
|
+
warn "File not found: #{path}"
|
|
57
|
+
exit 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
text = try_textutil(path) || try_strings(path)
|
|
61
|
+
|
|
62
|
+
if text
|
|
63
|
+
print text
|
|
64
|
+
exit 0
|
|
65
|
+
else
|
|
66
|
+
warn "Could not extract text from .doc file."
|
|
67
|
+
warn "Tip: on macOS textutil should work. On Linux try: apt install antiword"
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Clacky DOCX Parser — CLI interface
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ruby docx_parser.rb <file_path>
|
|
8
|
+
#
|
|
9
|
+
# Output:
|
|
10
|
+
# stdout — extracted text in Markdown (UTF-8)
|
|
11
|
+
# stderr — error messages
|
|
12
|
+
# exit 0 — success
|
|
13
|
+
# exit 1 — failure
|
|
14
|
+
#
|
|
15
|
+
# Dependencies: rubyzip gem (gem install rubyzip)
|
|
16
|
+
#
|
|
17
|
+
# This file lives in ~/.clacky/parsers/ and can be modified by the LLM.
|
|
18
|
+
#
|
|
19
|
+
# VERSION: 1
|
|
20
|
+
|
|
21
|
+
require "zip"
|
|
22
|
+
require "rexml/document"
|
|
23
|
+
require "stringio"
|
|
24
|
+
|
|
25
|
+
def read_document_xml(body)
|
|
26
|
+
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
27
|
+
entry = zip.find_entry("word/document.xml")
|
|
28
|
+
raise "Could not extract content — possibly encrypted or invalid format" unless entry
|
|
29
|
+
entry.get_input_stream.read
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_numbering(body)
|
|
34
|
+
result = {}
|
|
35
|
+
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
36
|
+
entry = zip.find_entry("word/numbering.xml")
|
|
37
|
+
break unless entry
|
|
38
|
+
doc = REXML::Document.new(entry.get_input_stream.read)
|
|
39
|
+
REXML::XPath.each(doc, "//w:abstractNum") do |an|
|
|
40
|
+
id = an.attributes["w:abstractNumId"]
|
|
41
|
+
levels = {}
|
|
42
|
+
REXML::XPath.each(an, "w:lvl") do |lvl|
|
|
43
|
+
ilvl = lvl.attributes["w:ilvl"].to_i
|
|
44
|
+
fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val")
|
|
45
|
+
levels[ilvl] = { fmt: fmt || "bullet" }
|
|
46
|
+
end
|
|
47
|
+
result[id] = levels
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
rescue
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_styles(body)
|
|
56
|
+
result = {}
|
|
57
|
+
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
58
|
+
entry = zip.find_entry("word/styles.xml")
|
|
59
|
+
break unless entry
|
|
60
|
+
doc = REXML::Document.new(entry.get_input_stream.read)
|
|
61
|
+
REXML::XPath.each(doc, "//w:style") do |s|
|
|
62
|
+
sid = s.attributes["w:styleId"]
|
|
63
|
+
name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s
|
|
64
|
+
if name =~ /^heading (\d)/i
|
|
65
|
+
result[sid] = { heading: $1.to_i }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
result
|
|
70
|
+
rescue
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_runs(para_node)
|
|
75
|
+
parts = []
|
|
76
|
+
REXML::XPath.each(para_node, "w:r") do |run|
|
|
77
|
+
rpr = REXML::XPath.first(run, "w:rPr")
|
|
78
|
+
bold = REXML::XPath.first(rpr, "w:b") if rpr
|
|
79
|
+
text = REXML::XPath.match(run, "w:t").map(&:text).compact.join
|
|
80
|
+
next if text.empty?
|
|
81
|
+
parts << (bold ? "**#{text}**" : text)
|
|
82
|
+
end
|
|
83
|
+
parts.join
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_paragraph(node, styles, numbering)
|
|
87
|
+
ppr = REXML::XPath.first(node, "w:pPr")
|
|
88
|
+
style = REXML::XPath.first(ppr, "w:pStyle")&.attributes&.[]("w:val") if ppr
|
|
89
|
+
num_pr = REXML::XPath.first(ppr, "w:numPr") if ppr
|
|
90
|
+
|
|
91
|
+
text = extract_runs(node)
|
|
92
|
+
return nil if text.strip.empty?
|
|
93
|
+
|
|
94
|
+
if style && styles[style]
|
|
95
|
+
level = styles[style][:heading]
|
|
96
|
+
return "#{"#" * level} #{text}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if num_pr
|
|
100
|
+
ilvl = REXML::XPath.first(num_pr, "w:ilvl")&.attributes&.[]("w:val").to_i
|
|
101
|
+
indent = " " * ilvl
|
|
102
|
+
return "#{indent}- #{text}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
text
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_table(tbl_node)
|
|
109
|
+
rows = []
|
|
110
|
+
REXML::XPath.each(tbl_node, "w:tr") do |tr|
|
|
111
|
+
cells = REXML::XPath.match(tr, "w:tc").map do |tc|
|
|
112
|
+
REXML::XPath.match(tc, ".//w:t").map(&:text).compact.join(" ").strip
|
|
113
|
+
end
|
|
114
|
+
rows << cells
|
|
115
|
+
end
|
|
116
|
+
return "" if rows.empty?
|
|
117
|
+
|
|
118
|
+
col_count = rows.map(&:size).max
|
|
119
|
+
lines = []
|
|
120
|
+
rows.each_with_index do |row, i|
|
|
121
|
+
padded = row + [""] * [col_count - row.size, 0].max
|
|
122
|
+
lines << "| #{padded.join(" | ")} |"
|
|
123
|
+
lines << "|#{" --- |" * col_count}" if i == 0
|
|
124
|
+
end
|
|
125
|
+
lines.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- main ---
|
|
129
|
+
|
|
130
|
+
path = ARGV[0]
|
|
131
|
+
|
|
132
|
+
if path.nil? || path.empty?
|
|
133
|
+
warn "Usage: ruby docx_parser.rb <file_path>"
|
|
134
|
+
exit 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless File.exist?(path)
|
|
138
|
+
warn "File not found: #{path}"
|
|
139
|
+
exit 1
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
body = File.binread(path)
|
|
144
|
+
xml = read_document_xml(body)
|
|
145
|
+
doc = REXML::Document.new(xml)
|
|
146
|
+
numbering = read_numbering(body)
|
|
147
|
+
styles = read_styles(body)
|
|
148
|
+
|
|
149
|
+
lines = []
|
|
150
|
+
REXML::XPath.each(doc, "//w:body/*") do |node|
|
|
151
|
+
case node.name
|
|
152
|
+
when "p"
|
|
153
|
+
line = parse_paragraph(node, styles, numbering)
|
|
154
|
+
lines << line unless line.nil?
|
|
155
|
+
when "tbl"
|
|
156
|
+
lines << parse_table(node)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
result = lines.join("\n").strip
|
|
161
|
+
if result.empty?
|
|
162
|
+
warn "Document appears to be empty"
|
|
163
|
+
exit 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
print result
|
|
167
|
+
exit 0
|
|
168
|
+
rescue => e
|
|
169
|
+
warn "Failed to parse DOCX: #{e.message}"
|
|
170
|
+
warn "Tip: ensure rubyzip is installed: gem install rubyzip"
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Clacky PDF Parser — CLI interface
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ruby pdf_parser.rb <file_path>
|
|
8
|
+
#
|
|
9
|
+
# Output:
|
|
10
|
+
# stdout — extracted text content (UTF-8)
|
|
11
|
+
# stderr — error messages
|
|
12
|
+
# exit 0 — success
|
|
13
|
+
# exit 1 — failure
|
|
14
|
+
#
|
|
15
|
+
# This file lives in ~/.clacky/parsers/ and can be modified by the LLM
|
|
16
|
+
# to add new capabilities (e.g. OCR for scanned PDFs).
|
|
17
|
+
#
|
|
18
|
+
# VERSION: 1
|
|
19
|
+
|
|
20
|
+
require "open3"
|
|
21
|
+
|
|
22
|
+
MIN_CONTENT_BYTES = 20
|
|
23
|
+
|
|
24
|
+
def try_pdftotext(path)
|
|
25
|
+
stdout, _stderr, status = Open3.capture3("pdftotext", "-layout", "-enc", "UTF-8", path, "-")
|
|
26
|
+
return nil unless status.success?
|
|
27
|
+
text = stdout.strip
|
|
28
|
+
return nil if text.bytesize < MIN_CONTENT_BYTES
|
|
29
|
+
text
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
nil # pdftotext not installed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def try_pdfplumber(path)
|
|
35
|
+
script = <<~PYTHON
|
|
36
|
+
import sys, pdfplumber
|
|
37
|
+
with pdfplumber.open(sys.argv[1]) as pdf:
|
|
38
|
+
pages = []
|
|
39
|
+
for i, page in enumerate(pdf.pages, 1):
|
|
40
|
+
t = page.extract_text()
|
|
41
|
+
if t and t.strip():
|
|
42
|
+
pages.append(f"--- Page {i} ---\\n{t.strip()}")
|
|
43
|
+
print("\\n\\n".join(pages))
|
|
44
|
+
PYTHON
|
|
45
|
+
|
|
46
|
+
stdout, _stderr, status = Open3.capture3("python3", "-c", script, path)
|
|
47
|
+
return nil unless status.success?
|
|
48
|
+
text = stdout.strip
|
|
49
|
+
return nil if text.bytesize < MIN_CONTENT_BYTES
|
|
50
|
+
text
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
nil # python3 not available
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- main ---
|
|
56
|
+
|
|
57
|
+
path = ARGV[0]
|
|
58
|
+
|
|
59
|
+
if path.nil? || path.empty?
|
|
60
|
+
warn "Usage: ruby pdf_parser.rb <file_path>"
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless File.exist?(path)
|
|
65
|
+
warn "File not found: #{path}"
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
text = try_pdftotext(path) || try_pdfplumber(path)
|
|
70
|
+
|
|
71
|
+
if text
|
|
72
|
+
print text
|
|
73
|
+
exit 0
|
|
74
|
+
else
|
|
75
|
+
warn "Could not extract text from PDF."
|
|
76
|
+
warn "Tip: install poppler for text-based PDFs: brew install poppler"
|
|
77
|
+
warn "For scanned PDFs, consider adding OCR support (e.g. tesseract)."
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Clacky PPTX Parser — CLI interface
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ruby pptx_parser.rb <file_path>
|
|
8
|
+
#
|
|
9
|
+
# Output:
|
|
10
|
+
# stdout — extracted content in Markdown (UTF-8)
|
|
11
|
+
# stderr — error messages
|
|
12
|
+
# exit 0 — success
|
|
13
|
+
# exit 1 — failure
|
|
14
|
+
#
|
|
15
|
+
# Dependencies: rubyzip gem (gem install rubyzip)
|
|
16
|
+
#
|
|
17
|
+
# This file lives in ~/.clacky/parsers/ and can be modified by the LLM.
|
|
18
|
+
#
|
|
19
|
+
# VERSION: 1
|
|
20
|
+
|
|
21
|
+
require "zip"
|
|
22
|
+
require "rexml/document"
|
|
23
|
+
require "stringio"
|
|
24
|
+
|
|
25
|
+
def extract_text(shape_node)
|
|
26
|
+
paras = []
|
|
27
|
+
REXML::XPath.each(shape_node, ".//a:p") do |para|
|
|
28
|
+
text = REXML::XPath.match(para, ".//a:t").map(&:text).compact.join
|
|
29
|
+
paras << text unless text.strip.empty?
|
|
30
|
+
end
|
|
31
|
+
paras.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_table(tbl_node)
|
|
35
|
+
rows = []
|
|
36
|
+
REXML::XPath.each(tbl_node, ".//a:tr") do |tr|
|
|
37
|
+
cells = REXML::XPath.match(tr, ".//a:tc").map do |tc|
|
|
38
|
+
REXML::XPath.match(tc, ".//a:t").map(&:text).compact.join(" ").strip
|
|
39
|
+
end
|
|
40
|
+
rows << cells
|
|
41
|
+
end
|
|
42
|
+
return "" if rows.empty?
|
|
43
|
+
|
|
44
|
+
col_count = rows.map(&:size).max
|
|
45
|
+
lines = []
|
|
46
|
+
rows.each_with_index do |row, i|
|
|
47
|
+
padded = row + [""] * [col_count - row.size, 0].max
|
|
48
|
+
lines << "| #{padded.join(" | ")} |"
|
|
49
|
+
lines << "|#{" --- |" * col_count}" if i == 0
|
|
50
|
+
end
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_slide(doc, slide_num)
|
|
55
|
+
lines = []
|
|
56
|
+
|
|
57
|
+
title_text = nil
|
|
58
|
+
REXML::XPath.each(doc, "//p:sp") do |sp|
|
|
59
|
+
ph = REXML::XPath.first(sp, ".//p:ph")
|
|
60
|
+
next unless ph
|
|
61
|
+
ph_type = ph.attributes["type"]
|
|
62
|
+
if ph_type == "title" || ph_type == "ctrTitle"
|
|
63
|
+
title_text = extract_text(sp).strip
|
|
64
|
+
break
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
lines << "## Slide #{slide_num}#{title_text && !title_text.empty? ? ": #{title_text}" : ""}"
|
|
69
|
+
|
|
70
|
+
REXML::XPath.each(doc, "//p:sp") do |sp|
|
|
71
|
+
ph = REXML::XPath.first(sp, ".//p:ph")
|
|
72
|
+
if ph
|
|
73
|
+
ph_type = ph.attributes["type"]
|
|
74
|
+
next if %w[title ctrTitle sldNum dt ftr].include?(ph_type)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
text = extract_text(sp).strip
|
|
78
|
+
next if text.empty?
|
|
79
|
+
next if text == title_text
|
|
80
|
+
|
|
81
|
+
text.each_line do |line|
|
|
82
|
+
lines << "- #{line.rstrip}" unless line.strip.empty?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
REXML::XPath.each(doc, "//a:tbl") do |tbl|
|
|
87
|
+
lines << parse_table(tbl)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- main ---
|
|
94
|
+
|
|
95
|
+
path = ARGV[0]
|
|
96
|
+
|
|
97
|
+
if path.nil? || path.empty?
|
|
98
|
+
warn "Usage: ruby pptx_parser.rb <file_path>"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless File.exist?(path)
|
|
103
|
+
warn "File not found: #{path}"
|
|
104
|
+
exit 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
body = File.binread(path)
|
|
109
|
+
slides = {}
|
|
110
|
+
|
|
111
|
+
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
112
|
+
zip.each do |entry|
|
|
113
|
+
if entry.name =~ %r{ppt/slides/slide(\d+)\.xml}
|
|
114
|
+
slides[$1.to_i] = entry.get_input_stream.read
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if slides.empty?
|
|
120
|
+
warn "Presentation appears to be empty"
|
|
121
|
+
exit 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sections = slides.keys.sort.map do |num|
|
|
125
|
+
doc = REXML::Document.new(slides[num])
|
|
126
|
+
parse_slide(doc, num)
|
|
127
|
+
end.compact
|
|
128
|
+
|
|
129
|
+
if sections.empty?
|
|
130
|
+
warn "Presentation appears to be empty"
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
print sections.join("\n\n---\n\n")
|
|
135
|
+
exit 0
|
|
136
|
+
rescue => e
|
|
137
|
+
warn "Failed to parse PPTX: #{e.message}"
|
|
138
|
+
warn "Tip: ensure rubyzip is installed: gem install rubyzip"
|
|
139
|
+
exit 1
|
|
140
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# Clacky XLSX Parser — CLI interface
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ruby xlsx_parser.rb <file_path>
|
|
8
|
+
#
|
|
9
|
+
# Output:
|
|
10
|
+
# stdout — extracted content in Markdown tables (UTF-8)
|
|
11
|
+
# stderr — error messages
|
|
12
|
+
# exit 0 — success
|
|
13
|
+
# exit 1 — failure
|
|
14
|
+
#
|
|
15
|
+
# Dependencies: rubyzip gem (gem install rubyzip)
|
|
16
|
+
#
|
|
17
|
+
# This file lives in ~/.clacky/parsers/ and can be modified by the LLM.
|
|
18
|
+
#
|
|
19
|
+
# VERSION: 1
|
|
20
|
+
|
|
21
|
+
require "zip"
|
|
22
|
+
require "rexml/document"
|
|
23
|
+
require "stringio"
|
|
24
|
+
|
|
25
|
+
def parse_row(row_node, shared_strings)
|
|
26
|
+
REXML::XPath.match(row_node, ".//c").map do |c|
|
|
27
|
+
v = REXML::XPath.first(c, "v")&.text
|
|
28
|
+
next "" unless v
|
|
29
|
+
c.attributes["t"] == "s" ? (shared_strings[v.to_i] || "") : v
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_markdown_table(rows)
|
|
34
|
+
col_count = rows.map(&:size).max
|
|
35
|
+
lines = []
|
|
36
|
+
rows.each_with_index do |row, i|
|
|
37
|
+
padded = row + [""] * [col_count - row.size, 0].max
|
|
38
|
+
lines << "| #{padded.join(" | ")} |"
|
|
39
|
+
lines << "|#{" --- |" * col_count}" if i == 0
|
|
40
|
+
end
|
|
41
|
+
lines.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- main ---
|
|
45
|
+
|
|
46
|
+
path = ARGV[0]
|
|
47
|
+
|
|
48
|
+
if path.nil? || path.empty?
|
|
49
|
+
warn "Usage: ruby xlsx_parser.rb <file_path>"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless File.exist?(path)
|
|
54
|
+
warn "File not found: #{path}"
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
body = File.binread(path)
|
|
60
|
+
shared_strings = []
|
|
61
|
+
sheet_names = {}
|
|
62
|
+
sheet_xmls = {}
|
|
63
|
+
|
|
64
|
+
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
65
|
+
ss_entry = zip.find_entry("xl/sharedStrings.xml")
|
|
66
|
+
if ss_entry
|
|
67
|
+
doc = REXML::Document.new(ss_entry.get_input_stream.read)
|
|
68
|
+
REXML::XPath.each(doc, "//si") do |si|
|
|
69
|
+
shared_strings << REXML::XPath.match(si, ".//t").map(&:text).compact.join
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
wb_entry = zip.find_entry("xl/workbook.xml")
|
|
74
|
+
if wb_entry
|
|
75
|
+
doc = REXML::Document.new(wb_entry.get_input_stream.read)
|
|
76
|
+
REXML::XPath.each(doc, "//sheet") do |s|
|
|
77
|
+
idx = s.attributes["sheetId"]
|
|
78
|
+
name = s.attributes["name"]
|
|
79
|
+
sheet_names[idx] = name if idx && name
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
zip.each do |entry|
|
|
84
|
+
if entry.name =~ %r{xl/worksheets/sheet(\d+)\.xml}
|
|
85
|
+
sheet_xmls[$1] = entry.get_input_stream.read
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if sheet_xmls.empty?
|
|
91
|
+
warn "Spreadsheet appears to be empty"
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sections = []
|
|
96
|
+
sheet_xmls.keys.sort_by(&:to_i).each do |idx|
|
|
97
|
+
name = sheet_names[idx] || "Sheet#{idx}"
|
|
98
|
+
doc = REXML::Document.new(sheet_xmls[idx])
|
|
99
|
+
|
|
100
|
+
rows = []
|
|
101
|
+
REXML::XPath.each(doc, "//row") do |row|
|
|
102
|
+
cells = parse_row(row, shared_strings)
|
|
103
|
+
rows << cells unless cells.all?(&:empty?)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
next if rows.empty?
|
|
107
|
+
sections << "### #{name}\n\n#{build_markdown_table(rows)}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if sections.empty?
|
|
111
|
+
warn "Spreadsheet appears to be empty"
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
print sections.join("\n\n")
|
|
116
|
+
exit 0
|
|
117
|
+
rescue => e
|
|
118
|
+
warn "Failed to parse XLSX: #{e.message}"
|
|
119
|
+
warn "Tip: ensure rubyzip is installed: gem install rubyzip"
|
|
120
|
+
exit 1
|
|
121
|
+
end
|