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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. 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 ||= Clacky::Agent.new(client, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile,
107
- session_id: Clacky::SessionManager.generate_id)
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.list(current_dir: working_dir, limit: 5)
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: 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.list(current_dir: working_dir, limit: 100)
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: 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