consolle 0.2.6

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,718 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+ require "json"
6
+ require "socket"
7
+ require "timeout"
8
+ require "securerandom"
9
+ require "date"
10
+ require_relative "adapters/rails_console"
11
+
12
+ module Consolle
13
+ class CLI < Thor
14
+ class_option :verbose, type: :boolean, aliases: "-v", desc: "Verbose output"
15
+ class_option :target, type: :string, aliases: "-t", desc: "Target session name", default: "cone"
16
+
17
+ def self.exit_on_failure?
18
+ true
19
+ end
20
+
21
+ # Override invoke_command to handle --help flag for subcommands
22
+ no_commands do
23
+ def invoke_command(command, *args)
24
+ # Check if --help or -h is in the original arguments
25
+ if ARGV.include?("--help") || ARGV.include?("-h")
26
+ # Show help for the command
27
+ self.class.command_help(shell, command.name)
28
+ return
29
+ end
30
+
31
+ # Call original invoke_command
32
+ super
33
+ end
34
+ end
35
+
36
+
37
+ default_task :default
38
+
39
+ desc "default", "Start console"
40
+ def default
41
+ start
42
+ end
43
+
44
+ desc "version", "Show consolle version"
45
+ def version
46
+ puts "Consolle version #{Consolle::VERSION}"
47
+ end
48
+
49
+ desc "rule FILE", "Write cone command guide to FILE"
50
+ def rule(file_path)
51
+ # Read the embedded rule content
52
+ rule_content = File.read(File.expand_path("../../../rule.md", __FILE__))
53
+
54
+ # Write to the specified file
55
+ File.write(file_path, rule_content)
56
+ puts "✓ Cone command guide written to #{file_path}"
57
+ rescue => e
58
+ puts "✗ Failed to write rule file: #{e.message}"
59
+ exit 1
60
+ end
61
+
62
+ desc "start", "Start Rails console in background"
63
+ method_option :rails_env, type: :string, aliases: "-e", desc: "Rails environment", default: "development"
64
+ method_option :command, type: :string, aliases: "-c", desc: "Custom console command", default: "bin/rails console"
65
+ def start
66
+ ensure_rails_project!
67
+ ensure_project_directories
68
+ validate_session_name!(options[:target])
69
+
70
+ # Check if already running using session info
71
+ session_info = load_session_info
72
+ if session_info && session_info[:process_pid]
73
+ # Check if process is still running
74
+ begin
75
+ Process.kill(0, session_info[:process_pid])
76
+ # Check if socket is ready
77
+ if File.exist?(session_info[:socket_path])
78
+ puts "Rails console is already running (PID: #{session_info[:process_pid]})"
79
+ puts "Socket: #{session_info[:socket_path]}"
80
+ return
81
+ end
82
+ rescue Errno::ESRCH
83
+ # Process not found, clean up session
84
+ clear_session_info
85
+ end
86
+ elsif session_info
87
+ # Session file exists but no valid PID, clean up
88
+ clear_session_info
89
+ end
90
+
91
+ adapter = create_rails_adapter(options[:rails_env], options[:target], options[:command])
92
+
93
+ puts "Starting Rails console..."
94
+
95
+ begin
96
+ adapter.start
97
+ puts "✓ Rails console started successfully"
98
+ puts " PID: #{adapter.process_pid}"
99
+ puts " Socket: #{adapter.socket_path}"
100
+
101
+ # Save session info
102
+ save_session_info(adapter)
103
+
104
+ # Log session start
105
+ log_session_event(adapter.process_pid, "session_start", {
106
+ rails_env: options[:rails_env],
107
+ socket_path: adapter.socket_path
108
+ })
109
+ rescue StandardError => e
110
+ puts "✗ Failed to start Rails console: #{e.message}"
111
+ exit 1
112
+ end
113
+ end
114
+
115
+ desc "status", "Show Rails console status"
116
+ def status
117
+ ensure_rails_project!
118
+ validate_session_name!(options[:target])
119
+
120
+ session_info = load_session_info
121
+
122
+ if session_info.nil?
123
+ puts "No active Rails console session found"
124
+ return
125
+ end
126
+
127
+ # Check if server is actually responsive
128
+ adapter = create_rails_adapter("development", options[:target])
129
+ server_status = adapter.get_status rescue nil
130
+ process_running = server_status && server_status["success"] && server_status["running"]
131
+
132
+ if process_running
133
+ rails_env = server_status["rails_env"] || "unknown"
134
+ console_pid = server_status["pid"] || "unknown"
135
+
136
+ puts "✓ Rails console is running"
137
+ puts " PID: #{console_pid}"
138
+ puts " Environment: #{rails_env}"
139
+ puts " Session: #{session_info[:socket_path]}"
140
+ puts " Ready for input: Yes"
141
+ else
142
+ puts "✗ Rails console is not running"
143
+ clear_session_info
144
+ end
145
+ end
146
+
147
+ desc "ls", "List active Rails console sessions"
148
+ def ls
149
+ ensure_rails_project!
150
+
151
+ sessions = load_sessions
152
+
153
+ if sessions.empty? || sessions.size == 1 && sessions.key?("_schema")
154
+ puts "No active sessions"
155
+ return
156
+ end
157
+
158
+ active_sessions = []
159
+ stale_sessions = []
160
+
161
+ sessions.each do |name, info|
162
+ next if name == "_schema" # Skip schema field
163
+
164
+ # Check if process is alive
165
+ if info["process_pid"] && process_alive?(info["process_pid"])
166
+ # Try to get server status
167
+ adapter = create_rails_adapter("development", name)
168
+ server_status = adapter.get_status rescue nil
169
+
170
+ if server_status && server_status["success"] && server_status["running"]
171
+ rails_env = server_status["rails_env"] || "development"
172
+ console_pid = server_status["pid"] || info["process_pid"]
173
+ active_sessions << "#{name} (#{rails_env}) - PID: #{console_pid}"
174
+ else
175
+ stale_sessions << name
176
+ end
177
+ else
178
+ stale_sessions << name
179
+ end
180
+ end
181
+
182
+ # Clean up stale sessions
183
+ if stale_sessions.any?
184
+ with_sessions_lock do
185
+ sessions = load_sessions
186
+ stale_sessions.each { |name| sessions.delete(name) }
187
+ save_sessions(sessions)
188
+ end
189
+ end
190
+
191
+ if active_sessions.empty?
192
+ puts "No active sessions"
193
+ else
194
+ active_sessions.each { |session| puts session }
195
+ end
196
+ end
197
+
198
+ desc "stop", "Stop Rails console"
199
+ def stop
200
+ ensure_rails_project!
201
+ validate_session_name!(options[:target])
202
+
203
+ adapter = create_rails_adapter("development", options[:target])
204
+
205
+ if adapter.running?
206
+ puts "Stopping Rails console..."
207
+
208
+ if adapter.stop
209
+ puts "✓ Rails console stopped"
210
+
211
+ # Log session stop
212
+ session_info = load_session_info
213
+ if session_info && session_info[:process_pid]
214
+ log_session_event(session_info[:process_pid], "session_stop", {
215
+ reason: "user_requested"
216
+ })
217
+ end
218
+ else
219
+ puts "✗ Failed to stop Rails console"
220
+ end
221
+ else
222
+ puts "Rails console is not running"
223
+ end
224
+
225
+ clear_session_info
226
+ end
227
+
228
+ desc "stop_all", "Stop all Rails console sessions"
229
+ map ["stop-all"] => :stop_all
230
+ def stop_all
231
+ ensure_rails_project!
232
+
233
+ sessions = load_sessions
234
+ active_sessions = []
235
+
236
+ # Filter active sessions (excluding schema)
237
+ sessions.each do |name, info|
238
+ next if name == "_schema"
239
+ if info["process_pid"] && process_alive?(info["process_pid"])
240
+ active_sessions << { name: name, info: info }
241
+ end
242
+ end
243
+
244
+ if active_sessions.empty?
245
+ puts "No active sessions to stop"
246
+ return
247
+ end
248
+
249
+ puts "Found #{active_sessions.size} active session(s)"
250
+
251
+ # Stop each active session
252
+ active_sessions.each do |session|
253
+ name = session[:name]
254
+ info = session[:info]
255
+
256
+ puts "\nStopping session '#{name}'..."
257
+
258
+ adapter = create_rails_adapter("development", name)
259
+
260
+ if adapter.stop
261
+ puts "✓ Session '#{name}' stopped"
262
+
263
+ # Log session stop
264
+ if info["process_pid"]
265
+ log_session_event(info["process_pid"], "session_stop", {
266
+ reason: "stop_all_requested"
267
+ })
268
+ end
269
+
270
+ # Clear session info
271
+ with_sessions_lock do
272
+ sessions = load_sessions
273
+ sessions.delete(name)
274
+ save_sessions(sessions)
275
+ end
276
+ else
277
+ puts "✗ Failed to stop session '#{name}'"
278
+ end
279
+ end
280
+
281
+ puts "\n✓ All sessions stopped"
282
+ end
283
+
284
+ desc "restart", "Restart Rails console"
285
+ method_option :rails_env, type: :string, aliases: "-e", desc: "Rails environment", default: "development"
286
+ method_option :force, type: :boolean, aliases: "-f", desc: "Force restart the entire server"
287
+ def restart
288
+ ensure_rails_project!
289
+ validate_session_name!(options[:target])
290
+
291
+ adapter = create_rails_adapter(options[:rails_env], options[:target])
292
+
293
+ if adapter.running?
294
+ # Check if environment needs to be changed
295
+ current_status = adapter.get_status rescue nil
296
+ current_env = current_status&.dig("rails_env") || "development"
297
+ needs_full_restart = options[:force] || (current_env != options[:rails_env])
298
+
299
+ if needs_full_restart
300
+ if current_env != options[:rails_env]
301
+ puts "Environment change detected (#{current_env} -> #{options[:rails_env]})"
302
+ puts "Performing full server restart..."
303
+ else
304
+ puts "Force restarting Rails console server..."
305
+ end
306
+
307
+ # Save current rails_env for start command
308
+ old_env = @rails_env
309
+ @rails_env = options[:rails_env]
310
+
311
+ stop
312
+ sleep 1
313
+ invoke(:start, [], { rails_env: options[:rails_env] })
314
+
315
+ @rails_env = old_env
316
+ else
317
+ puts "Restarting Rails console subprocess..."
318
+
319
+ # Send restart request to the socket server
320
+ request = {
321
+ "action" => "restart",
322
+ "request_id" => SecureRandom.uuid
323
+ }
324
+
325
+ begin
326
+ # Use direct socket connection for restart request
327
+ socket = UNIXSocket.new(adapter.socket_path)
328
+ socket.write(JSON.generate(request))
329
+ socket.write("\n")
330
+ socket.flush
331
+ response_data = socket.gets
332
+ socket.close
333
+
334
+ response = JSON.parse(response_data)
335
+
336
+ if response["success"]
337
+ puts "✓ Rails console subprocess restarted"
338
+ puts " New PID: #{response["pid"]}" if response["pid"]
339
+ else
340
+ puts "✗ Failed to restart: #{response["message"]}"
341
+ puts "You can try 'cone restart --force' to restart the entire server"
342
+ end
343
+ rescue StandardError => e
344
+ puts "✗ Error restarting: #{e.message}"
345
+ puts "You can try 'cone restart --force' to restart the entire server"
346
+ end
347
+ end
348
+ else
349
+ puts "Rails console is not running. Starting it..."
350
+ invoke(:start, [], { rails_env: options[:rails_env] })
351
+ end
352
+ end
353
+
354
+ desc "exec CODE", "Execute Ruby code in Rails console"
355
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "Timeout in seconds", default: 15
356
+ method_option :file, type: :string, aliases: "-f", desc: "Read Ruby code from FILE"
357
+ method_option :raw, type: :boolean, desc: "Do not apply escape fixes for Claude Code (keep \\! as is)"
358
+ def exec(*code_parts)
359
+ ensure_rails_project!
360
+ ensure_project_directories
361
+ validate_session_name!(options[:target])
362
+
363
+ # Handle code input from file or arguments first
364
+ code = if options[:file]
365
+ path = File.expand_path(options[:file])
366
+ unless File.file?(path)
367
+ puts "Error: File not found: #{path}"
368
+ exit 1
369
+ end
370
+ File.read(path, mode: "r:UTF-8")
371
+ else
372
+ code_parts.join(" ")
373
+ end
374
+
375
+ if code.strip.empty?
376
+ puts "Error: No code provided (pass CODE or use -f FILE)"
377
+ exit 1
378
+ end
379
+
380
+ session_info = load_session_info
381
+ server_running = false
382
+
383
+ # Check if server is running
384
+ if session_info
385
+ begin
386
+ # Try to connect to socket and get status
387
+ socket = UNIXSocket.new(session_info[:socket_path])
388
+ request = {
389
+ "action" => "status",
390
+ "request_id" => SecureRandom.uuid
391
+ }
392
+ socket.write(JSON.generate(request))
393
+ socket.write("\n")
394
+ socket.flush
395
+ response_data = socket.gets
396
+ socket.close
397
+
398
+ response = JSON.parse(response_data)
399
+ server_running = response["success"] && response["running"]
400
+ rescue StandardError
401
+ # Server not responsive
402
+ server_running = false
403
+ end
404
+ end
405
+
406
+ # Check if server is running
407
+ unless server_running
408
+ puts "✗ Rails console is not running"
409
+ puts "Please start it first with: cone start"
410
+ exit 1
411
+ end
412
+
413
+ # Apply Claude Code escape fix unless --raw option is specified
414
+ unless options[:raw]
415
+ code = code.gsub('\\!', '!')
416
+ end
417
+
418
+ puts "Executing: #{code}" if options[:verbose]
419
+
420
+ # Send code to socket
421
+ result = send_code_to_socket(session_info[:socket_path], code, timeout: options[:timeout])
422
+
423
+ # Log the request and response
424
+ log_session_activity(session_info[:process_pid], code, result)
425
+
426
+ if result["success"]
427
+ # Always print result, even if empty (multiline code often returns empty string)
428
+ puts result["result"] unless result["result"].nil?
429
+ puts "Execution time: #{result["execution_time"]}s" if options[:verbose] && result["execution_time"]
430
+ else
431
+ puts "Error: #{result["error"]}"
432
+ puts result["message"]
433
+ puts result["backtrace"]&.join("\n") if options[:verbose]
434
+ exit 1
435
+ end
436
+ end
437
+
438
+ private
439
+
440
+ def ensure_rails_project!
441
+ unless File.exist?("config/environment.rb") || File.exist?("config/application.rb")
442
+ puts "Error: This command must be run from a Rails project root directory"
443
+ exit 1
444
+ end
445
+ end
446
+
447
+ def ensure_project_directories
448
+ # Create tmp/cone directory for socket
449
+ socket_dir = File.join(Dir.pwd, "tmp", "cone")
450
+ FileUtils.mkdir_p(socket_dir) unless Dir.exist?(socket_dir)
451
+
452
+ # Create session directory based on PWD
453
+ session_dir = project_session_dir
454
+ FileUtils.mkdir_p(session_dir) unless Dir.exist?(session_dir)
455
+ end
456
+
457
+ def project_session_dir
458
+ # Convert PWD to directory name (Claude Code style)
459
+ pwd_as_dirname = Dir.pwd.gsub("/", "-")
460
+ File.expand_path("~/.cone/sessions/#{pwd_as_dirname}")
461
+ end
462
+
463
+ def project_socket_path(target = nil)
464
+ target ||= options[:target]
465
+ File.join(Dir.pwd, "tmp", "cone", "#{target}.socket")
466
+ end
467
+
468
+ def project_pid_path(target = nil)
469
+ target ||= options[:target]
470
+ File.join(Dir.pwd, "tmp", "cone", "#{target}.pid")
471
+ end
472
+
473
+ def project_log_path(target = nil)
474
+ target ||= options[:target]
475
+ File.join(Dir.pwd, "tmp", "cone", "#{target}.log")
476
+ end
477
+
478
+ def send_code_to_socket(socket_path, code, timeout: 15)
479
+ request_id = SecureRandom.uuid
480
+ request = {
481
+ "action" => "eval",
482
+ "code" => code,
483
+ "timeout" => timeout,
484
+ "request_id" => request_id
485
+ }
486
+
487
+ Timeout.timeout(timeout + 5) do
488
+ socket = UNIXSocket.new(socket_path)
489
+
490
+ # Send request as single line JSON
491
+ socket.write(JSON.generate(request))
492
+ socket.write("\n")
493
+ socket.flush
494
+
495
+ # Read response
496
+ response_data = socket.gets
497
+ socket.close
498
+
499
+ JSON.parse(response_data)
500
+ end
501
+ rescue Timeout::Error
502
+ { "success" => false, "error" => "Timeout", "message" => "Request timed out after #{timeout} seconds" }
503
+ rescue StandardError => e
504
+ { "success" => false, "error" => e.class.name, "message" => e.message }
505
+ end
506
+
507
+ def sessions_file_path
508
+ File.join(Dir.pwd, "tmp", "cone", "sessions.json")
509
+ end
510
+
511
+ def create_rails_adapter(rails_env = "development", target = nil, command = nil)
512
+ target ||= options[:target]
513
+
514
+ Consolle::Adapters::RailsConsole.new(
515
+ socket_path: project_socket_path(target),
516
+ pid_path: project_pid_path(target),
517
+ log_path: project_log_path(target),
518
+ rails_root: Dir.pwd,
519
+ rails_env: rails_env,
520
+ verbose: options[:verbose],
521
+ command: command
522
+ )
523
+ end
524
+
525
+ def save_session_info(adapter)
526
+ target = options[:target]
527
+
528
+ with_sessions_lock do
529
+ sessions = load_sessions
530
+
531
+ sessions[target] = {
532
+ "socket_path" => adapter.socket_path,
533
+ "process_pid" => adapter.process_pid,
534
+ "pid_path" => project_pid_path(target),
535
+ "log_path" => project_log_path(target),
536
+ "started_at" => Time.now.to_f,
537
+ "rails_root" => Dir.pwd
538
+ }
539
+
540
+ save_sessions(sessions)
541
+ end
542
+ end
543
+
544
+ def load_session_info
545
+ target = options[:target]
546
+ sessions = load_sessions
547
+
548
+ return nil if sessions.empty?
549
+
550
+ session = sessions[target]
551
+ return nil unless session
552
+
553
+ # Convert to symbolized keys for backward compatibility
554
+ {
555
+ socket_path: session["socket_path"],
556
+ process_pid: session["process_pid"],
557
+ started_at: session["started_at"],
558
+ rails_root: session["rails_root"]
559
+ }
560
+ end
561
+
562
+ def clear_session_info
563
+ target = options[:target]
564
+
565
+ with_sessions_lock do
566
+ sessions = load_sessions
567
+ sessions.delete(target)
568
+ save_sessions(sessions)
569
+ end
570
+ end
571
+
572
+ def log_session_activity(process_pid, code, result)
573
+ # Create log filename based on date and PID
574
+ log_file = File.join(project_session_dir, "session_#{Date.today.strftime('%Y%m%d')}_pid#{process_pid}.log")
575
+
576
+ # Create log entry
577
+ log_entry = {
578
+ timestamp: Time.now.iso8601,
579
+ request_id: result["request_id"],
580
+ code: code,
581
+ success: result["success"],
582
+ result: result["result"],
583
+ error: result["error"],
584
+ message: result["message"],
585
+ execution_time: result["execution_time"]
586
+ }
587
+
588
+ # Append to log file
589
+ File.open(log_file, "a") do |f|
590
+ f.puts JSON.generate(log_entry)
591
+ end
592
+ rescue StandardError => e
593
+ # Log errors should not crash the command
594
+ puts "Warning: Failed to log session activity: #{e.message}" if options[:verbose]
595
+ end
596
+
597
+ def log_session_event(process_pid, event_type, details = {})
598
+ # Create log filename based on date and PID
599
+ log_file = File.join(project_session_dir, "session_#{Date.today.strftime('%Y%m%d')}_pid#{process_pid}.log")
600
+
601
+ # Create log entry
602
+ log_entry = {
603
+ timestamp: Time.now.iso8601,
604
+ event: event_type
605
+ }.merge(details)
606
+
607
+ # Append to log file
608
+ File.open(log_file, "a") do |f|
609
+ f.puts JSON.generate(log_entry)
610
+ end
611
+ rescue StandardError => e
612
+ # Log errors should not crash the command
613
+ puts "Warning: Failed to log session event: #{e.message}" if options[:verbose]
614
+ end
615
+
616
+ def load_sessions
617
+ # Check for legacy session.json file first
618
+ legacy_file = File.join(Dir.pwd, "tmp", "cone", "session.json")
619
+ if File.exist?(legacy_file) && !File.exist?(sessions_file_path)
620
+ # Migrate from old format
621
+ migrate_legacy_session(legacy_file)
622
+ end
623
+
624
+ return {} unless File.exist?(sessions_file_path)
625
+
626
+ json = JSON.parse(File.read(sessions_file_path))
627
+
628
+ # Handle backward compatibility with old single-session format
629
+ if json.key?("socket_path")
630
+ # Legacy single session format - convert to new format
631
+ { "cone" => json }
632
+ else
633
+ # New multi-session format
634
+ json
635
+ end
636
+ rescue JSON::ParserError, Errno::ENOENT
637
+ {}
638
+ end
639
+
640
+ def migrate_legacy_session(legacy_file)
641
+ legacy_data = JSON.parse(File.read(legacy_file))
642
+
643
+ # Convert to new format
644
+ new_sessions = {
645
+ "_schema" => 1,
646
+ "cone" => legacy_data
647
+ }
648
+
649
+ # Write new format
650
+ File.write(sessions_file_path, JSON.pretty_generate(new_sessions))
651
+
652
+ # Remove old file
653
+ File.delete(legacy_file)
654
+
655
+ puts "Migrated session data to new multi-session format" if options[:verbose]
656
+ rescue StandardError => e
657
+ puts "Warning: Failed to migrate legacy session: #{e.message}" if options[:verbose]
658
+ end
659
+
660
+ def save_sessions(sessions)
661
+ # Add schema version for future migrations
662
+ sessions_with_schema = { "_schema" => 1 }.merge(sessions)
663
+
664
+ # Write to temp file first for atomicity - use PID to avoid conflicts
665
+ temp_path = "#{sessions_file_path}.tmp.#{Process.pid}"
666
+ File.write(temp_path, JSON.pretty_generate(sessions_with_schema))
667
+
668
+ # Atomic rename - will overwrite existing file
669
+ File.rename(temp_path, sessions_file_path)
670
+ rescue StandardError => e
671
+ # Clean up temp file if rename fails
672
+ File.unlink(temp_path) if File.exist?(temp_path)
673
+ raise e
674
+ end
675
+
676
+ def with_sessions_lock(&block)
677
+ # Ensure directory exists
678
+ FileUtils.mkdir_p(File.dirname(sessions_file_path))
679
+
680
+ # Create lock file separate from sessions file to avoid issues
681
+ lock_file_path = "#{sessions_file_path}.lock"
682
+
683
+ # Use file locking to prevent concurrent access
684
+ File.open(lock_file_path, File::RDWR | File::CREAT, 0644) do |f|
685
+ f.flock(File::LOCK_EX)
686
+
687
+ # Execute the block
688
+ yield
689
+ ensure
690
+ f.flock(File::LOCK_UN)
691
+ end
692
+ end
693
+
694
+ def process_alive?(pid)
695
+ return false unless pid
696
+
697
+ Process.kill(0, pid)
698
+ true
699
+ rescue Errno::ESRCH, Errno::EPERM
700
+ false
701
+ end
702
+
703
+ def validate_session_name!(name)
704
+ # Allow alphanumeric, hyphen, and underscore only
705
+ unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
706
+ puts "Error: Invalid session name '#{name}'"
707
+ puts "Session names can only contain letters, numbers, hyphens (-), and underscores (_)"
708
+ exit 1
709
+ end
710
+
711
+ # Check length (reasonable limit)
712
+ if name.length > 50
713
+ puts "Error: Session name is too long (maximum 50 characters)"
714
+ exit 1
715
+ end
716
+ end
717
+ end
718
+ end