openclacky 1.0.4 → 1.1.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +99 -356
  3. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  4. data/CHANGELOG.md +42 -0
  5. data/docs/system-skill-authoring-guide.md +1 -1
  6. data/lib/clacky/agent/tool_executor.rb +3 -1
  7. data/lib/clacky/agent.rb +12 -7
  8. data/lib/clacky/agent_config.rb +9 -3
  9. data/lib/clacky/brand_config.rb +19 -4
  10. data/lib/clacky/cli.rb +1 -1
  11. data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
  12. data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  13. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
  14. data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  15. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  16. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
  17. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
  18. data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
  19. data/lib/clacky/message_history.rb +26 -1
  20. data/lib/clacky/providers.rb +29 -4
  21. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
  22. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
  23. data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
  24. data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
  25. data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
  26. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
  27. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
  28. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
  29. data/lib/clacky/server/channel/channel_config.rb +26 -0
  30. data/lib/clacky/server/channel.rb +3 -0
  31. data/lib/clacky/server/http_server.rb +75 -4
  32. data/lib/clacky/server/server_master.rb +35 -13
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +7 -1
  35. data/lib/clacky/telemetry.rb +1 -16
  36. data/lib/clacky/tools/browser.rb +8 -5
  37. data/lib/clacky/tools/glob.rb +11 -38
  38. data/lib/clacky/tools/grep.rb +7 -16
  39. data/lib/clacky/ui2/markdown_renderer.rb +1 -1
  40. data/lib/clacky/ui2/ui_controller.rb +2 -1
  41. data/lib/clacky/utils/file_ignore_helper.rb +49 -0
  42. data/lib/clacky/utils/gitignore_parser.rb +27 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +248 -31
  45. data/lib/clacky/web/app.js +51 -1
  46. data/lib/clacky/web/channels.js +98 -28
  47. data/lib/clacky/web/datepicker.js +205 -0
  48. data/lib/clacky/web/i18n.js +48 -9
  49. data/lib/clacky/web/index.html +33 -6
  50. data/lib/clacky/web/onboard.js +46 -4
  51. data/lib/clacky/web/sessions.js +33 -72
  52. data/lib/clacky/web/settings.js +42 -4
  53. data/lib/clacky/web/version.js +52 -1
  54. metadata +21 -10
  55. data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
  56. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
  57. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
  58. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
  59. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
@@ -163,7 +163,8 @@ module Clacky
163
163
  @session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
164
164
  @registry = SessionRegistry.new(
165
165
  session_manager: @session_manager,
166
- session_restorer: method(:build_session_from_data)
166
+ session_restorer: method(:build_session_from_data),
167
+ agent_config: @agent_config
167
168
  )
168
169
  @ws_clients = {} # session_id => [WebSocketConnection, ...]
169
170
  @all_ws_conns = [] # every connected WS client, regardless of session subscription
@@ -244,7 +245,15 @@ module Clacky
244
245
  Clacky::Logger.warn("[HttpServer] Forced exit after graceful shutdown timeout.")
245
246
  exit!(0)
246
247
  end
247
- # Stop channel and browser managers in parallel to minimize shutdown time.
248
+ # Detach the inherited (shared) listen socket BEFORE shutdown so that
249
+ # WEBrick's cleanup_listener does not call shutdown(SHUT_RDWR)+close on
250
+ # it — that would propagate to every process sharing the underlying
251
+ # kernel socket (Master + new worker), breaking subsequent accept()
252
+ # on Linux. macOS's BSD stack tolerates this; Linux does not.
253
+ if @inherited_socket && server.listeners.include?(@inherited_socket)
254
+ server.listeners.delete(@inherited_socket)
255
+ Clacky::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
256
+ end
248
257
  t1 = Thread.new { @channel_manager.stop rescue nil }
249
258
  t2 = Thread.new { Clacky::BrowserManager.instance.stop rescue nil }
250
259
  t1.join(1.5)
