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.
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