clacky 0.5.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.
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Clacky
6
+ module Tools
7
+ class Shell < Base
8
+ self.tool_name = "shell"
9
+ self.tool_description = "Execute shell commands in the terminal"
10
+ self.tool_category = "system"
11
+ self.tool_parameters = {
12
+ type: "object",
13
+ properties: {
14
+ command: {
15
+ type: "string",
16
+ description: "Shell command to execute"
17
+ },
18
+ soft_timeout: {
19
+ type: "integer",
20
+ description: "Soft timeout in seconds (for interaction detection)"
21
+ },
22
+ hard_timeout: {
23
+ type: "integer",
24
+ description: "Hard timeout in seconds (force kill)"
25
+ }
26
+ },
27
+ required: ["command"]
28
+ }
29
+
30
+ INTERACTION_PATTERNS = [
31
+ [/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
32
+ [/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
33
+ [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^>\s*$/, 'repl'],
34
+ [/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
35
+ [/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
36
+ [/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
37
+ [/Select an option|Choose|Which one|select one/i, 'selection']
38
+ ].freeze
39
+
40
+ SLOW_COMMANDS = [
41
+ 'bundle install',
42
+ 'npm install',
43
+ 'yarn install',
44
+ 'pnpm install',
45
+ 'rspec',
46
+ 'rake test',
47
+ 'npm run build',
48
+ 'npm run test',
49
+ 'yarn build',
50
+ 'cargo build',
51
+ 'go build'
52
+ ].freeze
53
+
54
+ def execute(command:, soft_timeout: nil, hard_timeout: nil)
55
+ require "open3"
56
+ require "stringio"
57
+
58
+ soft_timeout, hard_timeout = determine_timeouts(command, soft_timeout, hard_timeout)
59
+
60
+ stdout_buffer = StringIO.new
61
+ stderr_buffer = StringIO.new
62
+ soft_timeout_triggered = false
63
+
64
+ begin
65
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
66
+ start_time = Time.now
67
+
68
+ stdout.sync = true
69
+ stderr.sync = true
70
+
71
+ loop do
72
+ elapsed = Time.now - start_time
73
+
74
+ if elapsed > hard_timeout
75
+ Process.kill('TERM', wait_thr.pid) rescue nil
76
+ sleep 0.5
77
+ Process.kill('KILL', wait_thr.pid) if wait_thr.alive? rescue nil
78
+
79
+ return format_timeout_result(
80
+ command,
81
+ stdout_buffer.string,
82
+ stderr_buffer.string,
83
+ elapsed,
84
+ :hard_timeout,
85
+ hard_timeout
86
+ )
87
+ end
88
+
89
+ if elapsed > soft_timeout && !soft_timeout_triggered
90
+ soft_timeout_triggered = true
91
+
92
+ # L1:
93
+ interaction = detect_interaction(stdout_buffer.string)
94
+ if interaction
95
+ Process.kill('TERM', wait_thr.pid) rescue nil
96
+ return format_waiting_input_result(
97
+ command,
98
+ stdout_buffer.string,
99
+ stderr_buffer.string,
100
+ interaction
101
+ )
102
+ end
103
+
104
+ # L2:
105
+ last_size = stdout_buffer.size
106
+ stdin.puts("\n") rescue nil
107
+ sleep 2
108
+
109
+ if stdout_buffer.size > last_size
110
+ next
111
+ else
112
+ Process.kill('TERM', wait_thr.pid) rescue nil
113
+ return format_stuck_result(
114
+ command,
115
+ stdout_buffer.string,
116
+ stderr_buffer.string,
117
+ elapsed
118
+ )
119
+ end
120
+ end
121
+
122
+ break unless wait_thr.alive?
123
+
124
+ begin
125
+ ready = IO.select([stdout, stderr], nil, nil, 0.1)
126
+ if ready
127
+ ready[0].each do |io|
128
+ begin
129
+ data = io.read_nonblock(4096)
130
+ if io == stdout
131
+ stdout_buffer.write(data)
132
+ else
133
+ stderr_buffer.write(data)
134
+ end
135
+ rescue IO::WaitReadable, EOFError
136
+ end
137
+ end
138
+ end
139
+ rescue StandardError => e
140
+ end
141
+
142
+ sleep 0.1
143
+ end
144
+
145
+ begin
146
+ stdout_buffer.write(stdout.read)
147
+ rescue StandardError
148
+ end
149
+ begin
150
+ stderr_buffer.write(stderr.read)
151
+ rescue StandardError
152
+ end
153
+
154
+ {
155
+ command: command,
156
+ stdout: stdout_buffer.string,
157
+ stderr: stderr_buffer.string,
158
+ exit_code: wait_thr.value.exitstatus,
159
+ success: wait_thr.value.success?,
160
+ elapsed: Time.now - start_time
161
+ }
162
+ end
163
+ rescue StandardError => e
164
+ {
165
+ command: command,
166
+ stdout: stdout_buffer.string,
167
+ stderr: "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}",
168
+ exit_code: -1,
169
+ success: false
170
+ }
171
+ end
172
+ end
173
+
174
+ def determine_timeouts(command, soft_timeout, hard_timeout)
175
+ # 检查是否是慢命令
176
+ is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }
177
+
178
+ if is_slow
179
+ soft_timeout ||= 30
180
+ hard_timeout ||= 180 # 3分钟
181
+ else
182
+ soft_timeout ||= 7
183
+ hard_timeout ||= 60
184
+ end
185
+
186
+ [soft_timeout, hard_timeout]
187
+ end
188
+
189
+ # L1: 规则检测
190
+ def detect_interaction(output)
191
+ return nil if output.empty?
192
+
193
+ lines = output.split("\n").last(10)
194
+
195
+ lines.reverse.each do |line|
196
+ line_stripped = line.strip
197
+ next if line_stripped.empty?
198
+
199
+ INTERACTION_PATTERNS.each do |pattern, type|
200
+ if line.match?(pattern)
201
+ return { type: type, line: line_stripped }
202
+ end
203
+ end
204
+ end
205
+
206
+ nil
207
+ end
208
+
209
+ def format_waiting_input_result(command, stdout, stderr, interaction)
210
+ {
211
+ command: command,
212
+ stdout: stdout,
213
+ stderr: stderr,
214
+ exit_code: -2,
215
+ success: false,
216
+ state: 'WAITING_INPUT',
217
+ interaction_type: interaction[:type],
218
+ message: format_waiting_message(stdout, interaction)
219
+ }
220
+ end
221
+
222
+ def format_waiting_message(output, interaction)
223
+ <<~MSG
224
+ #{output}
225
+
226
+ #{'=' * 60}
227
+ [Terminal State: WAITING_INPUT]
228
+ #{'=' * 60}
229
+
230
+ The terminal is waiting for your input.
231
+
232
+ Detected pattern: #{interaction[:type]}
233
+ Last line: #{interaction[:line]}
234
+
235
+ Suggested actions:
236
+ • Provide answer: run shell with your response
237
+ • Cancel: send Ctrl+C (\\x03)
238
+ MSG
239
+ end
240
+
241
+ def format_stuck_result(command, stdout, stderr, elapsed)
242
+ {
243
+ command: command,
244
+ stdout: stdout,
245
+ stderr: stderr,
246
+ exit_code: -3,
247
+ success: false,
248
+ state: 'STUCK',
249
+ elapsed: elapsed,
250
+ message: format_stuck_message(stdout, elapsed)
251
+ }
252
+ end
253
+
254
+ def format_stuck_message(output, elapsed)
255
+ <<~MSG
256
+ #{output}
257
+
258
+ #{'=' * 60}
259
+ [Terminal State: STUCK]
260
+ #{'=' * 60}
261
+
262
+ The terminal is not responding after #{elapsed.round(1)}s.
263
+
264
+ Suggested actions:
265
+ • Try interrupting with Ctrl+C
266
+ • Check if command is frozen
267
+ MSG
268
+ end
269
+
270
+ def format_timeout_result(command, stdout, stderr, elapsed, type, timeout)
271
+ {
272
+ command: command,
273
+ stdout: stdout,
274
+ stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
275
+ exit_code: -1,
276
+ success: false,
277
+ state: 'TIMEOUT',
278
+ timeout_type: type
279
+ }
280
+ end
281
+
282
+ def format_call(args)
283
+ cmd = args[:command] || args['command'] || ''
284
+ cmd_parts = cmd.split
285
+ cmd_short = cmd_parts.first(3).join(' ')
286
+ cmd_short += '...' if cmd_parts.size > 3
287
+ "Shell(#{cmd_short})"
288
+ end
289
+
290
+ def format_result(result)
291
+ exit_code = result[:exit_code] || result['exit_code'] || 0
292
+ stdout = result[:stdout] || result['stdout'] || ""
293
+ stderr = result[:stderr] || result['stderr'] || ""
294
+
295
+ if exit_code == 0
296
+ lines = stdout.lines.size
297
+ "✓ Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
298
+ else
299
+ error_msg = stderr.lines.first&.strip || "Failed"
300
+ "✗ Exit #{exit_code}: #{error_msg[0..50]}"
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Clacky
7
+ module Tools
8
+ class TodoManager < Base
9
+ self.tool_name = "todo_manager"
10
+ self.tool_description = "Manage TODO items for task planning and tracking. IMPORTANT: This tool is ONLY for planning - after adding all TODOs, you MUST immediately start executing them using other tools (write, edit, shell, etc). DO NOT stop after adding TODOs!"
11
+ self.tool_category = "task_management"
12
+ self.tool_parameters = {
13
+ type: "object",
14
+ properties: {
15
+ action: {
16
+ type: "string",
17
+ enum: ["add", "list", "complete", "remove", "clear"],
18
+ description: "Action to perform: 'add' (add new todo(s)), 'list' (show all todos), 'complete' (mark as done), 'remove' (delete todo), 'clear' (remove all todos)"
19
+ },
20
+ tasks: {
21
+ type: "array",
22
+ items: { type: "string" },
23
+ description: "Array of task descriptions to add (for 'add' action). Example: ['Task 1', 'Task 2', 'Task 3']"
24
+ },
25
+ task: {
26
+ type: "string",
27
+ description: "Single task description (for 'add' action). Use 'tasks' array for adding multiple tasks at once."
28
+ },
29
+ id: {
30
+ type: "integer",
31
+ description: "The task ID (required for 'complete' and 'remove' actions)"
32
+ }
33
+ },
34
+ required: ["action"]
35
+ }
36
+
37
+ def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
38
+ # todos_storage is injected by Agent, stores todos in memory
39
+ @todos = todos_storage || []
40
+
41
+ case action
42
+ when "add"
43
+ add_todos(task: task, tasks: tasks)
44
+ when "list"
45
+ list_todos
46
+ when "complete"
47
+ complete_todo(id)
48
+ when "remove"
49
+ remove_todo(id)
50
+ when "clear"
51
+ clear_todos
52
+ else
53
+ { error: "Unknown action: #{action}" }
54
+ end
55
+ end
56
+
57
+ def format_call(args)
58
+ action = args[:action] || args['action']
59
+ case action
60
+ when 'add'
61
+ count = (args[:tasks] || args['tasks'])&.size || 1
62
+ "TodoManager(add #{count} task#{count > 1 ? 's' : ''})"
63
+ when 'complete'
64
+ "TodoManager(complete ##{args[:id] || args['id']})"
65
+ when 'list'
66
+ "TodoManager(list)"
67
+ when 'remove'
68
+ "TodoManager(remove ##{args[:id] || args['id']})"
69
+ when 'clear'
70
+ "TodoManager(clear all)"
71
+ else
72
+ "TodoManager(#{action})"
73
+ end
74
+ end
75
+
76
+ def format_result(result)
77
+ return result[:error] if result[:error]
78
+
79
+ if result[:message]
80
+ result[:message]
81
+ else
82
+ "Done"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def load_todos
89
+ @todos
90
+ end
91
+
92
+ def save_todos(todos)
93
+ # Modify the array in-place so Agent's @todos is updated
94
+ # Important: Don't use @todos.clear first because todos might be @todos itself!
95
+ @todos.replace(todos)
96
+ end
97
+
98
+ def add_todos(task: nil, tasks: nil)
99
+ # Determine which tasks to add
100
+ tasks_to_add = []
101
+
102
+ if tasks && tasks.is_a?(Array) && !tasks.empty?
103
+ tasks_to_add = tasks.map(&:strip).reject(&:empty?)
104
+ elsif task && !task.strip.empty?
105
+ tasks_to_add = [task.strip]
106
+ end
107
+
108
+ return { error: "At least one task description is required" } if tasks_to_add.empty?
109
+
110
+ existing_todos = load_todos
111
+ next_id = existing_todos.empty? ? 1 : existing_todos.map { |t| t[:id] }.max + 1
112
+
113
+ added_todos = []
114
+ tasks_to_add.each_with_index do |task_desc, index|
115
+ new_todo = {
116
+ id: next_id + index,
117
+ task: task_desc,
118
+ status: "pending",
119
+ created_at: Time.now.iso8601
120
+ }
121
+ existing_todos << new_todo
122
+ added_todos << new_todo
123
+ end
124
+
125
+ save_todos(existing_todos)
126
+
127
+ {
128
+ message: added_todos.size == 1 ? "TODO added successfully" : "#{added_todos.size} TODOs added successfully",
129
+ todos: added_todos,
130
+ total: existing_todos.size,
131
+ reminder: "⚠️ IMPORTANT: You have added TODO(s) but have NOT started working yet! You MUST now use other tools (write, edit, shell, etc.) to actually complete these tasks. DO NOT stop here!"
132
+ }
133
+ end
134
+
135
+ def list_todos
136
+ todos = load_todos
137
+
138
+ if todos.empty?
139
+ return {
140
+ message: "No TODO items",
141
+ todos: [],
142
+ total: 0
143
+ }
144
+ end
145
+
146
+ {
147
+ message: "TODO list",
148
+ todos: todos,
149
+ total: todos.size,
150
+ pending: todos.count { |t| t[:status] == "pending" },
151
+ completed: todos.count { |t| t[:status] == "completed" }
152
+ }
153
+ end
154
+
155
+ def complete_todo(id)
156
+ return { error: "Task ID is required" } if id.nil?
157
+
158
+ todos = load_todos
159
+ todo = todos.find { |t| t[:id] == id }
160
+
161
+ return { error: "Task not found: #{id}" } unless todo
162
+
163
+ if todo[:status] == "completed"
164
+ return { message: "Task already completed", todo: todo }
165
+ end
166
+
167
+ todo[:status] = "completed"
168
+ todo[:completed_at] = Time.now.iso8601
169
+ save_todos(todos)
170
+
171
+ # Find the next pending task
172
+ next_pending = todos.find { |t| t[:status] == "pending" }
173
+
174
+ # Count statistics
175
+ completed_count = todos.count { |t| t[:status] == "completed" }
176
+ total_count = todos.size
177
+
178
+ result = {
179
+ message: "Task marked as completed",
180
+ todo: todo,
181
+ progress: "#{completed_count}/#{total_count}",
182
+ reminder: "⚠️ REMINDER: Check the PROJECT-SPECIFIC RULES section in your system prompt before continuing to the next task"
183
+ }
184
+
185
+ if next_pending
186
+ result[:next_task] = next_pending
187
+ result[:next_task_info] = "✅ Progress: #{completed_count}/#{total_count}. Next task: ##{next_pending[:id]} - #{next_pending[:task]}"
188
+ else
189
+ result[:all_completed] = true
190
+ result[:completion_message] = "🎉 All tasks completed! (#{completed_count}/#{total_count})"
191
+ end
192
+
193
+ result
194
+ end
195
+
196
+ def remove_todo(id)
197
+ return { error: "Task ID is required" } if id.nil?
198
+
199
+ todos = load_todos
200
+ todo = todos.find { |t| t[:id] == id }
201
+
202
+ return { error: "Task not found: #{id}" } unless todo
203
+
204
+ todos.reject! { |t| t[:id] == id }
205
+ save_todos(todos)
206
+
207
+ {
208
+ message: "Task removed",
209
+ todo: todo,
210
+ remaining: todos.size
211
+ }
212
+ end
213
+
214
+ def clear_todos
215
+ todos = load_todos
216
+ count = todos.size
217
+
218
+ # Clear the in-memory storage
219
+ save_todos([])
220
+
221
+ {
222
+ message: "All TODOs cleared",
223
+ cleared_count: count
224
+ }
225
+ end
226
+ end
227
+ end
228
+ end