openclacky 0.5.1

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