binocs 0.1.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +528 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/javascripts/binocs/application.js +105 -0
  6. data/app/assets/stylesheets/binocs/application.css +67 -0
  7. data/app/channels/binocs/application_cable/channel.rb +8 -0
  8. data/app/channels/binocs/application_cable/connection.rb +8 -0
  9. data/app/channels/binocs/requests_channel.rb +13 -0
  10. data/app/controllers/binocs/application_controller.rb +62 -0
  11. data/app/controllers/binocs/requests_controller.rb +69 -0
  12. data/app/helpers/binocs/application_helper.rb +61 -0
  13. data/app/models/binocs/application_record.rb +7 -0
  14. data/app/models/binocs/request.rb +198 -0
  15. data/app/views/binocs/requests/_empty_list.html.erb +9 -0
  16. data/app/views/binocs/requests/_request.html.erb +61 -0
  17. data/app/views/binocs/requests/index.html.erb +115 -0
  18. data/app/views/binocs/requests/show.html.erb +227 -0
  19. data/app/views/layouts/binocs/application.html.erb +109 -0
  20. data/config/importmap.rb +6 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
  23. data/exe/binocs +86 -0
  24. data/lib/binocs/agent.rb +153 -0
  25. data/lib/binocs/agent_context.rb +165 -0
  26. data/lib/binocs/agent_manager.rb +302 -0
  27. data/lib/binocs/configuration.rb +65 -0
  28. data/lib/binocs/engine.rb +61 -0
  29. data/lib/binocs/log_subscriber.rb +56 -0
  30. data/lib/binocs/middleware/request_recorder.rb +264 -0
  31. data/lib/binocs/swagger/client.rb +100 -0
  32. data/lib/binocs/swagger/path_matcher.rb +118 -0
  33. data/lib/binocs/tui/agent_output.rb +163 -0
  34. data/lib/binocs/tui/agents_list.rb +195 -0
  35. data/lib/binocs/tui/app.rb +726 -0
  36. data/lib/binocs/tui/colors.rb +115 -0
  37. data/lib/binocs/tui/filter_menu.rb +162 -0
  38. data/lib/binocs/tui/help_screen.rb +93 -0
  39. data/lib/binocs/tui/request_detail.rb +899 -0
  40. data/lib/binocs/tui/request_list.rb +268 -0
  41. data/lib/binocs/tui/spirit_animal.rb +235 -0
  42. data/lib/binocs/tui/window.rb +98 -0
  43. data/lib/binocs/tui.rb +24 -0
  44. data/lib/binocs/version.rb +5 -0
  45. data/lib/binocs.rb +27 -0
  46. data/lib/generators/binocs/install/install_generator.rb +61 -0
  47. data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
  48. data/lib/generators/binocs/install/templates/initializer.rb +25 -0
  49. data/lib/tasks/binocs_tasks.rake +38 -0
  50. metadata +149 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'shellwords'
