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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +99 -356
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/CHANGELOG.md +42 -0
- data/docs/system-skill-authoring-guide.md +1 -1
- data/lib/clacky/agent/tool_executor.rb +3 -1
- data/lib/clacky/agent.rb +12 -7
- data/lib/clacky/agent_config.rb +9 -3
- data/lib/clacky/brand_config.rb +19 -4
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
- data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
- data/lib/clacky/message_history.rb +26 -1
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
- data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +26 -0
- data/lib/clacky/server/channel.rb +3 -0
- data/lib/clacky/server/http_server.rb +75 -4
- data/lib/clacky/server/server_master.rb +35 -13
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +7 -1
- data/lib/clacky/telemetry.rb +1 -16
- data/lib/clacky/tools/browser.rb +8 -5
- data/lib/clacky/tools/glob.rb +11 -38
- data/lib/clacky/tools/grep.rb +7 -16
- data/lib/clacky/ui2/markdown_renderer.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_ignore_helper.rb +49 -0
- data/lib/clacky/utils/gitignore_parser.rb +27 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +248 -31
- data/lib/clacky/web/app.js +51 -1
- data/lib/clacky/web/channels.js +98 -28
- data/lib/clacky/web/datepicker.js +205 -0
- data/lib/clacky/web/i18n.js +48 -9
- data/lib/clacky/web/index.html +33 -6
- data/lib/clacky/web/onboard.js +46 -4
- data/lib/clacky/web/sessions.js +33 -72
- data/lib/clacky/web/settings.js +42 -4
- data/lib/clacky/web/version.js +52 -1
- metadata +21 -10
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
- /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
|
-
#
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
299
|
+
Clacky::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}, port=#{port}), waiting...")
|
|
287
300
|
|
|
288
|
-
|
|
289
|
-
|
|
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]
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -67,22 +67,7 @@ module Clacky
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
private def resolve_device_id(brand)
|
|
70
|
-
|
|
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.
|
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
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
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
88
|
+
next
|
|
116
89
|
end
|
|
117
90
|
|
|
118
|
-
|
|
91
|
+
matches << file
|
|
119
92
|
end
|
|
120
93
|
|
|
121
94
|
# Sort by modification time (most recent first)
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
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
|
|
@@ -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
|
|
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
|
data/lib/clacky/version.rb
CHANGED