@@ -415,6 +424,9 @@ module Clacky
415
424
  elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
416
425
  platform = path.sub("/api/channels/", "").sub("/test", "")
417
426
  api_test_channel(platform, req, res)
427
+ elsif method == "PATCH" && path.match?(%r{^/api/channels/[^/]+/enabled$})
428
+ platform = path.sub("/api/channels/", "").sub("/enabled", "")
429
+ api_toggle_channel(platform, req, res)
418
430
  elsif method == "POST" && path.start_with?("/api/channels/")
419
431
  platform = path.sub("/api/channels/", "")
420
432
  api_save_channel(platform, req, res)
@@ -525,7 +537,7 @@ module Clacky
525
537
  profile = "general" if profile.empty?
526
538
 
527
539
  # Optional source; defaults to :manual. Accept "system" for skill-launched sessions
528
- # (e.g. /onboard, /browser-setup, /channel-setup).
540
+ # (e.g. /onboard, /browser-setup, /channel-manager).
529
541
  raw_source = body["source"].to_s.strip
530
542
  source = %w[manual cron channel setup].include?(raw_source) ? raw_source.to_sym : :manual
531
543
 
@@ -1005,10 +1017,14 @@ module Clacky
1005
1017
  def api_get_version(res)
1006
1018
  current = Clacky::VERSION
1007
1019
  latest = fetch_latest_version_cached
1020
+ brand = Clacky::BrandConfig.load
1021
+ cli_cmd = brand.branded? && brand.package_name && !brand.package_name.empty? ? brand.package_name : "openclacky"
1008
1022
  json_response(res, 200, {
1009
1023
  current: current,
1010
1024
  latest: latest,
1011
- needs_update: latest ? version_older?(current, latest) : false
1025
+ needs_update: latest ? version_older?(current, latest) : false,
1026
+ launcher: ENV["CLACKY_LAUNCHER"] || "cli",
1027
+ cli_command: cli_cmd
1012
1028
  })
1013
1029
  end
1014
1030
 
@@ -1608,6 +1624,7 @@ module Clacky
1608
1624
 
1609
1625
  # Record when the token was last updated so clients can detect re-login
1610
1626
  fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
1627
+ fields[:token_updated_at] = Time.now.to_i if platform == :discord && fields.key?(:bot_token)
1611
1628
 
1612
1629
  # Validate credentials against live API before persisting.
1613
1630
  # Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
@@ -1648,6 +1665,34 @@ module Clacky
1648
1665
  json_response(res, 422, { ok: false, error: e.message })
1649
1666
  end
1650
1667
 
1668
+ # PATCH /api/channels/:platform/enabled
1669
+ # Body: { enabled: true|false }
1670
+ # Toggles the platform on/off without touching credentials.
1671
+ # Enabling requires the platform to already be configured.
1672
+ def api_toggle_channel(platform, req, res)
1673
+ platform = platform.to_sym
1674
+ enabled = parse_json_body(req)["enabled"] == true
1675
+
1676
+ config = Clacky::ChannelConfig.load
1677
+
1678
+ if enabled
1679
+ unless config.platform_config(platform)
1680
+ json_response(res, 422, { ok: false, error: "Platform is not configured yet" })
1681
+ return
1682
+ end
1683
+ config.enable_platform(platform)
1684
+ else
1685
+ config.disable_platform(platform)
1686
+ end
1687
+
1688
+ config.save
1689
+ @channel_manager.reload_platform(platform, config)
1690
+
1691
+ json_response(res, 200, { ok: true, enabled: config.enabled?(platform) })
1692
+ rescue StandardError => e
1693
+ json_response(res, 422, { ok: false, error: e.message })
1694
+ end
1695
+
1651
1696
  # POST /api/channels/:platform/test
1652
1697
  # Body: { fields... } (credentials to test — NOT saved)
1653
1698
  # Tests connectivity using the provided credentials without persisting.