5
+
6
+ module Binocs
7
+ class Agent
8
+ STATUSES = %i[pending running completed failed stopped].freeze
9
+ TOOLS = %i[claude_code opencode].freeze
10
+
11
+ attr_accessor :id, :status, :tool, :worktree_path, :pid, :request_id,
12
+ :prompt, :created_at, :output_file, :branch_name,
13
+ :request_context, :exit_code
14
+
15
+ @@agents = []
16
+ @@mutex = Mutex.new
17
+
18
+ def initialize(attrs = {})
19
+ @id = attrs[:id] || SecureRandom.uuid[0, 8]
20
+ @status = attrs[:status] || :pending
21
+ @tool = attrs[:tool] || Binocs.configuration.agent_tool
22
+ @worktree_path = attrs[:worktree_path]
23
+ @pid = attrs[:pid]
24
+ @request_id = attrs[:request_id]
25
+ @prompt = attrs[:prompt]
26
+ @created_at = attrs[:created_at] || Time.now
27
+ @output_file = attrs[:output_file]
28
+ @branch_name = attrs[:branch_name]
29
+ @request_context = attrs[:request_context]
30
+ @exit_code = nil
31
+ end
32
+
33
+ def running?
34
+ @status == :running && @pid && process_alive?
35
+ end
36
+
37
+ def completed?
38
+ @status == :completed
39
+ end
40
+
41
+ def failed?
42
+ @status == :failed
43
+ end
44
+
45
+ def stopped?
46
+ @status == :stopped
47
+ end
48
+
49
+ def process_alive?
50
+ return false unless @pid
51
+
52
+ Process.kill(0, @pid)
53
+ true
54
+ rescue Errno::ESRCH, Errno::EPERM
55
+ false
56
+ end
57
+
58
+ def stop!
59
+ return unless running?
60
+
61
+ begin
62
+ Process.kill('TERM', @pid)
63
+ sleep 0.5
64
+ Process.kill('KILL', @pid) if process_alive?
65
+ rescue Errno::ESRCH, Errno::EPERM
66
+ # Process already dead
67
+ end
68
+
69
+ @status = :stopped
70
+ end
71
+
72
+ def output
73
+ return '' unless @output_file && File.exist?(@output_file)
74
+
75
+ File.read(@output_file)
76
+ end
77
+
78
+ def output_tail(lines = 50)
79
+ return '' unless @output_file && File.exist?(@output_file)
80
+
81
+ `tail -n #{lines} #{@output_file.shellescape}`.strip
82
+ end
83
+
84
+ def duration
85
+ return nil unless @created_at
86
+
87
+ elapsed = Time.now - @created_at
88
+ if elapsed < 60
89
+ "#{elapsed.to_i}s"
90
+ elsif elapsed < 3600
91
+ "#{(elapsed / 60).to_i}m"
92
+ else
93
+ "#{(elapsed / 3600).to_i}h #{((elapsed % 3600) / 60).to_i}m"
94
+ end
95
+ end
96
+
97
+ def tool_command
98
+ case @tool
99
+ when :claude_code then 'claude'
100
+ when :opencode then 'opencode'
101
+ else 'claude'
102
+ end
103
+ end
104
+
105
+ def short_prompt(max_length = 50)
106
+ return '' unless @prompt
107
+
108
+ @prompt.length > max_length ? "#{@prompt[0, max_length - 3]}..." : @prompt
109
+ end
110
+
111
+ # Class methods for agent registry
112
+ class << self
113
+ def all
114
+ @@mutex.synchronize { @@agents.dup }
115
+ end
116
+
117
+ def running
118
+ all.select(&:running?)
119
+ end
120
+
121
+ def find(id)
122
+ @@mutex.synchronize { @@agents.find { |a| a.id == id } }
123
+ end
124
+
125
+ def add(agent)
126
+ @@mutex.synchronize { @@agents << agent }
127
+ agent
128
+ end
129
+
130
+ def remove(agent)
131
+ @@mutex.synchronize { @@agents.delete(agent) }
132
+ end
133
+
134
+ def for_request(request_id)
135
+ all.select { |a| a.request_id == request_id }
136
+ end
137
+
138
+ def count
139
+ @@mutex.synchronize { @@agents.length }
140
+ end
141
+
142
+ def running_count
143
+ running.length
144
+ end
145
+
146
+ def clear_completed
147
+ @@mutex.synchronize do
148
+ @@agents.reject! { |a| a.completed? || a.failed? || a.stopped? }
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Binocs
6
+ class AgentContext
7
+ class << self
8
+ def build(request)
9
+ sections = []
10
+
11
+ sections << build_overview(request)
12
+ sections << build_params(request)
13
+ sections << build_headers(request)
14
+ sections << build_body(request)
15
+ sections << build_response(request)
16
+ sections << build_logs(request)
17
+ sections << build_exception(request)
18
+
19
+ sections.compact.join("\n\n")
20
+ end
21
+
22
+ private
23
+
24
+ def build_overview(request)
25
+ # Use read_attribute for 'method' to avoid conflict with Object#method
26
+ http_method = request.respond_to?(:read_attribute) ? request.read_attribute(:method) : request.method
27
+ <<~SECTION
28
+ ## Request Overview
29
+
30
+ - **Method**: #{http_method || 'N/A'}
31
+ - **Path**: #{request.path || 'N/A'}
32
+ - **Full URL**: #{request.try(:full_url) || 'N/A'}
33
+ - **Controller**: #{request.controller_name || 'N/A'}
34
+ - **Action**: #{request.action_name || 'N/A'}
35
+ - **Status Code**: #{request.status_code || 'N/A'}
36
+ - **Duration**: #{request.try(:formatted_duration) || 'N/A'}
37
+ - **IP Address**: #{request.ip_address || 'N/A'}
38
+ - **Timestamp**: #{request.created_at&.iso8601 || 'N/A'}
39
+ SECTION
40
+ end
41
+
42
+ def build_params(request)
43
+ params = request.params
44
+ return nil if params.blank?
45
+
46
+ <<~SECTION
47
+ ## Request Parameters
48
+
49
+ ```json
50
+ #{JSON.pretty_generate(params)}
51
+ ```
52
+ SECTION
53
+ end
54
+
55
+ def build_headers(request)
56
+ sections = []
57
+
58
+ req_headers = request.request_headers
59
+ if req_headers.present?
60
+ sections << <<~SECTION
61
+ ## Request Headers
62
+
63
+ ```json
64
+ #{JSON.pretty_generate(req_headers)}
65
+ ```
66
+ SECTION
67
+ end
68
+
69
+ res_headers = request.response_headers
70
+ if res_headers.present?
71
+ sections << <<~SECTION
72
+ ## Response Headers
73
+
74
+ ```json
75
+ #{JSON.pretty_generate(res_headers)}
76
+ ```
77
+ SECTION
78
+ end
79
+
80
+ sections.any? ? sections.join("\n\n") : nil
81
+ end
82
+
83
+ def build_body(request)
84
+ body = request.request_body
85
+ return nil if body.blank?
86
+
87
+ formatted_body = format_body(body)
88
+
89
+ <<~SECTION
90
+ ## Request Body
91
+
92
+ ```
93
+ #{formatted_body}
94
+ ```
95
+ SECTION
96
+ end
97
+
98
+ def build_response(request)
99
+ body = request.response_body
100
+ return nil if body.blank?
101
+
102
+ formatted_body = format_body(body)
103
+
104
+ <<~SECTION
105
+ ## Response Body
106
+
107
+ ```
108
+ #{formatted_body}
109
+ ```
110
+ SECTION
111
+ end
112
+
113
+ def build_logs(request)
114
+ logs = request.logs
115
+ return nil if logs.blank?
116
+
117
+ log_lines = logs.map do |log|
118
+ case log['type']
119
+ when 'controller'
120
+ "[#{log['timestamp']}] #{log['controller']}##{log['action']} - #{log['duration']}ms"
121
+ when 'redirect'
122
+ "[#{log['timestamp']}] Redirect to #{log['location']} (#{log['status']})"
123
+ else
124
+ "[#{log['timestamp']}] #{log['type']}: #{log.except('timestamp', 'type').to_json}"
125
+ end
126
+ end
127
+
128
+ <<~SECTION
129
+ ## Request Logs
130
+
131
+ ```
132
+ #{log_lines.join("\n")}
133
+ ```
134
+ SECTION
135
+ end
136
+
137
+ def build_exception(request)
138
+ exc = request.exception
139
+ return nil if exc.blank?
140
+
141
+ backtrace = exc['backtrace']&.first(15)&.join("\n") || 'No backtrace'
142
+
143
+ <<~SECTION
144
+ ## Exception
145
+
146
+ - **Class**: #{exc['class']}
147
+ - **Message**: #{exc['message']}
148
+
149
+ ### Backtrace
150
+
151
+ ```
152
+ #{backtrace}
153
+ ```
154
+ SECTION
155
+ end
156
+
157
+ def format_body(body)
158
+ # Try to pretty-print JSON
159
+ JSON.pretty_generate(JSON.parse(body))
160
+ rescue JSON::ParserError
161
+ body
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+ require 'open3'
6
+
7
+ module Binocs
8
+ class AgentManager
9
+ class << self
10
+ def launch(request:, prompt:, tool: nil, branch_name: nil, use_worktree: false)
11
+ tool ||= Binocs.configuration.agent_tool
12
+
13
+ # Create agent record
14
+ agent = Agent.new(
15
+ request_id: request.id,
16
+ prompt: prompt,
17
+ tool: tool,
18
+ request_context: AgentContext.build(request)
19
+ )
20
+
21
+ # Set up output file path early so we can log to it
22
+ base_dir = worktree_base_path
23
+ FileUtils.mkdir_p(base_dir)
24
+
25
+ # Generate a name for logs
26
+ timestamp = Time.now.strftime('%m%d-%H%M%S')
27
+ prompt_slug = generate_slug(agent.prompt)
28
+ log_name = "#{timestamp}-#{prompt_slug}"
29
+ agent.output_file = File.join(base_dir, "#{log_name}.log")
30
+
31
+ # Start logging
32
+ log_to_agent(agent, "=" * 60)
33
+ log_to_agent(agent, "Binocs Agent Started")
34
+ log_to_agent(agent, "Time: #{Time.now}")
35
+ log_to_agent(agent, "Tool: #{agent.tool}")
36
+ log_to_agent(agent, "Mode: #{use_worktree ? 'New Worktree' : 'Current Branch'}")
37
+ log_to_agent(agent, "=" * 60)
38
+ log_to_agent(agent, "")
39
+
40
+ if use_worktree
41
+ # Create worktree for isolated work
42
+ worktree_name = branch_name && !branch_name.empty? ? branch_name : log_name
43
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Creating git worktree...")
44
+ worktree_path, branch_name = create_worktree(agent, worktree_name)
45
+ agent.worktree_path = worktree_path
46
+ agent.branch_name = branch_name
47
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Worktree created: #{worktree_path}")
48
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Branch: #{branch_name}")
49
+ log_to_agent(agent, "")
50
+
51
+ # Write context file for the agent
52
+ context_file = File.join(worktree_path, '.binocs-context.md')
53
+ File.write(context_file, agent.request_context)
54
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Context file written: .binocs-context.md")
55
+ else
56
+ # Run in current directory on current branch
57
+ agent.worktree_path = find_git_root
58
+ agent.branch_name = current_branch_name
59
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Running on current branch: #{agent.branch_name}")
60
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Directory: #{agent.worktree_path}")
61
+ log_to_agent(agent, "")
62
+
63
+ # Write context file in current directory
64
+ context_file = File.join(agent.worktree_path, '.binocs-context.md')
65
+ File.write(context_file, agent.request_context)
66
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Context file written: .binocs-context.md")
67
+ end
68
+
69
+ # Register agent
70
+ Agent.add(agent)
71
+
72
+ # Spawn the process
73
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Starting #{agent.tool_command}...")
74
+ log_to_agent(agent, "")
75
+ log_to_agent(agent, "-" * 60)
76
+ log_to_agent(agent, "Agent Output:")
77
+ log_to_agent(agent, "-" * 60)
78
+ log_to_agent(agent, "")
79
+ spawn_agent(agent)
80
+
81
+ agent
82
+ end
83
+
84
+ def continue_session(agent:, prompt:, tool: nil)
85
+ tool ||= agent.tool
86
+
87
+ # Update agent for new session
88
+ agent.prompt = prompt
89
+ agent.tool = tool
90
+ agent.status = :pending
91
+ agent.created_at = Time.now
92
+
93
+ # Log continuation
94
+ log_to_agent(agent, "")
95
+ log_to_agent(agent, "=" * 60)
96
+ log_to_agent(agent, "Continuing Session")
97
+ log_to_agent(agent, "Time: #{Time.now}")
98
+ log_to_agent(agent, "Tool: #{tool}")
99
+ log_to_agent(agent, "=" * 60)
100
+ log_to_agent(agent, "")
101
+
102
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Continuing in: #{agent.worktree_path}")
103
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] New prompt: #{prompt[0, 50]}...")
104
+ log_to_agent(agent, "")
105
+
106
+ # Spawn the process in the same directory
107
+ log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Starting #{agent.tool_command}...")
108
+ log_to_agent(agent, "")
109
+ log_to_agent(agent, "-" * 60)
110
+ log_to_agent(agent, "Agent Output:")
111
+ log_to_agent(agent, "-" * 60)
112
+ log_to_agent(agent, "")
113
+ spawn_agent(agent)
114
+
115
+ agent
116
+ end
117
+
118
+ def log_to_agent(agent, message)
119
+ return unless agent.output_file
120
+ File.open(agent.output_file, 'a') { |f| f.puts(message) }
121
+ end
122
+
123
+ def create_worktree(agent, worktree_name)
124
+ base_dir = worktree_base_path
125
+ worktree_path = File.join(base_dir, worktree_name)
126
+ branch_name = "agent/#{worktree_name}"
127
+
128
+ # Get current repo root
129
+ repo_root = find_git_root
130
+
131
+ # Create the worktree with a new branch, capturing output
132
+ Dir.chdir(repo_root) do
133
+ output = `git worktree add -b #{branch_name.shellescape} #{worktree_path.shellescape} HEAD 2>&1`
134
+ unless $?.success?
135
+ log_to_agent(agent, "[ERROR] Git worktree creation failed:")
136
+ log_to_agent(agent, output)
137
+ raise "Failed to create git worktree at #{worktree_path}: #{output}"
138
+ end
139
+ log_to_agent(agent, output) unless output.strip.empty?
140
+ end
141
+
142
+ [worktree_path, branch_name]
143
+ end
144
+
145
+ def generate_slug(prompt)
146
+ return 'task' if prompt.nil? || prompt.empty?
147
+
148
+ # Take first few words, remove special chars, join with dashes
149
+ words = prompt.downcase.gsub(/[^a-z0-9\s]/, '').split.first(4)
150
+ slug = words.join('-')
151
+ slug = slug[0, 25] if slug.length > 25
152
+ slug.empty? ? 'task' : slug
153
+ end
154
+
155
+ def spawn_agent(agent)
156
+ return unless agent.worktree_path && Dir.exist?(agent.worktree_path)
157
+
158
+ # Build the full prompt including context
159
+ full_prompt = build_full_prompt(agent)
160
+
161
+ # Create a prompt file for the agent to read
162
+ prompt_file = File.join(agent.worktree_path, '.binocs-prompt.md')
163
+ File.write(prompt_file, full_prompt)
164
+
165
+ # Build command arguments based on tool
166
+ cmd_args = build_agent_command_args(agent, prompt_file)
167
+
168
+ # Spawn the process
169
+ pid = Process.spawn(
170
+ *cmd_args,
171
+ chdir: agent.worktree_path,
172
+ out: [agent.output_file, 'a'],
173
+ err: [agent.output_file, 'a'],
174
+ pgroup: true
175
+ )
176
+
177
+ Process.detach(pid)
178
+
179
+ agent.pid = pid
180
+ agent.status = :running
181
+
182
+ # Start a monitor thread
183
+ start_monitor_thread(agent)
184
+ end
185
+
186
+ def cleanup(agent)
187
+ # Stop the process if running
188
+ agent.stop! if agent.running?
189
+
190
+ # Remove the worktree
191
+ if agent.worktree_path && Dir.exist?(agent.worktree_path)
192
+ repo_root = find_git_root
193
+
194
+ Dir.chdir(repo_root) do
195
+ system("git worktree remove #{agent.worktree_path.shellescape} --force",
196
+ out: File::NULL, err: File::NULL)
197
+ end
198
+
199
+ # Also delete the branch if it exists
200
+ if agent.branch_name
201
+ Dir.chdir(repo_root) do
202
+ system("git branch -D #{agent.branch_name.shellescape}",
203
+ out: File::NULL, err: File::NULL)
204
+ end
205
+ end
206
+ end
207
+
208
+ # Remove from registry
209
+ Agent.remove(agent)
210
+ end
211
+
212
+ def open_worktree(agent)
213
+ return unless agent.worktree_path && Dir.exist?(agent.worktree_path)
214
+
215
+ # Open in file manager
216
+ if RbConfig::CONFIG['host_os'] =~ /darwin/
217
+ system("open", agent.worktree_path)
218
+ elsif RbConfig::CONFIG['host_os'] =~ /linux/
219
+ system("xdg-open", agent.worktree_path)
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ def worktree_base_path
226
+ base = Binocs.configuration.agent_worktree_base
227
+ if base.start_with?('/')
228
+ base
229
+ else
230
+ File.expand_path(base, find_git_root)
231
+ end
232
+ end
233
+
234
+ def find_git_root
235
+ dir = Dir.pwd
236
+ while dir != '/'
237
+ return dir if File.exist?(File.join(dir, '.git'))
238
+
239
+ dir = File.dirname(dir)
240
+ end
241
+ Dir.pwd
242
+ end
243
+
244
+ def current_branch_name
245
+ Dir.chdir(find_git_root) do
246
+ `git rev-parse --abbrev-ref HEAD`.strip
247
+ end
248
+ rescue
249
+ 'unknown'
250
+ end
251
+
252
+ def build_full_prompt(agent)
253
+ <<~PROMPT
254
+ # Request Context
255
+
256
+ The following is context from an HTTP request that was captured by Binocs.
257
+ Use this information to understand the issue and implement a fix.
258
+
259
+ #{agent.request_context}
260
+
261
+ ---
262
+
263
+ # Task
264
+
265
+ #{agent.prompt}
266
+
267
+ ---
268
+
269
+ Note: The request context is also saved in `.binocs-context.md` for reference.
270
+ PROMPT
271
+ end
272
+
273
+ def build_agent_command_args(agent, prompt_file)
274
+ prompt_content = File.read(prompt_file)
275
+
276
+ case agent.tool
277
+ when :claude_code
278
+ # Claude Code: use -p for prompt, --dangerously-skip-permissions for autonomous mode
279
+ [agent.tool_command, '-p', prompt_content, '--dangerously-skip-permissions']
280
+ when :opencode
281
+ # OpenCode - run with prompt flag
282
+ [agent.tool_command, '-p', prompt_content]
283
+ else
284
+ [agent.tool_command, '-p', prompt_content]
285
+ end
286
+ end
287
+
288
+ def start_monitor_thread(agent)
289
+ Thread.new do
290
+ begin
291
+ Process.wait(agent.pid)
292
+ agent.exit_code = $?.exitstatus
293
+ agent.status = agent.exit_code == 0 ? :completed : :failed
294
+ rescue Errno::ECHILD
295
+ # Process already reaped
296
+ agent.status = :completed unless agent.status == :stopped
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binocs
4
+ class Configuration
5
+ attr_accessor :enabled,
6
+ :retention_period,
7
+ :max_body_size,
8
+ :ignored_paths,
9
+ :ignored_content_types,
10
+ :basic_auth_username,
11
+ :basic_auth_password,
12
+ :max_requests,
13
+ :record_request_body,
14
+ :record_response_body,
15
+ :swagger_spec_url,
16
+ :swagger_ui_url,
17
+ :agent_tool,
18
+ :agent_worktree_base,
19
+ :login_path,
20
+ :authentication_method
21
+
22
+ def initialize
23
+ @enabled = true
24
+ @retention_period = 24.hours
25
+ @max_body_size = 64.kilobytes
26
+ @ignored_paths = %w[/assets /packs /binocs /cable]
27
+ @ignored_content_types = %w[image/ video/ audio/ font/]
28
+ @basic_auth_username = nil
29
+ @basic_auth_password = nil
30
+ @max_requests = 1000
31
+ @record_request_body = true
32
+ @record_response_body = true
33
+ @swagger_spec_url = '/api-docs'
34
+ @swagger_ui_url = '/api-docs/index.html'
35
+ @agent_tool = :claude_code
36
+ @agent_worktree_base = '../binocs-agents'
37
+ @login_path = nil # Auto-detected from Devise if available, or set manually
38
+ @authentication_method = nil # e.g., :authenticate_user! or a Proc
39
+ end
40
+
41
+ def basic_auth_enabled?
42
+ basic_auth_username.present? && basic_auth_password.present?
43
+ end
44
+
45
+ def swagger_enabled?
46
+ swagger_spec_url.present?
47
+ end
48
+
49
+ def resolved_login_path
50
+ return @login_path if @login_path.present?
51
+
52
+ # Try to auto-detect Devise login path
53
+ if defined?(Devise) && defined?(Rails.application.routes)
54
+ begin
55
+ Rails.application.routes.url_helpers.new_user_session_path
56
+ rescue NoMethodError
57
+ # Devise might use a different scope
58
+ '/users/sign_in'
59
+ end
60
+ else
61
+ '/login'
62
+ end
63
+ end
64
+ end
65
+ end