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
@@ -1,90 +0,0 @@
1
- ---
2
- name: pdf-reader
3
- description: 'Read and analyze PDF files. Use this skill when the user has attached a PDF or mentions a PDF file path and wants to understand, summarize, extract, or ask questions about its content. Trigger on: "read this PDF", "analyze the PDF", "what does this PDF say", "what is in this file", "里面有什么", "帮我看看这个PDF", "总结一下", "这份文件说了什么" — or when a message contains a PDF attachment reference even without an explicit question. Also trigger when the user asks vague questions like "what is this?", "summarize", "tell me about this" if a PDF is attached.'
4
- disable-model-invocation: false
5
- user-invocable: true
6
- ---
7
-
8
- # PDF Reading Skill
9
-
10
- ## Your Goal
11
- Extract text content from the PDF file and answer the user's question based on that content. If the user's question is vague or absent, default to providing a clear structured summary of the document.
12
-
13
- ## Step 1 — Extract text from the PDF
14
-
15
- Use `pdftotext` (preferred, fastest) or Python `pdfplumber` as fallback.
16
-
17
- ### Option A: pdftotext (use this first)
18
- ```bash
19
- pdftotext -layout -enc UTF-8 "/path/to/file.pdf" -
20
- ```
21
- - `-enc UTF-8` ensures correct encoding for Chinese, Japanese, and other non-Latin text
22
- - `-layout` preserves column layout for tables
23
- - The `-` at the end prints to stdout (no temp file needed)
24
-
25
- **Install if missing:**
26
- - macOS: `brew install poppler`
27
- - Ubuntu/Debian: `apt install poppler-utils`
28
- - CentOS/Fedora: `yum install poppler-utils`
29
-
30
- ### Option B: Python pdfplumber (fallback if pdftotext not available)
31
- ```python
32
- import pdfplumber
33
-
34
- with pdfplumber.open("/path/to/file.pdf") as pdf:
35
- for i, page in enumerate(pdf.pages, 1):
36
- text = page.extract_text()
37
- if text:
38
- print(f"--- Page {i} ---")
39
- print(text)
40
- ```
41
-
42
- ### Option C: pypdf (last resort)
43
- ```python
44
- from pypdf import PdfReader
45
-
46
- reader = PdfReader("/path/to/file.pdf")
47
- for i, page in enumerate(reader.pages, 1):
48
- print(f"--- Page {i} ---")
49
- print(page.extract_text())
50
- ```
51
-
52
- ## Step 2 — Handle large files
53
-
54
- If the extracted text is truncated or very long (>200 lines):
55
- - For a **summary request**: read the full output file instead of relying on stdout — save to a temp file first:
56
- ```bash
57
- pdftotext -layout -enc UTF-8 "/path/to/file.pdf" /tmp/pdf_extracted.txt
58
- cat /tmp/pdf_extracted.txt
59
- ```
60
- - For a **specific question**: use `grep` to locate relevant sections before reading the full content:
61
- ```bash
62
- grep -n "keyword" /tmp/pdf_extracted.txt | head -30
63
- ```
64
- - Extract once, answer from memory — do NOT re-read the file multiple times.
65
-
66
- ## Step 3 — Answer the user's question
67
-
68
- ### Output format guidelines
69
-
70
- Adapt the response format to the document type:
71
-
72
- | Document type | Recommended format |
73
- |---|---|
74
- | Business plan / Report | Structured summary with ## headers per section |
75
- | Contract / Legal | Key clauses in bullet points, highlight dates and parties |
76
- | Academic paper | Abstract → Key findings → Methodology → Conclusions |
77
- | Invoice / Receipt | Table: item, amount, total |
78
- | General / Unknown | Brief overview paragraph + key points as bullets |
79
-
80
- **General rules:**
81
- - Use Markdown formatting (headers, bullets, tables) for clarity
82
- - Match the user's language — if they asked in Chinese, answer in Chinese
83
- - Lead with the most important information first
84
- - If the user asked a specific question, answer it directly before summarizing
85
-
86
- ## Rules
87
- - Always use the **actual file path** from the `[PDF attached: ...]` message
88
- - If text extraction returns empty (scanned/image PDF), inform the user and suggest: `brew install tesseract` + `tesseract file.pdf output txt`
89
- - Do NOT re-read the file multiple times — extract once, answer from memory
90
- - If the user's question is vague (e.g. "里面有什么", "what is this?"), default to a full structured summary
@@ -1,156 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- require "rexml/document"
5
- require "stringio"
6
-
7
- module Clacky
8
- module FileParser
9
- # Parses DOCX/DOC files into Markdown, preserving headings, paragraphs, and tables.
10
- module DocxParser
11
- # Parse raw DOCX bytes and return a Markdown string.
12
- # @param body [String] Raw file bytes
13
- # @return [String] Markdown representation
14
- def self.parse(body)
15
- xml = read_document_xml(body)
16
- return xml if xml.start_with?("(") # error string
17
-
18
- doc = REXML::Document.new(xml)
19
- numbering = read_numbering(body)
20
- styles = read_styles(body)
21
-
22
- lines = []
23
- REXML::XPath.each(doc, "//w:body/*") do |node|
24
- case node.name
25
- when "p"
26
- line = parse_paragraph(node, styles, numbering)
27
- lines << line unless line.nil?
28
- when "tbl"
29
- lines << parse_table(node)
30
- end
31
- end
32
-
33
- result = lines.join("\n").strip
34
- result.empty? ? "(Document appears to be empty)" : result
35
- rescue => e
36
- "(Failed to parse document: #{e.message})"
37
- end
38
-
39
- # --- private ---
40
-
41
- def self.read_document_xml(body)
42
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
43
- entry = zip.find_entry("word/document.xml")
44
- return "(Could not extract content — possibly encrypted or invalid)" unless entry
45
- entry.get_input_stream.read
46
- end
47
- rescue => e
48
- "(Failed to open file: #{e.message})"
49
- end
50
-
51
- # Returns a hash: { abstract_num_id => { ilvl => { numFmt, start } } }
52
- def self.read_numbering(body)
53
- result = {}
54
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
55
- entry = zip.find_entry("word/numbering.xml")
56
- break unless entry
57
- doc = REXML::Document.new(entry.get_input_stream.read)
58
- REXML::XPath.each(doc, "//w:abstractNum") do |an|
59
- id = an.attributes["w:abstractNumId"]
60
- levels = {}
61
- REXML::XPath.each(an, "w:lvl") do |lvl|
62
- ilvl = lvl.attributes["w:ilvl"].to_i
63
- fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val")
64
- levels[ilvl] = { fmt: fmt || "bullet" }
65
- end
66
- result[id] = levels
67
- end
68
- end
69
- result
70
- rescue
71
- {}
72
- end
73
-
74
- # Returns a hash: { styleId => { heading_level } }
75
- def self.read_styles(body)
76
- result = {}
77
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
78
- entry = zip.find_entry("word/styles.xml")
79
- break unless entry
80
- doc = REXML::Document.new(entry.get_input_stream.read)
81
- REXML::XPath.each(doc, "//w:style") do |s|
82
- sid = s.attributes["w:styleId"]
83
- name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s
84
- if name =~ /^heading (\d)/i
85
- result[sid] = { heading: $1.to_i }
86
- end
87
- end
88
- end
89
- result
90
- rescue
91
- {}
92
- end
93
-
94
- def self.parse_paragraph(node, styles, numbering)
95
- ppr = REXML::XPath.first(node, "w:pPr")
96
- style = REXML::XPath.first(ppr, "w:pStyle")&.attributes&.[]("w:val") if ppr
97
- num_pr = REXML::XPath.first(ppr, "w:numPr") if ppr
98
-
99
- text = extract_runs(node)
100
- return nil if text.strip.empty?
101
-
102
- # Heading
103
- if style && styles[style]
104
- level = styles[style][:heading]
105
- return "#{"#" * level} #{text}"
106
- end
107
-
108
- # List item
109
- if num_pr
110
- ilvl = REXML::XPath.first(num_pr, "w:ilvl")&.attributes&.[]("w:val").to_i
111
- indent = " " * ilvl
112
- return "#{indent}- #{text}"
113
- end
114
-
115
- text
116
- end
117
-
118
- def self.extract_runs(para_node)
119
- parts = []
120
- REXML::XPath.each(para_node, "w:r") do |run|
121
- rpr = REXML::XPath.first(run, "w:rPr")
122
- bold = REXML::XPath.first(rpr, "w:b") if rpr
123
- text = REXML::XPath.match(run, "w:t").map(&:text).compact.join
124
- next if text.empty?
125
- parts << (bold ? "**#{text}**" : text)
126
- end
127
- parts.join
128
- end
129
-
130
- def self.parse_table(tbl_node)
131
- rows = []
132
- REXML::XPath.each(tbl_node, "w:tr") do |tr|
133
- cells = REXML::XPath.match(tr, "w:tc").map do |tc|
134
- REXML::XPath.match(tc, ".//w:t").map(&:text).compact.join(" ").strip
135
- end
136
- rows << cells
137
- end
138
- return "" if rows.empty?
139
-
140
- # Build markdown table
141
- col_count = rows.map(&:size).max
142
- lines = []
143
- rows.each_with_index do |row, i|
144
- padded = row + [""] * [col_count - row.size, 0].max
145
- lines << "| #{padded.join(" | ")} |"
146
- # Header separator after first row
147
- lines << "|#{" --- |" * col_count}" if i == 0
148
- end
149
- lines.join("\n")
150
- end
151
-
152
- private_class_method :read_document_xml, :read_numbering, :read_styles,
153
- :parse_paragraph, :extract_runs, :parse_table
154
- end
155
- end
156
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- require "rexml/document"
5
- require "stringio"
6
-
7
- module Clacky
8
- module FileParser
9
- # Parses PPTX files into Markdown, one section per slide.
10
- # Extracts slide titles, body text, and table content.
11
- module PptxParser
12
- # Parse raw PPTX bytes and return a Markdown string.
13
- # @param body [String] Raw file bytes
14
- # @return [String] Markdown representation
15
- def self.parse(body)
16
- slides = {}
17
-
18
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
19
- zip.each do |entry|
20
- if entry.name =~ %r{ppt/slides/slide(\d+)\.xml}
21
- slides[$1.to_i] = entry.get_input_stream.read
22
- end
23
- end
24
- end
25
-
26
- return "(Presentation appears to be empty)" if slides.empty?
27
-
28
- sections = slides.keys.sort.map do |num|
29
- doc = REXML::Document.new(slides[num])
30
- parse_slide(doc, num)
31
- end.compact
32
-
33
- sections.empty? ? "(Presentation appears to be empty)" : sections.join("\n\n---\n\n")
34
- rescue => e
35
- "(Failed to parse presentation: #{e.message})"
36
- end
37
-
38
- # --- private ---
39
-
40
- def self.parse_slide(doc, slide_num)
41
- lines = []
42
-
43
- # Title: look for ph type="title" or ph type="ctrTitle"
44
- title_text = nil
45
- REXML::XPath.each(doc, "//p:sp") do |sp|
46
- ph = REXML::XPath.first(sp, ".//p:ph")
47
- next unless ph
48
- ph_type = ph.attributes["type"]
49
- if ph_type == "title" || ph_type == "ctrTitle"
50
- title_text = extract_text(sp).strip
51
- break
52
- end
53
- end
54
-
55
- lines << "## Slide #{slide_num}#{title_text && !title_text.empty? ? ": #{title_text}" : ""}"
56
-
57
- # Body: all other text shapes
58
- REXML::XPath.each(doc, "//p:sp") do |sp|
59
- ph = REXML::XPath.first(sp, ".//p:ph")
60
- # Skip title shapes (already handled) and slide number/date placeholders
61
- if ph
62
- ph_type = ph.attributes["type"]
63
- next if %w[title ctrTitle sldNum dt ftr].include?(ph_type)
64
- end
65
-
66
- text = extract_text(sp).strip
67
- next if text.empty?
68
- next if text == title_text # deduplicate
69
-
70
- # Indent body bullets
71
- text.each_line do |line|
72
- lines << "- #{line.rstrip}" unless line.strip.empty?
73
- end
74
- end
75
-
76
- # Tables
77
- REXML::XPath.each(doc, "//a:tbl") do |tbl|
78
- lines << parse_table(tbl)
79
- end
80
-
81
- lines.join("\n")
82
- end
83
-
84
- def self.extract_text(shape_node)
85
- paras = []
86
- REXML::XPath.each(shape_node, ".//a:p") do |para|
87
- text = REXML::XPath.match(para, ".//a:t").map(&:text).compact.join
88
- paras << text unless text.strip.empty?
89
- end
90
- paras.join("\n")
91
- end
92
-
93
- def self.parse_table(tbl_node)
94
- rows = []
95
- REXML::XPath.each(tbl_node, ".//a:tr") do |tr|
96
- cells = REXML::XPath.match(tr, ".//a:tc").map do |tc|
97
- REXML::XPath.match(tc, ".//a:t").map(&:text).compact.join(" ").strip
98
- end
99
- rows << cells
100
- end
101
- return "" if rows.empty?
102
-
103
- col_count = rows.map(&:size).max
104
- lines = []
105
- rows.each_with_index do |row, i|
106
- padded = row + [""] * [col_count - row.size, 0].max
107
- lines << "| #{padded.join(" | ")} |"
108
- lines << "|#{" --- |" * col_count}" if i == 0
109
- end
110
- lines.join("\n")
111
- end
112
-
113
- private_class_method :parse_slide, :extract_text, :parse_table
114
- end
115
- end
116
- end
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- require "rexml/document"
5
- require "stringio"
6
-
7
- module Clacky
8
- module FileParser
9
- # Parses XLSX/XLS files into Markdown tables, one section per sheet.
10
- module XlsxParser
11
- # Parse raw XLSX bytes and return a Markdown string.
12
- # @param body [String] Raw file bytes
13
- # @return [String] Markdown representation with one table per sheet
14
- def self.parse(body)
15
- shared_strings = []
16
- sheet_names = {}
17
- sheet_xmls = {}
18
-
19
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
20
- # Shared strings table
21
- ss_entry = zip.find_entry("xl/sharedStrings.xml")
22
- if ss_entry
23
- doc = REXML::Document.new(ss_entry.get_input_stream.read)
24
- REXML::XPath.each(doc, "//si") do |si|
25
- shared_strings << REXML::XPath.match(si, ".//t").map(&:text).compact.join
26
- end
27
- end
28
-
29
- # Sheet name mapping from workbook.xml
30
- wb_entry = zip.find_entry("xl/workbook.xml")
31
- if wb_entry
32
- doc = REXML::Document.new(wb_entry.get_input_stream.read)
33
- REXML::XPath.each(doc, "//sheet") do |s|
34
- idx = s.attributes["sheetId"]
35
- name = s.attributes["name"]
36
- sheet_names[idx] = name if idx && name
37
- end
38
- end
39
-
40
- # Sheet XMLs
41
- zip.each do |entry|
42
- if entry.name =~ %r{xl/worksheets/sheet(\d+)\.xml}
43
- sheet_xmls[$1] = entry.get_input_stream.read
44
- end
45
- end
46
- end
47
-
48
- return "(Spreadsheet appears to be empty)" if sheet_xmls.empty?
49
-
50
- sections = []
51
- sheet_xmls.keys.sort_by(&:to_i).each do |idx|
52
- name = sheet_names[idx] || "Sheet#{idx}"
53
- doc = REXML::Document.new(sheet_xmls[idx])
54
-
55
- rows = []
56
- REXML::XPath.each(doc, "//row") do |row|
57
- cells = parse_row(row, shared_strings)
58
- rows << cells unless cells.all?(&:empty?)
59
- end
60
-
61
- next if rows.empty?
62
-
63
- sections << "### #{name}\n\n#{build_markdown_table(rows)}"
64
- end
65
-
66
- sections.empty? ? "(Spreadsheet appears to be empty)" : sections.join("\n\n")
67
- rescue => e
68
- "(Failed to parse spreadsheet: #{e.message})"
69
- end
70
-
71
- # --- private ---
72
-
73
- def self.parse_row(row_node, shared_strings)
74
- REXML::XPath.match(row_node, ".//c").map do |c|
75
- v = REXML::XPath.first(c, "v")&.text
76
- next "" unless v
77
- c.attributes["t"] == "s" ? (shared_strings[v.to_i] || "") : v
78
- end
79
- end
80
-
81
- def self.build_markdown_table(rows)
82
- col_count = rows.map(&:size).max
83
- lines = []
84
- rows.each_with_index do |row, i|
85
- padded = row + [""] * [col_count - row.size, 0].max
86
- lines << "| #{padded.join(" | ")} |"
87
- lines << "|#{" --- |" * col_count}" if i == 0
88
- end
89
- lines.join("\n")
90
- end
91
-
92
- private_class_method :parse_row, :build_markdown_table
93
- end
94
- end
95
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- require "stringio"
5
-
6
- module Clacky
7
- module FileParser
8
- # Lists the contents of a ZIP archive as a Markdown file tree.
9
- module ZipParser
10
- MAX_ENTRIES = 200
11
-
12
- # Parse raw ZIP bytes and return a Markdown directory listing.
13
- # @param body [String] Raw file bytes
14
- # @return [String] Markdown representation of archive contents
15
- def self.parse(body)
16
- entries = []
17
-
18
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
19
- zip.each do |entry|
20
- entries << {
21
- name: entry.name,
22
- size: entry.file? ? entry.size : nil,
23
- dir: entry.directory?
24
- }
25
- break if entries.size >= MAX_ENTRIES
26
- end
27
- end
28
-
29
- return "(ZIP archive appears to be empty)" if entries.empty?
30
-
31
- truncated = entries.size >= MAX_ENTRIES
32
- lines = ["**Archive contents** (#{entries.size}#{truncated ? "+" : ""} entries):\n"]
33
-
34
- entries.sort_by { |e| [e[:dir] ? 0 : 1, e[:name]] }.each do |e|
35
- if e[:dir]
36
- lines << "- 📁 #{e[:name]}"
37
- else
38
- size_str = e[:size] ? " *(#{format_size(e[:size])})*" : ""
39
- lines << "- #{e[:name]}#{size_str}"
40
- end
41
- end
42
-
43
- lines << "\n*(listing truncated at #{MAX_ENTRIES} entries)*" if truncated
44
- lines.join("\n")
45
- rescue => e
46
- "(Failed to read ZIP: #{e.message})"
47
- end
48
-
49
- # --- private ---
50
-
51
- def self.format_size(bytes)
52
- return "#{bytes} B" if bytes < 1024
53
- return "#{bytes / 1024} KB" if bytes < 1024 * 1024
54
- "#{(bytes / 1024.0 / 1024).round(1)} MB"
55
- end
56
-
57
- private_class_method :format_size
58
- end
59
- end
60
- end
@@ -1,67 +0,0 @@
1
- #!/bin/bash
2
- # Install or upgrade agent-browser (browser automation tool)
3
- # Can be sourced by install.sh or run standalone (e.g. from Ruby via shell)
4
- #
5
- # Exit codes:
6
- # 0 — success (installed/upgraded)
7
- # 1 — failure (npm not found or install failed)
8
-
9
- set -e
10
-
11
- RED='\033[0;31m'
12
- GREEN='\033[0;32m'
13
- YELLOW='\033[1;33m'
14
- BLUE='\033[0;34m'
15
- NC='\033[0m'
16
-
17
- print_info() { echo -e "${BLUE}ℹ${NC} $1"; }
18
- print_success() { echo -e "${GREEN}✓${NC} $1"; }
19
- print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }
20
- print_error() { echo -e "${RED}✗${NC} $1"; }
21
- print_step() { echo -e "\n${BLUE}==>${NC} $1"; }
22
-
23
- command_exists() { command -v "$1" >/dev/null 2>&1; }
24
-
25
- print_step "Installing agent-browser..."
26
-
27
- # Try to find npm; if missing, attempt to install Node.js via mise
28
- if ! command_exists npm; then
29
- mise_bin=""
30
- if command_exists mise; then
31
- mise_bin="mise"
32
- elif [ -x "$HOME/.local/bin/mise" ]; then
33
- mise_bin="$HOME/.local/bin/mise"
34
- fi
35
-
36
- if [ -n "$mise_bin" ]; then
37
- print_info "Installing Node.js via mise..."
38
- "$mise_bin" install node@22 > /dev/null 2>&1 || true
39
- "$mise_bin" use -g node@22 > /dev/null 2>&1 || true
40
- eval "$("$mise_bin" activate bash 2>/dev/null)" 2>/dev/null || true
41
- fi
42
- fi
43
-
44
- if ! command_exists npm; then
45
- print_error "agent-browser installation failed: Node.js/npm not found."
46
- print_info "Please run: mise install node@22 && mise use -g node@22 && npm install -g agent-browser"
47
- exit 1
48
- fi
49
-
50
- print_info "Running: npm install -g agent-browser"
51
- if npm install -g agent-browser > /dev/null 2>&1; then
52
- version=$(agent-browser --version 2>/dev/null | awk '{print $NF}')
53
- print_success "agent-browser ${version} installed/updated"
54
- else
55
- print_error "agent-browser installation failed."
56
- print_info "To install manually: npm install -g agent-browser"
57
- exit 1
58
- fi
59
-
60
- print_info "Installing Playwright Chromium..."
61
- if npx playwright install chromium > /dev/null 2>&1; then
62
- print_success "Playwright Chromium installed"
63
- else
64
- print_error "Playwright Chromium installation failed."
65
- print_info "To install manually: npx playwright install chromium"
66
- exit 1
67
- fi