@@ -1689,6 +1734,24 @@ module Clacky
1689
1734
  has_token: !raw["token"].to_s.strip.empty?,
1690
1735
  token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
1691
1736
  }
1737
+ when :discord
1738
+ {
1739
+ allowed_users: raw["allowed_users"] || [],
1740
+ has_token: !raw["bot_token"].to_s.strip.empty?,
1741
+ token_updated_at: raw["token_updated_at"]
1742
+ }
1743
+ when :telegram
1744
+ {
1745
+ base_url: raw["base_url"] || Clacky::Channel::Adapters::Telegram::ApiClient::DEFAULT_BASE_URL,
1746
+ parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
1747
+ allowed_users: raw["allowed_users"] || [],
1748
+ has_token: !raw["bot_token"].to_s.strip.empty?
1749
+ }
1750
+ when :dingtalk
1751
+ {
1752
+ client_id: raw["client_id"] || "",
1753
+ allowed_users: raw["allowed_users"] || []
1754
+ }
1692
1755
  else
1693
1756
  {}
1694
1757
  end
@@ -3459,6 +3522,14 @@ module Clacky
3459
3522
  # session persistence, and idle compression timer lifecycle.
3460
3523
  # Yields to the caller to perform the actual agent.run call.
3461
3524
  private def run_agent_task(session_id, agent, &task)
3525
+ if @registry.running_full?
3526
+ broadcast(session_id, { type: "error", session_id: session_id,
3527
+ message: "Too many concurrent tasks (max #{@registry.max_running_agents}), please try again later" })
3528
+ return
3529
+ end
3530
+
3531
+ @registry.evict_excess_idle!
3532
+
3462
3533
  idle_timer = nil
3463
3534
  @registry.with_session(session_id) { |s| idle_timer = s[:idle_timer] }
3464
3535
 
@@ -275,35 +275,57 @@ module Clacky
275
275
  puts ""
276
276
  end
277
277
 
278
+ # Scan all fallback port PID files to prevent duplicate masters
279
+ # when a previous instance bound to a non-default fallback port.
278
280
  def kill_existing_master
279
- return unless File.exist?(pid_file_path)
281
+ max_port = (@port == 7070) ? (@port + 5) : @port
282
+ (@port..max_port).each do |port|
283
+ kill_master_on_port(port)
284
+ end
285
+ end
280
286
 
281
- pid = File.read(pid_file_path).strip.to_i
282
- return if pid <= 0
287
+ private def kill_master_on_port(port)
288
+ path = File.join(Dir.tmpdir, "clacky-master-#{port}.pid")
289
+ return unless File.exist?(path)
290
+
291
+ pid = File.read(path).strip.to_i
292
+ if pid <= 0
293
+ File.delete(path) rescue nil
294
+ return
295
+ end
283
296
 
284
297
  begin
285
298
  Process.kill("TERM", pid)
286
- Clacky::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}), waiting up to 3s...")
299
+ Clacky::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}, port=#{port}), waiting...")
287
300
 
288
- unless port_free_within?(5)
289
- Clacky::Logger.warn("[Master] Port #{@port} still in use after 5s, sending KILL to PID=#{pid}...")
301
+ deadline = Time.now + 5
302
+ until process_dead?(pid) || Time.now > deadline
303
+ sleep 0.1
304
+ end
305
+
306
+ unless process_dead?(pid)
307
+ Clacky::Logger.warn("[Master] PID=#{pid} still alive after 5s, sending KILL...")
290
308
  Process.kill("KILL", pid) rescue Errno::ESRCH
291
- unless port_free_within?(2)
292
- Clacky::Logger.error("[Master] Port #{@port} still in use after KILL, giving up.")
293
- exit(1)
294
- end
295
309
  end
296
310
 
297
- Clacky::Logger.info("[Master] Port #{@port} is now free.")
311
+ Clacky::Logger.info("[Master] Existing master PID=#{pid} (port=#{port}) stopped.")
298
312
  rescue Errno::ESRCH
