aidp 0.13.0 → 0.14.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/lib/aidp/cli/first_run_wizard.rb +28 -303
  4. data/lib/aidp/cli/issue_importer.rb +359 -0
  5. data/lib/aidp/cli.rb +151 -3
  6. data/lib/aidp/daemon/process_manager.rb +146 -0
  7. data/lib/aidp/daemon/runner.rb +232 -0
  8. data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
  9. data/lib/aidp/execute/future_work_backlog.rb +411 -0
  10. data/lib/aidp/execute/guard_policy.rb +246 -0
  11. data/lib/aidp/execute/instruction_queue.rb +131 -0
  12. data/lib/aidp/execute/interactive_repl.rb +335 -0
  13. data/lib/aidp/execute/repl_macros.rb +651 -0
  14. data/lib/aidp/execute/steps.rb +8 -0
  15. data/lib/aidp/execute/work_loop_runner.rb +322 -36
  16. data/lib/aidp/execute/work_loop_state.rb +162 -0
  17. data/lib/aidp/harness/config_schema.rb +88 -0
  18. data/lib/aidp/harness/configuration.rb +48 -1
  19. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
  20. data/lib/aidp/init/doc_generator.rb +256 -0
  21. data/lib/aidp/init/project_analyzer.rb +343 -0
  22. data/lib/aidp/init/runner.rb +83 -0
  23. data/lib/aidp/init.rb +5 -0
  24. data/lib/aidp/logger.rb +279 -0
  25. data/lib/aidp/setup/wizard.rb +777 -0
  26. data/lib/aidp/tooling_detector.rb +115 -0
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +282 -0
  29. data/lib/aidp/watch/plan_generator.rb +166 -0
  30. data/lib/aidp/watch/plan_processor.rb +83 -0
  31. data/lib/aidp/watch/repository_client.rb +243 -0
  32. data/lib/aidp/watch/runner.rb +93 -0
  33. data/lib/aidp/watch/state_store.rb +105 -0
  34. data/lib/aidp/watch.rb +9 -0
  35. data/lib/aidp.rb +14 -0
  36. data/templates/implementation/simple_task.md +36 -0
  37. metadata +26 -1
