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.
- checksums.yaml +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -0
data/lib/clacky/cli.rb
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require "tty-spinner"
|
|
6
|
+
require "readline"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
class CLI < Thor
|
|
10
|
+
def self.exit_on_failure?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "chat [MESSAGE]", "Start a chat with Claude or send a single message"
|
|
15
|
+
long_desc <<-LONGDESC
|
|
16
|
+
Start an interactive chat session with Claude AI.
|
|
17
|
+
|
|
18
|
+
If MESSAGE is provided, send it as a single message and exit.
|
|
19
|
+
If no MESSAGE is provided, start an interactive chat session.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
$ clacky chat "What is Ruby?"
|
|
23
|
+
$ clacky chat
|
|
24
|
+
LONGDESC
|
|
25
|
+
option :model, type: :string, desc: "Model to use (default from config)"
|
|
26
|
+
def chat(message = nil)
|
|
27
|
+
config = Clacky::Config.load
|
|
28
|
+
|
|
29
|
+
unless config.api_key
|
|
30
|
+
say "Error: API key not found. Please run 'clacky config set' first.", :red
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if message
|
|
35
|
+
# Single message mode
|
|
36
|
+
send_single_message(message, config)
|
|
37
|
+
else
|
|
38
|
+
# Interactive mode
|
|
39
|
+
start_interactive_chat(config)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "version", "Show clacky version"
|
|
44
|
+
def version
|
|
45
|
+
say "Clacky version #{Clacky::VERSION}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use"
|
|
49
|
+
long_desc <<-LONGDESC
|
|
50
|
+
Run an AI agent in interactive mode that can autonomously use tools to complete tasks.
|
|
51
|
+
|
|
52
|
+
The agent runs in a continuous loop, allowing multiple tasks in one session.
|
|
53
|
+
Each task is completed with its own React (Reason-Act-Observe) cycle.
|
|
54
|
+
After completing a task, the agent waits for your next instruction.
|
|
55
|
+
|
|
56
|
+
Permission modes:
|
|
57
|
+
auto_approve - Automatically execute all tools (use with caution)
|
|
58
|
+
confirm_safes - Auto-approve safe operations, confirm risky ones (default)
|
|
59
|
+
confirm_edits - Auto-approve read-only tools, confirm edits
|
|
60
|
+
plan_only - Generate plan without executing
|
|
61
|
+
|
|
62
|
+
Session management:
|
|
63
|
+
-c, --continue - Continue the most recent session for this directory
|
|
64
|
+
-l, --list - List recent sessions
|
|
65
|
+
-a, --attach N - Attach to session number N from the list
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
$ clacky agent
|
|
69
|
+
$ clacky agent "Create a README file"
|
|
70
|
+
$ clacky agent --mode=auto_approve --path /path/to/project
|
|
71
|
+
$ clacky agent --tools file_reader glob grep
|
|
72
|
+
$ clacky agent -c
|
|
73
|
+
$ clacky agent -l
|
|
74
|
+
$ clacky agent -a 2
|
|
75
|
+
LONGDESC
|
|
76
|
+
option :mode, type: :string, default: "confirm_safes",
|
|
77
|
+
desc: "Permission mode: auto_approve, confirm_safes, confirm_edits, plan_only"
|
|
78
|
+
option :tools, type: :array, default: ["all"], desc: "Allowed tools"
|
|
79
|
+
option :max_iterations, type: :numeric, desc: "Maximum iterations (default: 50)"
|
|
80
|
+
option :max_cost, type: :numeric, desc: "Maximum cost in USD (default: 5.0)"
|
|
81
|
+
option :verbose, type: :boolean, default: false, desc: "Show detailed output"
|
|
82
|
+
option :path, type: :string, desc: "Project directory path (defaults to current directory)"
|
|
83
|
+
option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
|
|
84
|
+
option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
|
|
85
|
+
option :attach, type: :numeric, aliases: "-a", desc: "Attach to session by number"
|
|
86
|
+
def agent(message = nil)
|
|
87
|
+
config = Clacky::Config.load
|
|
88
|
+
|
|
89
|
+
unless config.api_key
|
|
90
|
+
say "Error: API key not found. Please run 'clacky config set' first.", :red
|
|
91
|
+
exit 1
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Handle session listing
|
|
95
|
+
if options[:list]
|
|
96
|
+
list_sessions
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Handle Ctrl+C gracefully - raise exception to be caught in the loop
|
|
101
|
+
Signal.trap("INT") do
|
|
102
|
+
Thread.main.raise(Clacky::AgentInterrupted, "Interrupted by user")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Validate and get working directory
|
|
106
|
+
working_dir = validate_working_directory(options[:path])
|
|
107
|
+
|
|
108
|
+
# Build agent config
|
|
109
|
+
agent_config = build_agent_config(config)
|
|
110
|
+
client = Clacky::Client.new(config.api_key, base_url: config.base_url)
|
|
111
|
+
|
|
112
|
+
# Handle session loading/continuation
|
|
113
|
+
session_manager = Clacky::SessionManager.new
|
|
114
|
+
agent = nil
|
|
115
|
+
|
|
116
|
+
if options[:continue]
|
|
117
|
+
agent = load_latest_session(client, agent_config, session_manager, working_dir)
|
|
118
|
+
elsif options[:attach]
|
|
119
|
+
agent = load_session_by_number(client, agent_config, session_manager, working_dir, options[:attach])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Create new agent if no session loaded
|
|
123
|
+
agent ||= Clacky::Agent.new(client, agent_config, working_dir: working_dir)
|
|
124
|
+
|
|
125
|
+
# Change to working directory
|
|
126
|
+
original_dir = Dir.pwd
|
|
127
|
+
should_chdir = File.realpath(working_dir) != File.realpath(original_dir)
|
|
128
|
+
Dir.chdir(working_dir) if should_chdir
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
# Always run in interactive mode
|
|
132
|
+
run_agent_interactive(agent, working_dir, agent_config, message, session_manager)
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
say "\nā Error: #{e.message}", :red
|
|
135
|
+
say e.backtrace.first(5).join("\n"), :red if options[:verbose]
|
|
136
|
+
if session_manager&.last_saved_path
|
|
137
|
+
say "š Session saved: #{session_manager.last_saved_path}", :yellow
|
|
138
|
+
end
|
|
139
|
+
exit 1
|
|
140
|
+
ensure
|
|
141
|
+
Dir.chdir(original_dir)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
desc "tools", "List available tools"
|
|
146
|
+
option :category, type: :string, desc: "Filter by category"
|
|
147
|
+
def tools
|
|
148
|
+
registry = ToolRegistry.new
|
|
149
|
+
|
|
150
|
+
registry.register(Tools::Shell.new)
|
|
151
|
+
registry.register(Tools::FileReader.new)
|
|
152
|
+
registry.register(Tools::Write.new)
|
|
153
|
+
registry.register(Tools::Edit.new)
|
|
154
|
+
registry.register(Tools::Glob.new)
|
|
155
|
+
registry.register(Tools::Grep.new)
|
|
156
|
+
registry.register(Tools::WebSearch.new)
|
|
157
|
+
registry.register(Tools::WebFetch.new)
|
|
158
|
+
|
|
159
|
+
say "\nš¦ Available Tools:\n\n", :green
|
|
160
|
+
|
|
161
|
+
tools_to_show = if options[:category]
|
|
162
|
+
registry.by_category(options[:category])
|
|
163
|
+
else
|
|
164
|
+
registry.all
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
tools_to_show.each do |tool|
|
|
168
|
+
say " #{tool.name}", :cyan
|
|
169
|
+
say " #{tool.description}", :white
|
|
170
|
+
say " Category: #{tool.category}", :yellow
|
|
171
|
+
|
|
172
|
+
if tool.parameters[:properties]
|
|
173
|
+
say " Parameters:", :yellow
|
|
174
|
+
tool.parameters[:properties].each do |name, spec|
|
|
175
|
+
required = tool.parameters[:required]&.include?(name.to_s) ? " (required)" : ""
|
|
176
|
+
say " - #{name}: #{spec[:description]}#{required}", :white
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
say ""
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
say "Total: #{tools_to_show.size} tools\n", :green
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
no_commands do
|
|
186
|
+
def build_agent_config(config)
|
|
187
|
+
AgentConfig.new(
|
|
188
|
+
model: options[:model] || config.model,
|
|
189
|
+
permission_mode: options[:mode].to_sym,
|
|
190
|
+
allowed_tools: options[:tools],
|
|
191
|
+
max_iterations: options[:max_iterations],
|
|
192
|
+
max_cost_usd: options[:max_cost],
|
|
193
|
+
verbose: options[:verbose]
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def prompt_for_input
|
|
198
|
+
prompt = TTY::Prompt.new
|
|
199
|
+
prompt.ask("What would you like the agent to do?", required: true)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def display_agent_event(event)
|
|
203
|
+
case event[:type]
|
|
204
|
+
when :thinking
|
|
205
|
+
print "š "
|
|
206
|
+
when :assistant_message
|
|
207
|
+
# Display assistant's thinking/explanation before tool calls
|
|
208
|
+
say "\nš¬ #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
|
|
209
|
+
when :tool_call
|
|
210
|
+
display_tool_call(event[:data])
|
|
211
|
+
when :observation
|
|
212
|
+
display_tool_result(event[:data])
|
|
213
|
+
# Auto-display TODO status if exists
|
|
214
|
+
display_todo_status_if_exists
|
|
215
|
+
when :answer
|
|
216
|
+
say "\nāŗ #{event[:data][:content]}", :white if event[:data][:content] && !event[:data][:content].empty?
|
|
217
|
+
when :tool_denied
|
|
218
|
+
say "\nāŗ Tool denied: #{event[:data][:name]}", :red
|
|
219
|
+
when :tool_planned
|
|
220
|
+
say "\nāŗ Planned: #{event[:data][:name]}", :blue
|
|
221
|
+
when :tool_error
|
|
222
|
+
say "\nāŗ Error: #{event[:data][:error].message}", :red
|
|
223
|
+
when :on_iteration
|
|
224
|
+
say "\n--- Iteration #{event[:data][:iteration]} ---", :yellow if options[:verbose]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def display_tool_call(data)
|
|
229
|
+
tool_name = data[:name]
|
|
230
|
+
args_json = data[:arguments]
|
|
231
|
+
|
|
232
|
+
# Get tool instance to use its format_call method
|
|
233
|
+
tool = get_tool_instance(tool_name)
|
|
234
|
+
if tool
|
|
235
|
+
begin
|
|
236
|
+
args = JSON.parse(args_json, symbolize_names: true)
|
|
237
|
+
formatted = tool.format_call(args)
|
|
238
|
+
say "\nāŗ #{formatted}", :cyan
|
|
239
|
+
rescue JSON::ParserError, StandardError => e
|
|
240
|
+
say "ā ļø Warning: Failed to format tool call: #{e.message}", :yellow
|
|
241
|
+
say "\nāŗ #{tool_name}(...)", :cyan
|
|
242
|
+
end
|
|
243
|
+
else
|
|
244
|
+
say "ā ļø Warning: Tool instance not found for '#{tool_name}'", :yellow
|
|
245
|
+
say "\nāŗ #{tool_name}(...)", :cyan
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Show verbose details if requested
|
|
249
|
+
if options[:verbose]
|
|
250
|
+
say " Arguments: #{args_json[0..200]}", :white
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def display_tool_result(data)
|
|
255
|
+
tool_name = data[:tool]
|
|
256
|
+
result = data[:result]
|
|
257
|
+
|
|
258
|
+
# Get tool instance to use its format_result method
|
|
259
|
+
tool = get_tool_instance(tool_name)
|
|
260
|
+
if tool
|
|
261
|
+
begin
|
|
262
|
+
summary = tool.format_result(result)
|
|
263
|
+
say " āæ #{summary}", :white
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
say " āæ Done", :white
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
# Fallback for unknown tools
|
|
269
|
+
result_str = result.to_s
|
|
270
|
+
summary = result_str.length > 100 ? "#{result_str[0..100]}..." : result_str
|
|
271
|
+
say " āæ #{summary}", :white
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Show verbose details if requested
|
|
275
|
+
if options[:verbose] && result.is_a?(Hash)
|
|
276
|
+
say " #{result.inspect[0..200]}", :white
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def get_tool_instance(tool_name)
|
|
281
|
+
# Use metaprogramming to find tool class by name
|
|
282
|
+
# Convert tool_name to class name (e.g., "file_reader" -> "FileReader")
|
|
283
|
+
class_name = tool_name.split('_').map(&:capitalize).join
|
|
284
|
+
|
|
285
|
+
# Try to find the class in Clacky::Tools namespace
|
|
286
|
+
if Clacky::Tools.const_defined?(class_name)
|
|
287
|
+
tool_class = Clacky::Tools.const_get(class_name)
|
|
288
|
+
tool_class.new
|
|
289
|
+
else
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
rescue NameError
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def display_todo_status_if_exists
|
|
297
|
+
return unless @current_agent
|
|
298
|
+
|
|
299
|
+
todos = @current_agent.todos
|
|
300
|
+
return if todos.empty?
|
|
301
|
+
|
|
302
|
+
# Count statuses
|
|
303
|
+
completed = todos.count { |t| t[:status] == "completed" }
|
|
304
|
+
total = todos.size
|
|
305
|
+
|
|
306
|
+
# Build progress bar
|
|
307
|
+
progress_bar = todos.map { |t| t[:status] == "completed" ? "ā" : "ā" }.join
|
|
308
|
+
|
|
309
|
+
# Check if all completed
|
|
310
|
+
if completed == total
|
|
311
|
+
say "\nš Tasks [#{completed}/#{total}]: #{progress_bar} š All completed!", :green
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Find current and next tasks
|
|
316
|
+
current_task = todos.find { |t| t[:status] == "pending" }
|
|
317
|
+
next_task_index = todos.index(current_task)
|
|
318
|
+
next_task = next_task_index && todos[next_task_index + 1]
|
|
319
|
+
|
|
320
|
+
say "\nš Tasks [#{completed}/#{total}]: #{progress_bar}", :yellow
|
|
321
|
+
if current_task
|
|
322
|
+
say " ā Next: ##{current_task[:id]} - #{current_task[:task]}", :white
|
|
323
|
+
end
|
|
324
|
+
if next_task && next_task[:status] == "pending"
|
|
325
|
+
say " ⢠After that: ##{next_task[:id]} - #{next_task[:task]}", :white
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def display_agent_result(result)
|
|
330
|
+
say "\n" + ("=" * 60), :cyan
|
|
331
|
+
say "Agent Session Complete", :green
|
|
332
|
+
say "=" * 60, :cyan
|
|
333
|
+
say "Status: #{result[:status]}", :green
|
|
334
|
+
say "Iterations: #{result[:iterations]}", :yellow
|
|
335
|
+
say "Duration: #{result[:duration_seconds].round(2)}s", :yellow
|
|
336
|
+
say "Total Cost: $#{result[:total_cost_usd]}", :yellow
|
|
337
|
+
say "=" * 60, :cyan
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validate_working_directory(path)
|
|
341
|
+
working_dir = path || Dir.pwd
|
|
342
|
+
|
|
343
|
+
# Expand path to absolute path
|
|
344
|
+
working_dir = File.expand_path(working_dir)
|
|
345
|
+
|
|
346
|
+
# Validate directory exists
|
|
347
|
+
unless Dir.exist?(working_dir)
|
|
348
|
+
say "Error: Directory does not exist: #{working_dir}", :red
|
|
349
|
+
exit 1
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Validate it's a directory
|
|
353
|
+
unless File.directory?(working_dir)
|
|
354
|
+
say "Error: Path is not a directory: #{working_dir}", :red
|
|
355
|
+
exit 1
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
working_dir
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def run_in_directory(directory)
|
|
362
|
+
original_dir = Dir.pwd
|
|
363
|
+
|
|
364
|
+
begin
|
|
365
|
+
Dir.chdir(directory)
|
|
366
|
+
yield
|
|
367
|
+
ensure
|
|
368
|
+
Dir.chdir(original_dir)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def run_agent_interactive(agent, working_dir, agent_config, initial_message = nil, session_manager = nil)
|
|
373
|
+
# Store agent as instance variable for access in display methods
|
|
374
|
+
@current_agent = agent
|
|
375
|
+
|
|
376
|
+
# Show session info if continuing
|
|
377
|
+
if agent.total_tasks > 0
|
|
378
|
+
say "š Continuing session: #{agent.session_id[0..7]}", :green
|
|
379
|
+
say " Created: #{Time.parse(agent.created_at).strftime('%Y-%m-%d %H:%M')}", :cyan
|
|
380
|
+
say " Tasks completed: #{agent.total_tasks}", :cyan
|
|
381
|
+
say " Total cost: $#{agent.total_cost.round(4)}", :cyan
|
|
382
|
+
say ""
|
|
383
|
+
|
|
384
|
+
# Show recent conversation history
|
|
385
|
+
display_recent_messages(agent.messages, limit: 5)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
say "š¤ Starting interactive agent mode...", :green
|
|
389
|
+
say "Working directory: #{working_dir}", :cyan
|
|
390
|
+
say "Mode: #{agent_config.permission_mode}", :yellow
|
|
391
|
+
say "Max iterations: #{agent_config.max_iterations} per task", :yellow
|
|
392
|
+
say "Max cost: $#{agent_config.max_cost_usd} per task", :yellow
|
|
393
|
+
say "\nType 'exit' or 'quit' to end the session.\n", :yellow
|
|
394
|
+
|
|
395
|
+
prompt = TTY::Prompt.new
|
|
396
|
+
total_tasks = agent.total_tasks
|
|
397
|
+
total_cost = agent.total_cost
|
|
398
|
+
|
|
399
|
+
# Process initial message if provided
|
|
400
|
+
current_message = initial_message
|
|
401
|
+
|
|
402
|
+
loop do
|
|
403
|
+
# Get message from user if not provided
|
|
404
|
+
unless current_message && !current_message.strip.empty?
|
|
405
|
+
say "\n" if total_tasks > 0
|
|
406
|
+
|
|
407
|
+
# Use Readline for better Unicode/CJK support
|
|
408
|
+
current_message = Readline.readline("You: ", true)
|
|
409
|
+
|
|
410
|
+
break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
|
|
411
|
+
next if current_message.strip.empty?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
total_tasks += 1
|
|
415
|
+
say "\n"
|
|
416
|
+
|
|
417
|
+
begin
|
|
418
|
+
result = agent.run(current_message) do |event|
|
|
419
|
+
display_agent_event(event)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
total_cost += result[:total_cost_usd]
|
|
423
|
+
|
|
424
|
+
# Save session after each task with success status
|
|
425
|
+
if session_manager
|
|
426
|
+
session_manager.save(agent.to_session_data(status: :success))
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Show brief task completion
|
|
430
|
+
say "\n" + ("-" * 60), :cyan
|
|
431
|
+
say "ā Task completed", :green
|
|
432
|
+
say " Iterations: #{result[:iterations]}", :white
|
|
433
|
+
say " Cost: $#{result[:total_cost_usd].round(4)}", :white
|
|
434
|
+
say " Session total: #{total_tasks} tasks, $#{total_cost.round(4)}", :yellow
|
|
435
|
+
say "-" * 60, :cyan
|
|
436
|
+
rescue Clacky::AgentInterrupted
|
|
437
|
+
# Save session on interruption
|
|
438
|
+
if session_manager
|
|
439
|
+
session_manager.save(agent.to_session_data(status: :interrupted))
|
|
440
|
+
say "\n\nā ļø Task interrupted by user (Ctrl+C)", :yellow
|
|
441
|
+
say "You can start a new task or type 'exit' to quit.\n", :yellow
|
|
442
|
+
end
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
# Save session on error
|
|
445
|
+
if session_manager
|
|
446
|
+
session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
say "\nā Error: #{e.message}", :red
|
|
450
|
+
say e.backtrace.first(3).join("\n"), :white if options[:verbose]
|
|
451
|
+
if session_manager&.last_saved_path
|
|
452
|
+
say "š Session saved: #{session_manager.last_saved_path}", :yellow
|
|
453
|
+
end
|
|
454
|
+
say "\nYou can continue with a new task or type 'exit' to quit.", :yellow
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Clear current_message to prompt for next input
|
|
458
|
+
current_message = nil
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Save final session state
|
|
462
|
+
if session_manager
|
|
463
|
+
session_manager.save(agent.to_session_data)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
say "\nš Agent session ended", :green
|
|
467
|
+
say "Total tasks completed: #{total_tasks}", :cyan
|
|
468
|
+
say "Total cost: $#{total_cost.round(4)}", :cyan
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def list_sessions
|
|
472
|
+
session_manager = Clacky::SessionManager.new
|
|
473
|
+
working_dir = validate_working_directory(options[:path])
|
|
474
|
+
sessions = session_manager.list(current_dir: working_dir, limit: 5)
|
|
475
|
+
|
|
476
|
+
if sessions.empty?
|
|
477
|
+
say "No sessions found.", :yellow
|
|
478
|
+
return
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
say "\nš Recent sessions:\n", :green
|
|
482
|
+
sessions.each_with_index do |session, index|
|
|
483
|
+
created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
|
|
484
|
+
session_id = session[:session_id][0..7]
|
|
485
|
+
tasks = session.dig(:stats, :total_tasks) || 0
|
|
486
|
+
cost = session.dig(:stats, :total_cost_usd) || 0.0
|
|
487
|
+
first_msg = session[:first_user_message] || "No message"
|
|
488
|
+
is_current_dir = session[:working_dir] == working_dir
|
|
489
|
+
|
|
490
|
+
dir_marker = is_current_dir ? "š" : " "
|
|
491
|
+
say "#{dir_marker} #{index + 1}. [#{session_id}] #{created_at} (#{tasks} tasks, $#{cost.round(4)}) - #{first_msg}", :cyan
|
|
492
|
+
end
|
|
493
|
+
say ""
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def load_latest_session(client, agent_config, session_manager, working_dir)
|
|
497
|
+
session_data = session_manager.latest_for_directory(working_dir)
|
|
498
|
+
|
|
499
|
+
if session_data.nil?
|
|
500
|
+
say "No previous session found for this directory.", :yellow
|
|
501
|
+
return nil
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
say "Loading latest session: #{session_data[:session_id][0..7]}", :green
|
|
505
|
+
Clacky::Agent.from_session(client, agent_config, session_data)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def load_session_by_number(client, agent_config, session_manager, working_dir, number)
|
|
509
|
+
sessions = session_manager.list(current_dir: working_dir, limit: 10)
|
|
510
|
+
|
|
511
|
+
if sessions.empty?
|
|
512
|
+
say "No sessions found.", :yellow
|
|
513
|
+
return nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
index = number - 1
|
|
517
|
+
if index < 0 || index >= sessions.size
|
|
518
|
+
say "Invalid session number. Use -l to list available sessions.", :red
|
|
519
|
+
exit 1
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
session_data = sessions[index]
|
|
523
|
+
say "Loading session: #{session_data[:session_id][0..7]}", :green
|
|
524
|
+
Clacky::Agent.from_session(client, agent_config, session_data)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def display_recent_messages(messages, limit: 5)
|
|
528
|
+
# Filter out user and assistant messages (exclude system and tool messages)
|
|
529
|
+
conversation_messages = messages.select { |m| m[:role] == "user" || m[:role] == "assistant" }
|
|
530
|
+
|
|
531
|
+
# Get the last N messages
|
|
532
|
+
recent = conversation_messages.last(limit * 2) # *2 to get user+assistant pairs
|
|
533
|
+
|
|
534
|
+
if recent.empty?
|
|
535
|
+
return
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
say "š Recent conversation history:\n", :yellow
|
|
539
|
+
say "-" * 60, :white
|
|
540
|
+
|
|
541
|
+
recent.each do |msg|
|
|
542
|
+
case msg[:role]
|
|
543
|
+
when "user"
|
|
544
|
+
content = truncate_message(msg[:content], 150)
|
|
545
|
+
say "\nš¤ You: #{content}", :cyan
|
|
546
|
+
when "assistant"
|
|
547
|
+
content = truncate_message(msg[:content], 200)
|
|
548
|
+
say "š¤ Assistant: #{content}", :green
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
say "\n" + ("-" * 60), :white
|
|
553
|
+
say ""
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def truncate_message(content, max_length)
|
|
557
|
+
return "" if content.nil? || content.empty?
|
|
558
|
+
|
|
559
|
+
# Remove excessive whitespace
|
|
560
|
+
cleaned = content.strip.gsub(/\s+/, ' ')
|
|
561
|
+
|
|
562
|
+
if cleaned.length > max_length
|
|
563
|
+
cleaned[0...max_length] + "..."
|
|
564
|
+
else
|
|
565
|
+
cleaned
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
private
|
|
571
|
+
|
|
572
|
+
def send_single_message(message, config)
|
|
573
|
+
spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
|
|
574
|
+
spinner.auto_spin
|
|
575
|
+
|
|
576
|
+
client = Clacky::Client.new(config.api_key, base_url: config.base_url)
|
|
577
|
+
response = client.send_message(message, model: options[:model] || config.model)
|
|
578
|
+
|
|
579
|
+
spinner.success("Done!")
|
|
580
|
+
say "\n#{response}", :cyan
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
spinner.error("Failed!")
|
|
583
|
+
say "Error: #{e.message}", :red
|
|
584
|
+
exit 1
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def start_interactive_chat(config)
|
|
588
|
+
say "Starting interactive chat with Claude...", :green
|
|
589
|
+
say "Type 'exit' or 'quit' to end the session.\n\n", :yellow
|
|
590
|
+
|
|
591
|
+
conversation = Clacky::Conversation.new(
|
|
592
|
+
config.api_key,
|
|
593
|
+
model: options[:model] || config.model,
|
|
594
|
+
base_url: config.base_url
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
loop do
|
|
598
|
+
# Use Readline for better Unicode/CJK support
|
|
599
|
+
message = Readline.readline("You: ", true)
|
|
600
|
+
|
|
601
|
+
break if message.nil? || %w[exit quit].include?(message.downcase.strip)
|
|
602
|
+
next if message.strip.empty?
|
|
603
|
+
|
|
604
|
+
spinner = TTY::Spinner.new("[:spinner] Claude is thinking...", format: :dots)
|
|
605
|
+
spinner.auto_spin
|
|
606
|
+
|
|
607
|
+
begin
|
|
608
|
+
response = conversation.send_message(message)
|
|
609
|
+
spinner.success("Claude:")
|
|
610
|
+
say response, :cyan
|
|
611
|
+
say "\n"
|
|
612
|
+
rescue StandardError => e
|
|
613
|
+
spinner.error("Error!")
|
|
614
|
+
say "Error: #{e.message}", :red
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
say "\nGoodbye!", :green
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
class ConfigCommand < Thor
|
|
623
|
+
desc "set", "Set configuration values"
|
|
624
|
+
def set
|
|
625
|
+
prompt = TTY::Prompt.new
|
|
626
|
+
|
|
627
|
+
config = Clacky::Config.load
|
|
628
|
+
|
|
629
|
+
# API Key
|
|
630
|
+
api_key = prompt.mask("Enter your Claude API key:")
|
|
631
|
+
config.api_key = api_key
|
|
632
|
+
|
|
633
|
+
# Model
|
|
634
|
+
model = prompt.ask("Enter model:", default: config.model)
|
|
635
|
+
config.model = model
|
|
636
|
+
|
|
637
|
+
# Base URL
|
|
638
|
+
base_url = prompt.ask("Enter base URL:", default: config.base_url)
|
|
639
|
+
config.base_url = base_url
|
|
640
|
+
|
|
641
|
+
config.save
|
|
642
|
+
|
|
643
|
+
say "\nConfiguration saved successfully!", :green
|
|
644
|
+
say "API Key: #{api_key[0..7]}#{'*' * 20}#{api_key[-4..]}", :cyan
|
|
645
|
+
say "Model: #{config.model}", :cyan
|
|
646
|
+
say "Base URL: #{config.base_url}", :cyan
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
desc "show", "Show current configuration"
|
|
650
|
+
def show
|
|
651
|
+
config = Clacky::Config.load
|
|
652
|
+
|
|
653
|
+
if config.api_key
|
|
654
|
+
masked_key = config.api_key[0..7] + ("*" * 20) + config.api_key[-4..]
|
|
655
|
+
say "API Key: #{masked_key}", :cyan
|
|
656
|
+
say "Model: #{config.model}", :cyan
|
|
657
|
+
say "Base URL: #{config.base_url}", :cyan
|
|
658
|
+
else
|
|
659
|
+
say "No configuration found. Run 'clacky config set' to configure.", :yellow
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Register subcommands after all classes are defined
|
|
665
|
+
CLI.register(ConfigCommand, "config", "config SUBCOMMAND", "Manage configuration")
|
|
666
|
+
end
|