299
313
  Clacky::Logger.info("[Master] Existing master PID=#{pid} already gone.")
300
314
  rescue Errno::EPERM
301
315
  Clacky::Logger.warn("[Master] Could not stop existing master (PID=#{pid}) — permission denied.")
302
- exit(1)
303
316
  ensure
304
- File.delete(pid_file_path) if File.exist?(pid_file_path)
317
+ File.delete(path) if File.exist?(path)
305
318
  end
306
319
  end
320
+
321
+ private def process_dead?(pid)
322
+ Process.kill(0, pid)
323
+ false
324
+ rescue Errno::ESRCH
325
+ true
326
+ rescue Errno::EPERM
327
+ false
328
+ end
307
329
  end
308
330
  end
309
331
  end
@@ -18,13 +18,12 @@ module Clacky
18
18
  class SessionRegistry
19
19
  SESSION_TIMEOUT = 24 * 60 * 60 # 24 hours of inactivity before cleanup
20
20
 
21
- # session_manager: Clacky::SessionManager instance
22
- # session_restorer: callable(session_data) → session_id — builds agent + wires into registry
23
- def initialize(session_manager: nil, session_restorer: nil)
21
+ def initialize(session_manager: nil, session_restorer: nil, agent_config:)
24
22
  @sessions = {}
25
23
  @mutex = Mutex.new
26
24
  @session_manager = session_manager
27
25
  @session_restorer = session_restorer
26
+ @agent_config = agent_config
28
27
  # Tracks sessions currently being restored from disk.
29
28
  # Other threads calling ensure() for the same id will wait via @restore_cond
30
29
  # instead of seeing a half-built session (agent=nil).
@@ -322,6 +321,58 @@ module Clacky
322
321
  end
323
322
  end
324
323
 
324
+ def count_by_status(status)
325
+ @mutex.synchronize do
326
+ @sessions.count { |_, s| s[:status] == status }
327
+ end
328
+ end
329
+
330
+ def max_running_agents
331
+ @agent_config.max_running_agents
332
+ end
333
+
334
+ def max_idle_agents
335
+ @agent_config.max_idle_agents
336
+ end
337
+
338
+ def running_full?
339
+ count_by_status(:running) >= max_running_agents
340
+ end
341
+
342
+ # Evict oldest idle agents beyond MAX_IDLE_AGENTS.
343
+ # Persists session data to disk before releasing the agent from memory.
344
+ def evict_excess_idle!
345
+ to_evict = []
346
+
347
+ @mutex.synchronize do
348
+ idle = @sessions.select { |_, s| s[:status] == :idle && s[:agent] }
349
+ .sort_by { |_, s| s[:updated_at] || Time.at(0) }
350
+
351
+ while idle.size > max_idle_agents
352
+ id, session = idle.shift
353
+ to_evict << [id, session]
354
+ end
355
+ end
356
+
357
+ to_evict.each { |id, session| persist_and_release(id, session) }
358
+ end
359
+
360
+ private def persist_and_release(id, session)
361
+ agent = session[:agent]
362
+ @session_manager&.save(agent.to_session_data(status: :success)) if agent
363
+
364
+ @mutex.synchronize do
365
+ s = @sessions[id]
366
+ next unless s
367
+ s[:idle_timer]&.cancel
368
+ s[:agent] = nil
369
+ s[:ui] = nil
370
+ s[:idle_timer] = nil
371
+ s[:thread] = nil
372
+ @sessions.delete(id)
373
+ end
374
+ end
375
+
325
376
  # Build a summary hash for API responses (for in-registry sessions).
326
377
  # Used when we need live agent fields (name, cost, etc.) after ensure().
327
378
  def session_summary(session_id)
@@ -87,7 +87,13 @@ module Clacky
87
87
  def show_assistant_message(content, files:)