@@ -0,0 +1,777 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "yaml"
5
+ require "time"
6
+ require "fileutils"
7
+
8
+ require_relative "../util"
9
+ require_relative "../config/paths"
10
+
11
+ module Aidp
12
+ module Setup
13
+ # Interactive setup wizard for configuring AIDP.
14
+ # Guides the user through provider, work loop, NFR, logging, and mode settings
15
+ # while remaining idempotent and safe to re-run.
16
+ class Wizard
17
+ SCHEMA_VERSION = 1
18
+
19
+ attr_reader :project_dir, :prompt, :dry_run
20
+
21
+ def initialize(project_dir = Dir.pwd, prompt: nil, dry_run: false)
22
+ @project_dir = project_dir
23
+ @prompt = prompt || TTY::Prompt.new
24
+ @dry_run = dry_run
25
+ @warnings = []
26
+ @existing_config = load_existing_config
27
+ @config = deep_symbolize(@existing_config)
28
+ @saved = false
29
+ end
30
+
31
+ def run
32
+ display_welcome
33
+ return @saved if skip_wizard?
34
+
35
+ configure_providers
36
+ configure_work_loop
37
+ configure_branching
38
+ configure_artifacts
39
+ configure_nfrs
40
+ configure_logging
41
+ configure_modes
42
+
43
+ yaml_content = generate_yaml
44
+ display_preview(yaml_content)
45
+ display_diff(yaml_content) if @existing_config.any?
46
+
47
+ return true if dry_run_mode?(yaml_content)
48
+
49
+ if prompt.yes?("Save this configuration?", default: true)
50
+ save_config(yaml_content)
51
+ prompt.ok("āœ… Configuration saved to #{relative_config_path}")
52
+ show_next_steps
53
+ display_warnings
54
+ @saved = true
55
+ else
56
+ prompt.warn("Configuration not saved")
57
+ display_warnings
58
+ end
59
+
60
+ @saved
61
+ end
62
+
63
+ def saved?
64
+ @saved
65
+ end
66
+
67
+ private
68
+
69
+ def display_welcome
70
+ prompt.say("\n" + "=" * 80)
71
+ prompt.say("šŸ§™ AIDP Setup Wizard")
72
+ prompt.say("=" * 80)
73
+ prompt.say("\nThis wizard will help you configure AIDP for your project.")
74
+ prompt.say("Press Enter to keep defaults. Type 'clear' to remove a value.")
75
+ prompt.say("Run 'aidp config --interactive' anytime to revisit these settings.")
76
+ prompt.say("=" * 80 + "\n")
77
+ end
78
+
79
+ def skip_wizard?
80
+ return false unless @existing_config.any?
81
+
82
+ prompt.say("šŸ“ Found existing configuration at #{relative_config_path}")
83
+ skip = !prompt.yes?("Would you like to update it?", default: true)
84
+ @saved = true if skip
85
+ skip
86
+ end
87
+
88
+ # -------------------------------------------
89
+ # Provider configuration
90
+ # -------------------------------------------
91
+ def discover_available_providers
92
+ providers_dir = File.join(__dir__, "../providers")
93
+ provider_files = Dir.glob("*.rb", base: providers_dir)
94
+
95
+ # Exclude base classes and utility classes
96
+ excluded_files = ["base.rb", "macos_ui.rb"]
97
+ provider_files -= excluded_files
98
+
99
+ providers = {}
100
+
101
+ provider_files.each do |file|
102
+ provider_name = File.basename(file, ".rb")
103
+ begin
104
+ # Require the provider file if not already loaded
105
+ require_relative "../providers/#{provider_name}"
106
+
107
+ # Convert to class name (e.g., "anthropic" -> "Anthropic")
108
+ class_name = provider_name.split("_").map(&:capitalize).join
109
+ provider_class = Aidp::Providers.const_get(class_name)
110
+
111
+ # Create a temporary instance to get the display name
112
+ if provider_class.respond_to?(:new)
113
+ instance = provider_class.new
114
+ display_name = instance.respond_to?(:display_name) ? instance.display_name : provider_name.capitalize
115
+ providers[display_name] = provider_name
116
+ end
117
+ rescue => e
118
+ # Skip providers that can't be loaded, but don't fail the entire discovery
119
+ warn "Warning: Could not load provider #{provider_name}: #{e.message}" if ENV["DEBUG"]
120
+ end
121
+ end
122
+
123
+ providers
124
+ end
125
+
126
+ def configure_providers
127
+ prompt.say("\nšŸ“¦ Provider configuration")
128
+ prompt.say("-" * 40)
129
+
130
+ @config.fetch(:providers, {}).fetch(:llm, {})
131
+
132
+ available_providers = discover_available_providers
133
+
134
+ # TODO: Add default selection back once TTY-Prompt default validation issue is resolved
135
+ # For now, the user will select manually from the dynamically discovered providers
136
+ provider_choice = prompt.select("Select your primary LLM provider:") do |menu|
137
+ available_providers.each do |display_name, provider_name|
138
+ menu.choice display_name, provider_name
139
+ end
140
+ menu.choice "Other/Custom", "custom"
141
+ end
142
+
143
+ # Prompt for fallback providers (excluding the primary)
144
+ fallback_choices = available_providers.reject { |_, name| name == provider_choice }
145
+ fallback_selected = prompt.multi_select("Select fallback providers (used if primary fails):") do |menu|
146
+ fallback_choices.each do |display_name, provider_name|
147
+ menu.choice display_name, provider_name
148
+ end
149
+ end
150
+
151
+ set([:harness, :fallback_providers], fallback_selected)
152
+
153
+ # No LLM settings needed; provider agent handles LLM config
154
+
155
+ configure_mcp
156
+ show_provider_secrets_help(provider_choice)
157
+ end
158
+
159
+ def configure_mcp
160
+ existing = get([:providers, :mcp]) || {}
161
+ enabled = prompt.yes?("Enable MCP (Model Context Protocol) tools?", default: existing.fetch(:enabled, true))
162
+ return delete_path([:providers, :mcp]) unless enabled
163
+
164
+ # TODO: Add default back once TTY-Prompt default validation issue is resolved
165
+ tools = prompt.multi_select("Select MCP tools:") do |menu|
166
+ menu.choice "Git", "git"
167
+ menu.choice "Shell", "shell"
168
+ menu.choice "Filesystem", "fs"
169
+ menu.choice "Browser", "browser"
170
+ menu.choice "GitHub", "github"
171
+ end
172
+
173
+ custom = ask_list("Custom MCP servers (comma-separated)", existing.fetch(:custom_servers, []))
174
+
175
+ set([:providers, :mcp], {
176
+ enabled: true,
177
+ tools: tools,
178
+ custom_servers: custom
179
+ }.compact)
180
+ end
181
+
182
+ # -------------------------------------------
183
+ # Work loop configuration
184
+ # -------------------------------------------
185
+ def configure_work_loop
186
+ prompt.say("\nāš™ļø Work loop configuration")
187
+ prompt.say("-" * 40)
188
+
189
+ configure_test_commands
190
+ configure_linting
191
+ configure_watch_patterns
192
+ configure_guards
193
+ end
194
+
195
+ def configure_test_commands
196
+ existing = get([:work_loop, :test]) || {}
197
+
198
+ unit = ask_with_default("Unit test command", existing[:unit] || detect_unit_test_command)
199
+ integration = ask_with_default("Integration test command", existing[:integration])
200
+ e2e = ask_with_default("End-to-end test command", existing[:e2e])
201
+
202
+ timeout = ask_with_default("Test timeout (seconds)", (existing[:timeout_seconds] || 1800).to_s) { |value| value.to_i }
203
+
204
+ set([:work_loop, :test], {
205
+ unit: unit,
206
+ integration: integration,
207
+ e2e: e2e,
208
+ timeout_seconds: timeout
209
+ }.compact)
210
+
211
+ validate_command(unit)
212
+ validate_command(integration)
213
+ validate_command(e2e)
214
+ end
215
+
216
+ def configure_linting
217
+ existing = get([:work_loop, :lint]) || {}
218
+
219
+ lint_cmd = ask_with_default("Lint command", existing[:command] || detect_lint_command)
220
+ format_cmd = ask_with_default("Format command", existing[:format] || detect_format_command)
221
+ autofix = prompt.yes?("Run formatter automatically?", default: existing.fetch(:autofix, false))
222
+
223
+ set([:work_loop, :lint], {
224
+ command: lint_cmd,
225
+ format: format_cmd,
226
+ autofix: autofix
227
+ })
228
+
229
+ validate_command(lint_cmd)
230
+ validate_command(format_cmd)
231
+ end
232
+
233
+ def configure_watch_patterns
234
+ existing = get([:work_loop, :test, :watch]) || {}
235
+ default_patterns = detect_watch_patterns
236
+
237
+ watch_patterns = ask_list("Test watch patterns (comma-separated)", existing.fetch(:patterns, default_patterns))
238
+ set([:work_loop, :test, :watch], {patterns: watch_patterns}) if watch_patterns.any?
239
+ end
240
+
241
+ def configure_guards
242
+ existing = get([:work_loop, :guards]) || {}
243
+
244
+ include_patterns = ask_list("Guard include patterns", existing[:include] || detect_source_patterns)
245
+ exclude_patterns = ask_list("Guard exclude patterns", existing[:exclude] || ["node_modules/**", "dist/**", "build/**"])
246
+ max_lines = ask_with_default("Max lines changed per commit", (existing[:max_lines_changed_per_commit] || 300).to_s) { |value| value.to_i }
247
+ protected_paths = ask_list("Protected paths (require confirmation)", existing[:protected_paths] || [], allow_empty: true)
248
+ confirmation_required = prompt.yes?("Require confirmation before editing protected paths?", default: existing.fetch(:confirm_protected, true))
249
+
250
+ set([:work_loop, :guards], {
251
+ include: include_patterns,
252
+ exclude: exclude_patterns,
253
+ max_lines_changed_per_commit: max_lines,
254
+ protected_paths: protected_paths,
255
+ confirm_protected: confirmation_required
256
+ })
257
+ end
258
+
259
+ def configure_branching
260
+ prompt.say("\n🌿 Branching strategy")
261
+ prompt.say("-" * 40)
262
+ existing = get([:work_loop, :branching]) || {}
263
+
264
+ prefix = ask_with_default("Branch prefix for work loops", existing[:prefix] || "aidp")
265
+ slug_format = ask_with_default("Slug format (use %{id} and %{title})", existing[:slug_format] || "issue-%{id}-%{title}")
266
+ checkpoint_tag = ask_with_default("Checkpoint tag template", existing[:checkpoint_tag] || "aidp-start/%{id}")
267
+
268
+ set([:work_loop, :branching], {
269
+ prefix: prefix,
270
+ slug_format: slug_format,
271
+ checkpoint_tag: checkpoint_tag
272
+ })
273
+ end
274
+
275
+ def configure_artifacts
276
+ prompt.say("\nšŸ“ Artifact storage")
277
+ prompt.say("-" * 40)
278
+ existing = get([:work_loop, :artifacts]) || {}
279
+
280
+ evidence_dir = ask_with_default("Evidence pack directory", existing[:evidence_dir] || ".aidp/evidence")
281
+ logs_dir = ask_with_default("Logs directory", existing[:logs_dir] || ".aidp/logs")
282
+ screenshots_dir = ask_with_default("Screenshots directory", existing[:screenshots_dir] || ".aidp/screenshots")
283
+
284
+ set([:work_loop, :artifacts], {
285
+ evidence_dir: evidence_dir,
286
+ logs_dir: logs_dir,
287
+ screenshots_dir: screenshots_dir
288
+ })
289
+ end
290
+
291
+ # -------------------------------------------
292
+ # NFRs & libraries
293
+ # -------------------------------------------
294
+ def configure_nfrs
295
+ prompt.say("\nšŸ“‹ Non-functional requirements & preferred libraries")
296
+ prompt.say("-" * 40)
297
+
298
+ return delete_path([:nfrs]) unless prompt.yes?("Configure NFRs?", default: true)
299
+
300
+ categories = %i[performance security reliability accessibility internationalization]
301
+ categories.each do |category|
302
+ existing = get([:nfrs, category])
303
+ value = ask_multiline("#{category.to_s.capitalize} requirements", existing)
304
+ value.nil? ? delete_path([:nfrs, category]) : set([:nfrs, category], value)
305
+ end
306
+
307
+ configure_preferred_libraries
308
+ configure_environment_overrides
309
+ end
310
+
311
+ def configure_preferred_libraries
312
+ return unless prompt.yes?("Configure preferred libraries/tools?", default: true)
313
+
314
+ stack = detect_stack
315
+ prompt.say("\nšŸ“š Detected stack: #{(stack == :other) ? "Custom" : stack.to_s.capitalize}")
316
+ case stack
317
+ when :rails
318
+ set([:nfrs, :preferred_libraries, :rails], configure_rails_libraries)
319
+ when :node
320
+ set([:nfrs, :preferred_libraries, :node], configure_node_libraries)
321
+ when :python
322
+ set([:nfrs, :preferred_libraries, :python], configure_python_libraries)
323
+ else
324
+ custom_stack = ask_with_default("Name this stack (e.g. go, php)", "custom")
325
+ libs = ask_list("Preferred libraries (comma-separated)", [])
326
+ set([:nfrs, :preferred_libraries, custom_stack.to_sym], libs)
327
+ end
328
+ end
329
+
330
+ def configure_environment_overrides
331
+ return unless prompt.yes?("Add environment-specific overrides?", default: false)
332
+
333
+ environments = prompt.multi_select("Select environments:", default: []) do |menu|
334
+ menu.choice "Development", :development
335
+ menu.choice "Test", :test
336
+ menu.choice "Production", :production
337
+ end
338
+
339
+ environments.each do |env|
340
+ categories = ask_multiline("#{env.to_s.capitalize} overrides", get([:nfrs, :environment_overrides, env]))
341
+ set([:nfrs, :environment_overrides, env], categories) unless categories.nil? || categories.empty?
342
+ end
343
+ end
344
+
345
+ def configure_rails_libraries
346
+ existing = get([:nfrs, :preferred_libraries, :rails]) || {}
347
+ {
348
+ auth: ask_with_default("Authentication gem", existing[:auth] || "devise"),
349
+ authz: ask_with_default("Authorization gem", existing[:authz] || "pundit"),
350
+ jobs: ask_with_default("Background jobs", existing[:jobs] || "sidekiq"),
351
+ testing: ask_list("Testing gems", existing[:testing] || %w[rspec factory_bot])
352
+ }
353
+ end
354
+
355
+ def configure_node_libraries
356
+ existing = get([:nfrs, :preferred_libraries, :node]) || {}
357
+ {
358
+ validation: ask_with_default("Validation library", existing[:validation] || "zod"),
359
+ orm: ask_with_default("ORM/Database", existing[:orm] || "prisma"),
360
+ testing: ask_with_default("Testing framework", existing[:testing] || "jest")
361
+ }
362
+ end
363
+
364
+ def configure_python_libraries
365
+ existing = get([:nfrs, :preferred_libraries, :python]) || {}
366
+ linting = ask_list("Linting tools", existing[:linting] || %w[ruff mypy])
367
+ {
368
+ validation: ask_with_default("Validation library", existing[:validation] || "pydantic"),
369
+ testing: ask_with_default("Testing framework", existing[:testing] || "pytest"),
370
+ linting: linting
371
+ }
372
+ end
373
+
374
+ # -------------------------------------------
375
+ # Logging & modes
376
+ # -------------------------------------------
377
+ def configure_logging
378
+ prompt.say("\nšŸ“ Logging configuration")
379
+ prompt.say("-" * 40)
380
+ existing = get([:logging]) || {}
381
+
382
+ # TODO: Add default back once TTY-Prompt default validation issue is resolved
383
+ log_level = prompt.select("Log level:") do |menu|
384
+ menu.choice "Debug", "debug"
385
+ menu.choice "Info", "info"
386
+ menu.choice "Error", "error"
387
+ end
388
+ json = prompt.yes?("Use JSON log format?", default: existing.fetch(:json, false))
389
+ max_size = ask_with_default("Max log size (MB)", (existing[:max_size_mb] || 10).to_s) { |value| value.to_i }
390
+ max_backups = ask_with_default("Max backup files", (existing[:max_backups] || 5).to_s) { |value| value.to_i }
391
+
392
+ set([:logging], {
393
+ level: log_level,
394
+ json: json,
395
+ max_size_mb: max_size,
396
+ max_backups: max_backups
397
+ })
398
+ end
399
+
400
+ def configure_modes
401
+ prompt.say("\nšŸš€ Operational modes")
402
+ prompt.say("-" * 40)
403
+ existing = get([:modes]) || {}
404
+
405
+ background = prompt.yes?("Run in background mode by default?", default: existing.fetch(:background_default, false))
406
+ watch = prompt.yes?("Enable watch mode integrations?", default: existing.fetch(:watch_enabled, false))
407
+ quick_mode = prompt.yes?("Enable quick mode (short timeouts) by default?", default: existing.fetch(:quick_mode_default, false))
408
+
409
+ set([:modes], {
410
+ background_default: background,
411
+ watch_enabled: watch,
412
+ quick_mode_default: quick_mode
413
+ })
414
+ end
415
+
416
+ # -------------------------------------------
417
+ # Preview & persistence
418
+ # -------------------------------------------
419
+ def generate_yaml
420
+ payload = @config.dup
421
+ payload[:schema_version] = SCHEMA_VERSION
422
+ payload[:generated_by] = "aidp setup wizard v#{Aidp::VERSION}"
423
+ payload[:generated_at] = Time.now.utc.iso8601
424
+
425
+ yaml = deep_stringify(payload).to_yaml
426
+ comment_header + annotate_yaml(yaml)
427
+ end
428
+
429
+ def comment_header
430
+ <<~HEADER
431
+ # AIDP configuration generated by the interactive setup wizard.
432
+ # Re-run `aidp config --interactive` to update. Manual edits are preserved.
433
+ HEADER
434
+ end
435
+
436
+ def annotate_yaml(yaml)
437
+ yaml
438
+ .sub(/^schema_version:/, "# Tracks configuration migrations\nschema_version:")
439
+ .sub(/^providers:/, "# Provider configuration (no secrets stored)\nproviders:")
440
+ .sub(/^work_loop:/, "# Work loop execution settings\nwork_loop:")
441
+ .sub(/^nfrs:/, "# Non-functional requirements to reference during planning\nnfrs:")
442
+ .sub(/^logging:/, "# Logging configuration\nlogging:")
443
+ .sub(/^modes:/, "# Defaults for background/watch/quick modes\nmodes:")
444
+ end
445
+
446
+ def display_preview(yaml_content)
447
+ prompt.say("\n" + "=" * 80)
448
+ prompt.say("šŸ“„ Configuration preview")
449
+ prompt.say("=" * 80)
450
+ prompt.say(yaml_content)
451
+ prompt.say("=" * 80 + "\n")
452
+ end
453
+
454
+ def display_diff(yaml_content)
455
+ existing_yaml = File.read(config_path)
456
+ diff_lines = line_diff(existing_yaml, yaml_content)
457
+ return if diff_lines.empty?
458
+
459
+ prompt.say("šŸ” Diff with existing configuration:")
460
+ diff_lines.each do |line|
461
+ case line[0]
462
+ when "+"
463
+ prompt.say(line, color: :green)
464
+ when "-"
465
+ prompt.say(line, color: :red)
466
+ else
467
+ prompt.say(line, color: :bright_black)
468
+ end
469
+ end
470
+ prompt.say("")
471
+ rescue Errno::ENOENT
472
+ nil
473
+ end
474
+
475
+ def dry_run_mode?(yaml_content)
476
+ return false unless dry_run
477
+
478
+ prompt.ok("Dry run mode active – configuration was NOT written.")
479
+ display_warnings
480
+ @saved = false
481
+ true
482
+ end
483
+
484
+ def save_config(yaml_content)
485
+ Aidp::ConfigPaths.ensure_config_dir(project_dir)
486
+ File.write(config_path, yaml_content)
487
+ end
488
+
489
+ def display_warnings
490
+ return if @warnings.empty?
491
+
492
+ prompt.warn("\nWarnings:")
493
+ @warnings.each { |warning| prompt.warn(" • #{warning}") }
494
+ end
495
+
496
+ def show_next_steps
497
+ prompt.say("\nšŸŽ‰ Setup complete!")
498
+ prompt.say("\nNext steps:")
499
+ prompt.say(" 1. Export provider API keys as environment variables.")
500
+ prompt.say(" 2. Run 'aidp init' to analyze the project.")
501
+ prompt.say(" 3. Run 'aidp execute' to start a work loop.")
502
+ prompt.say("")
503
+ end
504
+
505
+ # -------------------------------------------
506
+ # Helpers
507
+ # -------------------------------------------
508
+ def ask_with_default(question, default = nil)
509
+ existing_text = default.nil? ? "" : " [#{display_value(default)}]"
510
+ answer = prompt.ask("#{question}#{existing_text}:")
511
+
512
+ if answer.nil? || answer.strip.empty?
513
+ return default if default.nil? || !block_given?
514
+ return yield(default)
515
+ end
516
+
517
+ return nil if answer.strip.casecmp("clear").zero?
518
+
519
+ block_given? ? yield(answer) : answer
520
+ end
521
+
522
+ def ask_multiline(question, default)
523
+ prompt.say("#{question}:")
524
+ prompt.say(" (Enter text; submit empty line to finish. Type 'clear' alone to remove.)")
525
+ lines = []
526
+ loop do
527
+ line = prompt.ask("", default: nil)
528
+ break if line.nil? || line.empty?
529
+ return nil if line.strip.casecmp("clear").zero?
530
+ lines << line
531
+ end
532
+ return default if lines.empty?
533
+
534
+ lines.join("\n")
535
+ end
536
+
537
+ def ask_list(question, existing = [], allow_empty: false)
538
+ existing = Array(existing).compact
539
+ display = existing.any? ? " [#{existing.join(", ")}]" : ""
540
+ answer = prompt.ask("#{question}#{display}:")
541
+
542
+ return existing if answer.nil? || answer.strip.empty?
543
+ return [] if answer.strip.casecmp("clear").zero? && allow_empty
544
+
545
+ answer.split(",").map { |item| item.strip }.reject(&:empty?)
546
+ end
547
+
548
+ def validate_command(command)
549
+ return if command.nil? || command.strip.empty?
550
+ return if command.start_with?("echo")
551
+
552
+ executable = command.split(/\s+/).first
553
+ return if Aidp::Util.which(executable)
554
+
555
+ @warnings << "Command '#{command}' not found in PATH."
556
+ end
557
+
558
+ def fetch_retry_attempts(llm)
559
+ policy = llm[:retry_policy] || {}
560
+ (policy[:attempts] || 3).to_s
561
+ end
562
+
563
+ def fetch_retry_backoff(llm)
564
+ policy = llm[:retry_policy] || {}
565
+ (policy[:backoff_seconds] || 10).to_s
566
+ end
567
+
568
+ def detect_unit_test_command
569
+ return "bundle exec rspec" if project_file?("Gemfile") && Dir.exist?(File.join(project_dir, "spec"))
570
+ return "npm test" if project_file?("package.json")
571
+ return "pytest" if project_file?("pytest.ini") || Dir.exist?(File.join(project_dir, "tests"))
572
+ "echo 'No tests configured'"
573
+ end
574
+
575
+ def detect_lint_command
576
+ return "bundle exec rubocop" if project_file?(".rubocop.yml")
577
+ return "npm run lint" if project_file?("package.json")
578
+ return "ruff check ." if project_file?("pyproject.toml")
579
+ "echo 'No linter configured'"
580
+ end
581
+
582
+ def detect_format_command
583
+ return "bundle exec rubocop -A" if project_file?(".rubocop.yml")
584
+ return "npm run format" if project_file?("package.json")
585
+ return "ruff format ." if project_file?("pyproject.toml")
586
+ "echo 'No formatter configured'"
587
+ end
588
+
589
+ def detect_watch_patterns
590
+ if project_file?("Gemfile")
591
+ ["spec/**/*_spec.rb", "lib/**/*.rb"]
592
+ elsif project_file?("package.json")
593
+ ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
594
+ else
595
+ ["**/*"]
596
+ end
597
+ end
598
+
599
+ def detect_source_patterns
600
+ if project_file?("Gemfile")
601
+ %w[app/**/* lib/**/*]
602
+ elsif project_file?("package.json")
603
+ %w[src/**/* app/**/*]
604
+ elsif project_file?("pyproject.toml")
605
+ %w[src/**/*]
606
+ else
607
+ %w[**/*]
608
+ end
609
+ end
610
+
611
+ def detect_stack
612
+ return :rails if project_file?("Gemfile") && project_file?("config/application.rb")
613
+ return :node if project_file?("package.json")
614
+ return :python if project_file?("pyproject.toml") || project_file?("requirements.txt")
615
+
616
+ :other
617
+ end
618
+
619
+ def default_model(provider)
620
+ case provider
621
+ when "anthropic" then "claude-3-5-sonnet-20241022"
622
+ when "openai" then "gpt-4.1"
623
+ when "google" then "gemini-1.5-pro"
624
+ when "azure" then "gpt-4"
625
+ else "claude-3-5-sonnet-20241022"
626
+ end
627
+ end
628
+
629
+ def show_provider_secrets_help(provider)
630
+ prompt.say("\nšŸ’” Provider setup:")
631
+ case provider
632
+ when "anthropic"
633
+ prompt.say("Export API key: export ANTHROPIC_API_KEY=sk-ant-...")
634
+ when "openai", "azure"
635
+ prompt.say("Export API key: export OPENAI_API_KEY=sk-...")
636
+ when "google"
637
+ prompt.say("Export API key: export GOOGLE_API_KEY=...")
638
+ else
639
+ prompt.say("Configure API credentials via environment variables.")
640
+ end
641
+ end
642
+
643
+ def load_existing_config
644
+ return {} unless File.exist?(config_path)
645
+ YAML.safe_load_file(config_path, permitted_classes: [Time]) || {}
646
+ rescue => e
647
+ @warnings << "Failed to parse existing configuration: #{e.message}"
648
+ {}
649
+ end
650
+
651
+ def config_path
652
+ Aidp::ConfigPaths.config_file(project_dir)
653
+ end
654
+
655
+ def relative_config_path
656
+ config_path.sub("#{project_dir}/", "")
657
+ end
658
+
659
+ # -------------------------------------------
660
+ # Hash utilities
661
+ # -------------------------------------------
662
+ def get(path)
663
+ path.reduce(@config) do |acc, key|
664
+ acc.is_a?(Hash) ? acc[key.to_sym] : nil
665
+ end
666
+ end
667
+
668
+ def set(path, value)
669
+ parent = path[0...-1].reduce(@config) do |acc, key|
670
+ acc[key.to_sym] ||= {}
671
+ acc[key.to_sym]
672
+ end
673
+ parent[path.last.to_sym] = value
674
+ end
675
+
676
+ def delete_path(path)
677
+ parent = path[0...-1].reduce(@config) do |acc, key|
678
+ acc[key.to_sym] ||= {}
679
+ acc[key.to_sym]
680
+ end
681
+ parent.delete(path.last.to_sym)
682
+ end
683
+
684
+ def deep_symbolize(object)
685
+ case object
686
+ when Hash
687
+ object.each_with_object({}) do |(key, value), memo|
688
+ memo[key.to_sym] = deep_symbolize(value)
689
+ end
690
+ when Array
691
+ object.map { |item| deep_symbolize(item) }
692
+ else
693
+ object
694
+ end
695
+ end
696
+
697
+ def deep_stringify(object)
698
+ case object
699
+ when Hash
700
+ object.each_with_object({}) do |(key, value), memo|
701
+ memo[key.to_s] = deep_stringify(value)
702
+ end
703
+ when Array
704
+ object.map { |item| deep_stringify(item) }
705
+ else
706
+ object
707
+ end
708
+ end
709
+
710
+ # -------------------------------------------
711
+ # Diff utilities
712
+ # -------------------------------------------
713
+ def line_diff(old_str, new_str)
714
+ old_lines = old_str.split("\n")
715
+ new_lines = new_str.split("\n")
716
+ lcs_matrix = build_lcs_matrix(old_lines, new_lines)
717
+ backtrack_diff(lcs_matrix, old_lines, new_lines).reverse
718
+ end
719
+
720
+ def build_lcs_matrix(a_lines, b_lines)
721
+ Array.new(a_lines.length + 1) do
722
+ Array.new(b_lines.length + 1, 0)
723
+ end.tap do |matrix|
724
+ a_lines.each_index do |i|
725
+ b_lines.each_index do |j|
726
+ matrix[i + 1][j + 1] = if a_lines[i] == b_lines[j]
727
+ matrix[i][j] + 1
728
+ else
729
+ [matrix[i + 1][j], matrix[i][j + 1]].max
730
+ end
731
+ end
732
+ end
733
+ end
734
+ end
735
+
736
+ def backtrack_diff(matrix, a_lines, b_lines)
737
+ diff = []
738
+ i = a_lines.length
739
+ j = b_lines.length
740
+
741
+ while i > 0 && j > 0
742
+ if a_lines[i - 1] == b_lines[j - 1]
743
+ diff << " #{a_lines[i - 1]}"
744
+ i -= 1
745
+ j -= 1
746
+ elsif matrix[i - 1][j] >= matrix[i][j - 1]
747
+ diff << "- #{a_lines[i - 1]}"
748
+ i -= 1
749
+ else
750
+ diff << "+ #{b_lines[j - 1]}"
751
+ j -= 1
752
+ end
753
+ end
754
+
755
+ while i > 0
756
+ diff << "- #{a_lines[i - 1]}"
757
+ i -= 1
758
+ end
759
+
760
+ while j > 0
761
+ diff << "+ #{b_lines[j - 1]}"
762
+ j -= 1
763
+ end
764
+
765
+ diff
766
+ end
767
+
768
+ def display_value(value)
769
+ value.is_a?(Array) ? value.join(", ") : value
770
+ end
771
+
772
+ def project_file?(relative_path)
773
+ File.exist?(File.join(project_dir, relative_path))
774
+ end
775
+ end
776
+ end
777
+ end