aidp 0.13.0 → 0.14.1

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