88
88
  return if (content.nil? || content.to_s.strip.empty?) && files.empty?
89
89
 
90
- emit("assistant_message", content: content.to_s, files: files)
90
+ # Rewrite local image paths (file:// and bare absolute) to /api/local-image
91
+ # proxy URLs only for the browser, which runs on http://localhost and is
92
+ # blocked by browser security policy from loading file:// directly.
93
+ # Channel subscribers receive the original content so they can deliver
94
+ # local images as native attachments via send_file().
95
+ web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
96
+ emit("assistant_message", content: web_content, files: files)
91
97
  forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files) }
92
98
  end
93
99
 
@@ -67,22 +67,7 @@ module Clacky
67
67
  end
68
68
 
69
69
  private def resolve_device_id(brand)
70
- if brand.device_id && !brand.device_id.empty?
71
- brand.device_id
72
- else
73
- generate_anonymous_device_id
74
- end
75
- end
76
-
77
- # Generate an anonymous device fingerprint that is stable for the same
78
- # machine/user but does not reveal identifiable information.
79
- private def generate_anonymous_device_id
80
- components = [
81
- Socket.gethostname,
82
- ENV["USER"] || ENV["USERNAME"] || "",
83
- RUBY_PLATFORM
84
- ]
85
- Digest::SHA256.hexdigest(components.join(":"))
70
+ brand.device_id
86
71
  end
87
72
 
88
73
  # Send a POST to the telemetry endpoint in a background thread.
@@ -108,7 +108,6 @@ module Clacky
108
108
  if action == "screenshot" && result[:image_data]
109
109
  mime_type = result[:mime_type] || "image/png"
110
110
  image_data = result[:image_data]
111
- data_url = "data:#{mime_type};base64,#{image_data}"
112
111
  original_path = result[:original_path]
113
112
  compressed_path = result[:compressed_path]
114
113
 
@@ -118,10 +117,14 @@ module Clacky
118
117
  "\n- Compressed (800px, sent to AI): #{compressed_path || 'unavailable'}"
119
118
  end
120
119
 
121
- return [
122
- { type: "text", text: text },
123
- { type: "image_url", image_url: { url: data_url } }
124
- ]
120
+ return {
121
+ content_string: text,
122
+ image_inject: {
123
+ mime_type: mime_type,
124
+ base64_data: image_data,
125
+ path: compressed_path || original_path
126
+ }
127
+ }
125
128
  end
126
129
 
127
130
  output = result[:output].to_s
@@ -52,11 +52,6 @@ module Clacky
52
52
  begin
53
53
  expanded_path = base_path
54
54
 
55
- # Initialize gitignore parser
56
- gitignore_path = Clacky::Utils::FileIgnoreHelper.find_gitignore(expanded_path)
57
- gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
58
-
59
- # Track skipped files
60
55
  skipped = {
61
56
  binary: 0,
62
57
  too_large: 0,
@@ -64,8 +59,6 @@ module Clacky
64
59
  }
65
60
 
66
61
  # Auto-expand bare patterns (no slash, no **) to recursive search.
67
- # e.g. "*install*" -> "**/*install*", "*.rb" -> "**/*.rb"
68
- # This avoids surprising empty results when files are in subdirectories.
69
62
  effective_pattern = if !File.absolute_path?(pattern) &&
70
63
  !pattern.include?("/") &&
71
64
  !pattern.start_with?("**")
@@ -74,48 +67,28 @@ module Clacky
74
67
  pattern
75
68
  end
76
69
 
77
- # Build full pattern - handle absolute paths correctly
78
- full_pattern = if File.absolute_path?(effective_pattern)
79
- effective_pattern
80
- else
81
- File.join(base_path, effective_pattern)
82
- end
83
- # Always-ignored directory names that should never appear in results
84
- always_ignored_dirs = Clacky::Utils::FileIgnoreHelper::ALWAYS_IGNORED_DIRS
85
-
86
- all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
87
- .map { |p| p.encoding == Encoding::UTF_8 && p.valid_encoding? ? p : p.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") }
88
- .reject { |path| File.directory?(path) }
89
- .reject { |path| path.end_with?(".", "..") }
90
- .reject do |path|
91
- # Fast path: reject files inside always-ignored dirs by path component
92
- parts = path.split(File::SEPARATOR)
93
- parts.any? { |part| always_ignored_dirs.include?(part) }
94
- end
95
-
96
- # Filter out ignored, binary, and too large files
97
- matches = all_matches.select do |file|
98
- # Skip if file should be ignored (unless it's a config file)
99
- if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
100
- !Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
101
- skipped[:ignored] += 1
102
- next false
70
+ fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
71
+
72
+ matches = []
73
+ Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |file|
74
+ relative = file[(expanded_path.length + 1)..]
75
+
76
+ unless File.fnmatch(effective_pattern, relative, fnmatch_flags)
77
+ next
103
78
  end
104
79
 
105
- # Skip binary files (but allow known document types like PDF/Office)
106
80
  if Clacky::Utils::FileProcessor.binary_file_path?(file) &&
107
81
  !Clacky::Utils::FileProcessor.glob_allowed_binary?(file)
108
82
  skipped[:binary] += 1
109
- next false
83
+ next
110
84
  end
111
85
 
112
- # Skip files that are too large
113
86
  if File.size(file) > MAX_FILE_SIZE
114
87
  skipped[:too_large] += 1
115
- next false
88
+ next
116
89
  end
117
90
 
118
- true
91
+ matches << file
119
92
  end
120
93
 
121
94
  # Sort by modification time (most recent first)
@@ -100,10 +100,6 @@ module Clacky
100
100
  regex_options = case_insensitive ? Regexp::IGNORECASE : 0
101
101
  regex = Regexp.new(pattern, regex_options)
102
102
 
103
- # Initialize gitignore parser
104
- gitignore_path = Clacky::Utils::FileIgnoreHelper.find_gitignore(expanded_path)
105
- gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
106
-
107
103
  results = []
108
104
  total_matches = 0
109
105
  files_searched = 0
@@ -114,29 +110,24 @@ module Clacky
114
110
  }
115
111
  truncation_reason = nil
116
112
 
117
- # Get files to search
118
113
  files = if File.file?(expanded_path)
119
114
  [expanded_path]
120
115
  else
121
- Dir.glob(File.join(expanded_path, file_pattern))
122
- .select { |f| File.file?(f) }
116
+ fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
117
+ collected = []
118
+ Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |f|
119
+ relative = f[(expanded_path.length + 1)..]
120
+ collected << f if File.fnmatch(file_pattern, relative, fnmatch_flags)
121
+ end
122
+ collected
123
123
  end
124
124
 
125
- # Search each file
126
125
  files.each do |file|
127
- # Check if we've searched enough files
128
126
  if files_searched >= max_files_to_search
129
127
  truncation_reason ||= "max_files_to_search limit reached"
130
128
  break
131
129
  end
132
130
 
133
- # Skip if file should be ignored (unless it's a config file)
134
- if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
135
- !Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
136
- skipped[:ignored] += 1
137
- next
138
- end
139
-
140
131
  # Skip binary files
141
132
  if Clacky::Utils::FileProcessor.binary_file_path?(file)
142
133
  skipped[:binary] += 1
@@ -26,7 +26,7 @@ module Clacky
26
26
 
27
27
  parsed
28
28
  rescue StandardError => e
29
- # Fallback to plain content if rendering fails
29
+ warn "[markdown] render failed: #{e.class}: #{e.message}" if ENV["CLACKY_DEBUG"]
30
30
  content
31
31
  end
32
32
 
@@ -733,8 +733,9 @@ module Clacky
733
733
  @legacy_progress_handles ||= {}
734
734
 
735
735
  if phase.to_s == "done"
736
- handle = @legacy_progress_handles.delete(type)
736
+ handle = @legacy_progress_handles[type]
737
737
  handle&.finish(final_message: message)
738
+ @legacy_progress_handles.delete(type)
738
739
  return
739
740
  end
740
741
 
@@ -116,6 +116,55 @@ module Clacky
116
116
  CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
117
117
  end
118
118
 
119
+ # Walk a directory tree, pruning ignored directories early.
120
+ # Yields each non-ignored file path. Supports nested .gitignore files.
121
+ # @param skipped [Hash, nil] If provided, increments :ignored for each gitignore-skipped entry.
122
+ def self.walk_files(base_path, gitignore: nil, skipped: nil, &block)
123
+ return enum_for(:walk_files, base_path, gitignore: gitignore, skipped: skipped) unless block_given?
124
+
125
+ root_gitignore = gitignore || begin
126
+ gi_path = find_gitignore(base_path)
127
+ gi_path ? Clacky::GitignoreParser.new(gi_path) : nil
128
+ end
129
+
130
+ _walk_recursive(base_path, base_path, root_gitignore, skipped, &block)
131
+ end
132
+
133
+ def self._walk_recursive(dir, base_path, gitignore, skipped, &block)
134
+ child_gitignore_path = File.join(dir, ".gitignore")
135
+ if dir != base_path && File.exist?(child_gitignore_path)
136
+ gitignore ||= Clacky::GitignoreParser.new(nil)
137
+ relative_dir = dir[(base_path.length + 1)..]
138
+ gitignore.merge!(child_gitignore_path, prefix: relative_dir)
139
+ end
140
+
141
+ begin
142
+ entries = Dir.children(dir)
143
+ rescue Errno::EACCES, Errno::ENOENT
144
+ return
145
+ end
146
+
147
+ entries.sort.each do |name|
148
+ full = File.join(dir, name)
149
+ relative = full[(base_path.length + 1)..]
150
+
151
+ if File.directory?(full)
152
+ next if ALWAYS_IGNORED_DIRS.include?(name)
153
+ if gitignore&.ignored?("#{relative}/") || should_ignore_file?(full, base_path, gitignore)
154
+ next
155
+ end
156
+ _walk_recursive(full, base_path, gitignore, skipped, &block)
157
+ else
158
+ if !is_config_file?(full) && should_ignore_file?(full, base_path, gitignore)
159
+ skipped[:ignored] += 1 if skipped
160
+ next
161
+ end
162
+ yield full
163
+ end
164
+ end
165
+ end
166
+ private_class_method :_walk_recursive
167
+
119
168
  end
120
169
  end
121
170
  end
@@ -14,6 +14,33 @@ module Clacky
14
14
  end
15
15
  end
16
16
 
17
+ def merge!(other_gitignore_path, prefix: nil)
18
+ return unless other_gitignore_path && File.exist?(other_gitignore_path)
19
+
20
+ File.readlines(other_gitignore_path, chomp: true).each do |line|
21
+ next if line.strip.empty? || line.start_with?('#')
22
+
23
+ negation = line.start_with?('!')
24
+ raw = negation ? line[1..] : line
25
+ info = normalize_pattern(raw)
26
+
27
+ if prefix
28
+ original = info[:pattern]
29
+ original = original[1..] if info[:is_absolute]
30
+ info[:pattern] = "#{prefix}/#{original}"
31
+ info[:is_absolute] = false
32
+ end
33
+
34
+ if negation
35
+ @negation_patterns << info
36
+ else
37
+ @patterns << info
38
+ end
39
+ end
40
+ rescue StandardError => e
41
+ warn "Warning: Failed to merge .gitignore: #{e.message}"
42
+ end
43
+
17
44
  # Check if a file path should be ignored
18
45
  def ignored?(path)
19
46
  relative_path = path.start_with?('./') ? path[2..] : path
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.0.4"
4
+ VERSION = "1.1.0"
5
5
  end