omamori 0.1.4 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +19 -7
  5. data/README.md +185 -60
  6. data/README_ja.md +187 -57
  7. data/demo/ai_analysis_vulnerability.rb +3 -3
  8. data/demo/csrf_vulnerability.rb +1 -1
  9. data/demo/eval_vulnerability.rb +1 -1
  10. data/demo/idor_vulnerability.rb +2 -2
  11. data/demo/insecure_cookie_vulnerability.rb +1 -1
  12. data/demo/open_redirect_vulnerability.rb +1 -1
  13. data/demo/static_analysis_vulnerability.rb +1 -1
  14. data/demo/xss_vulnerability.rb +1 -1
  15. data/exe/omamori +2 -2
  16. data/lib/omamori/ai_analysis_engine/checklist_scanner.rb +143 -0
  17. data/lib/omamori/ai_analysis_engine/cli_clients/base.rb +43 -0
  18. data/lib/omamori/ai_analysis_engine/cli_clients/claude_code_client.rb +35 -0
  19. data/lib/omamori/ai_analysis_engine/cli_clients/codex_client.rb +47 -0
  20. data/lib/omamori/ai_analysis_engine/diff_splitter.rb +17 -4
  21. data/lib/omamori/ai_analysis_engine/fallback_client.rb +23 -0
  22. data/lib/omamori/ai_analysis_engine/judge_client.rb +45 -0
  23. data/lib/omamori/ai_analysis_engine/llm_client.rb +71 -0
  24. data/lib/omamori/ai_analysis_engine/prompt_manager.rb +72 -12
  25. data/lib/omamori/ai_analysis_engine/ruby_llm_context.rb +23 -0
  26. data/lib/omamori/ai_analysis_engine/sub_checks/audit_log_missing.yml +12 -0
  27. data/lib/omamori/ai_analysis_engine/sub_checks/csrf.yml +22 -0
  28. data/lib/omamori/ai_analysis_engine/sub_checks/insecure_deserialization_rce.yml +9 -0
  29. data/lib/omamori/ai_analysis_engine/sub_checks/insufficient_encryption.yml +19 -0
  30. data/lib/omamori/ai_analysis_engine/sub_checks/mass_assignment.yml +19 -0
  31. data/lib/omamori/ai_analysis_engine/sub_checks/open_redirect.yml +17 -0
  32. data/lib/omamori/ai_analysis_engine/sub_checks/sql_injection.yml +20 -0
  33. data/lib/omamori/ai_analysis_engine/sub_checks/xss.yml +37 -0
  34. data/lib/omamori/ai_settings.rb +273 -0
  35. data/lib/omamori/config.rb +71 -72
  36. data/lib/omamori/core_runner.rb +332 -359
  37. data/lib/omamori/doctor.rb +95 -0
  38. data/lib/omamori/parallel.rb +24 -0
  39. data/lib/omamori/report_generator/console_formatter.rb +48 -42
  40. data/lib/omamori/report_generator/html_formatter.rb +6 -6
  41. data/lib/omamori/report_generator/json_formatter.rb +1 -1
  42. data/lib/omamori/setup_files.rb +260 -0
  43. data/lib/omamori/static_analysers/brakeman_runner.rb +7 -7
  44. data/lib/omamori/static_analysers/bundler_audit_runner.rb +8 -6
  45. data/lib/omamori/version.rb +2 -2
  46. data/lib/omamori.rb +11 -11
  47. metadata +45 -26
  48. data/lib/omamori/ai_analysis_engine/gemini_client.rb +0 -71
@@ -3,9 +3,14 @@
3
3
  require 'optparse'
4
4
  require 'fileutils' # FileUtils を require する
5
5
  require 'pathname' # Pathname を require する
6
- require_relative 'ai_analysis_engine/gemini_client'
6
+ require 'shellwords'
7
+ require_relative 'ai_analysis_engine/llm_client'
8
+ require_relative 'ai_settings'
9
+ require_relative 'setup_files'
10
+ require_relative 'doctor'
7
11
  require_relative 'ai_analysis_engine/prompt_manager'
8
12
  require_relative 'ai_analysis_engine/diff_splitter'
13
+ require_relative 'ai_analysis_engine/checklist_scanner'
9
14
  require_relative 'report_generator/console_formatter'
10
15
  require_relative 'report_generator/html_formatter'
11
16
  require_relative 'report_generator/json_formatter'
@@ -18,48 +23,45 @@ module Omamori
18
23
  class CoreRunner
19
24
  # Define the JSON Schema for Structured Output
20
25
  JSON_SCHEMA = {
21
- "type": "object",
26
+ "type": 'object',
22
27
  "properties": {
23
28
  "security_risks": {
24
- "type": "array",
25
- "description": "検出されたセキュリティリスクのリスト。",
29
+ "type": 'array',
30
+ "description": '検出されたセキュリティリスクのリスト。',
26
31
  "items": {
27
- "type": "object",
32
+ "type": 'object',
28
33
  "properties": {
29
34
  "type": {
30
- "type": "string",
31
- "description": "検出されたリスクの種類 (例: XSS, CSRF, IDORなど)。3.3の診断対象脆弱性リストのいずれかであること。"
35
+ "type": 'string',
36
+ "description": '検出されたリスクの種類 (例: XSS, CSRF, IDORなど)。3.3の診断対象脆弱性リストのいずれかであること。'
32
37
  },
33
38
  "location": {
34
- "type": "string",
35
- "description": "リスクが存在するコードのファイル名、行番号、またはコードスニペット。差分分析の場合は差分の該当箇所を示す形式 (例: ファイル名:+行番号) であること。"
39
+ "type": 'string',
40
+ "description": 'リスクが存在するコードのファイル名、行番号、またはコードスニペット。差分分析の場合は差分の該当箇所を示す形式 (例: ファイル名:+行番号) であること。'
36
41
  },
37
42
  "details": {
38
- "type": "string",
39
- "description": "リスクの詳細な説明と、なぜそれがリスクなのかの理由。"
43
+ "type": 'string',
44
+ "description": 'リスクの詳細な説明と、なぜそれがリスクなのかの理由。'
40
45
  },
41
46
  "severity": {
42
- "type": "string",
43
- "description": "リスクの深刻度。",
44
- "enum": ["Critical", "High", "Medium", "Low", "Info"]
47
+ "type": 'string',
48
+ "description": 'リスクの深刻度。',
49
+ "enum": %w[Critical High Medium Low Info]
45
50
  },
46
51
  "code_snippet": {
47
- "type": "string",
48
- "description": "該当するコードスニペット。"
52
+ "type": 'string',
53
+ "description": '該当するコードスニペット。'
49
54
  }
50
55
  },
51
- "required": ["type", "location", "details", "severity"]
56
+ "required": %w[type location details severity]
52
57
  }
53
58
  }
54
59
  },
55
- "required": ["security_risks"]
60
+ "required": ['security_risks']
56
61
  }.freeze # Freeze the hash to make it immutable
57
62
 
58
- # Default risks to check, can be overridden by config
59
- DEFAULT_RISKS_TO_CHECK = [
60
- :xss, :csrf, :idor, :open_redirect, :ssrf, :session_fixation
61
- # TODO: Add other risks from requirements based on PromptManager::RISK_PROMPTS.keys
62
- ].freeze
63
+
64
+ DEFAULT_MODEL = AIAnalysisEngine::LLMClient::DEFAULT_MODEL
63
65
 
64
66
  # Threshold for splitting large content (characters as a proxy for tokens)
65
67
  # Can be overridden by config
@@ -72,25 +74,22 @@ module Omamori
72
74
  @config = Omamori::Config.new
73
75
 
74
76
  # Initialize components with configuration
75
- api_key = @config.get("api_key", ENV["GEMINI_API_KEY"])
76
- gemini_model = @config.get("model", "gemini-2.5-flash-preview-04-17")
77
- @gemini_client = AIAnalysisEngine::GeminiClient.new(api_key)
78
77
  @prompt_manager = AIAnalysisEngine::PromptManager.new(@config)
79
78
 
80
- chunk_size = @config.get("chunk_size", DEFAULT_SPLIT_THRESHOLD)
79
+ chunk_size = @config.get('chunk_size', DEFAULT_SPLIT_THRESHOLD)
81
80
  @diff_splitter = AIAnalysisEngine::DiffSplitter.new(chunk_size: chunk_size)
82
81
 
83
- report_config = @config.get("report", {})
84
- report_output_path = report_config.fetch("output_path", "./omamori_report")
85
- html_template_path = report_config.fetch("html_template", nil)
82
+ report_config = @config.get('report', {})
83
+ report_output_path = report_config.fetch('output_path', './omamori_report')
84
+ html_template_path = report_config.fetch('html_template', nil)
86
85
 
87
86
  @console_formatter = ReportGenerator::ConsoleFormatter.new
88
87
  @html_formatter = ReportGenerator::HTMLFormatter.new(report_output_path, html_template_path)
89
88
  @json_formatter = ReportGenerator::JSONFormatter.new(report_output_path)
90
89
 
91
- static_analyser_config = @config.get("static_analysers", {})
92
- brakeman_options = static_analyser_config.fetch("brakeman", {}).fetch("options", {})
93
- bundler_audit_options = static_analyser_config.fetch("bundler_audit", {}).fetch("options", {})
90
+ static_analyser_config = @config.get('static_analysers', {})
91
+ brakeman_options = static_analyser_config.fetch('brakeman', {}).fetch('options', {})
92
+ bundler_audit_options = static_analyser_config.fetch('bundler_audit', {}).fetch('options', {})
94
93
  @brakeman_runner = StaticAnalysers::BrakemanRunner.new(brakeman_options)
95
94
  @bundler_audit_runner = StaticAnalysers::BundlerAuditRunner.new(bundler_audit_options)
96
95
  end
@@ -100,14 +99,15 @@ module Omamori
100
99
 
101
100
  case @options[:command]
102
101
  when :scan
102
+ validate_ai_settings
103
103
  # Initialize results
104
- ai_analysis_result = { "security_risks" => [] }
104
+ ai_analysis_result = { 'security_risks' => [] }
105
105
  brakeman_result = nil
106
106
  bundler_audit_result = nil
107
107
 
108
108
  # Run static analysers first unless --ai option is specified
109
109
  unless @options[:only_ai]
110
- puts "Running static analysers..."
110
+ puts 'Running static analysers...'
111
111
  brakeman_result = @brakeman_runner.run
112
112
  bundler_audit_result = @bundler_audit_runner.run
113
113
  end
@@ -117,71 +117,86 @@ module Omamori
117
117
  when :paths
118
118
  # Scan specified files/directories
119
119
  if @target_paths.empty?
120
- puts "No paths specified for scan. Use --diff, --all, or provide paths."
120
+ puts 'No paths specified for scan. Use --diff, --all, or provide paths.'
121
121
  else
122
- puts "Scanning specified paths with AI..."
122
+ puts 'Scanning specified paths with AI...'
123
123
  ignore_patterns = @config.ignore_patterns
124
124
  force_scan_ignored = @options.fetch(:force_scan_ignored, false)
125
125
  files_to_scan = collect_files_from_paths(@target_paths, ignore_patterns, force_scan_ignored)
126
126
 
127
127
  if files_to_scan.empty?
128
- puts "No Ruby files found in the specified paths."
128
+ puts 'No Ruby files found in the specified paths.'
129
+ elsif decision_engine?
130
+ ai_analysis_result = checklist_scanner.scan(read_units(files_to_scan), get_risks_to_check)
129
131
  else
130
132
  files_to_scan.each do |file_path|
131
- begin
132
- file_content = File.read(file_path)
133
- puts "Analyzing file: #{file_path}..." # スキャン中のファイルパスを表示
134
- current_risks_to_check = get_risks_to_check
135
- # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
136
- if file_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
137
- puts "File content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
138
- file_ai_result = @diff_splitter.process_in_chunks(file_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, file_path: file_path, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
139
- else
140
- prompt = @prompt_manager.build_prompt(file_content, current_risks_to_check, JSON_SCHEMA, file_path: file_path)
141
- file_ai_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
142
- end
143
- # Merge results
144
- if file_ai_result && file_ai_result["security_risks"]
145
- ai_analysis_result["security_risks"].concat(file_ai_result["security_risks"])
146
- end
147
- rescue => e
148
- puts "Error analyzing file #{file_path}: #{e.message}"
133
+ file_content = File.read(file_path)
134
+ puts "Analyzing file: #{file_path}..." # スキャン中のファイルパスを表示
135
+ current_risks_to_check = get_risks_to_check
136
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
137
+ if file_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
138
+ puts "File content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
139
+ file_ai_result = @diff_splitter.process_in_chunks(file_content, llm_client, JSON_SCHEMA,
140
+ @prompt_manager, current_risks_to_check, file_path: file_path, model: llm_model)
141
+ else
142
+ prompt = @prompt_manager.build_prompt(file_content, current_risks_to_check, JSON_SCHEMA,
143
+ file_path: file_path)
144
+ file_ai_result = llm_client.analyze(prompt, JSON_SCHEMA,
145
+ model: llm_model)
146
+ end
147
+ # Merge results
148
+ if file_ai_result && file_ai_result['security_risks']
149
+ ai_analysis_result['security_risks'].concat(file_ai_result['security_risks'])
149
150
  end
151
+ rescue StandardError => e
152
+ puts "Error analyzing file #{file_path}: #{e.message}"
150
153
  end
151
154
  end
152
155
  end
153
156
  when :diff
154
- # Scan staged differences
155
- diff_content = get_staged_diff
157
+ # Scan staged differences, or the branch diff when --base is given
158
+ diff_content = get_diff
156
159
  if diff_content.empty?
157
- puts "No staged changes to scan."
160
+ puts @options[:base] ? "No changes against #{@options[:base]} to scan." : 'No staged changes to scan.'
161
+ elsif decision_engine?
162
+ puts "Scanning differences (#{ai_settings.describe})..."
163
+ units = @diff_splitter.split_by_file(diff_content)
164
+ ai_analysis_result = checklist_scanner.scan(units, get_risks_to_check)
158
165
  else
159
- puts "Scanning staged differences with AI..."
166
+ puts 'Scanning differences with AI...'
160
167
  current_risks_to_check = get_risks_to_check
161
168
  # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
162
169
  if diff_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
163
170
  puts "Diff content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
164
- ai_analysis_result = @diff_splitter.process_in_chunks(diff_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
171
+ ai_analysis_result = @diff_splitter.process_in_chunks(diff_content, llm_client, JSON_SCHEMA,
172
+ @prompt_manager, current_risks_to_check, model: llm_model)
165
173
  else
166
174
  prompt = @prompt_manager.build_prompt(diff_content, current_risks_to_check, JSON_SCHEMA)
167
- ai_analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
175
+ ai_analysis_result = llm_client.analyze(prompt, JSON_SCHEMA,
176
+ model: llm_model)
168
177
  end
169
178
  end
170
179
  when :all
171
180
  # Scan entire codebase
172
- full_code_content = get_full_codebase
173
- if full_code_content.strip.empty?
174
- puts "No code found to scan."
181
+ if decision_engine?
182
+ puts "Scanning entire codebase (#{ai_settings.describe})..."
183
+ files_to_scan = collect_files_from_paths(['.'], @config.ignore_patterns,
184
+ @options.fetch(:force_scan_ignored, false))
185
+ ai_analysis_result = checklist_scanner.scan(read_units(files_to_scan), get_risks_to_check)
186
+ elsif (full_code_content = get_full_codebase).strip.empty?
187
+ puts 'No code found to scan.'
175
188
  else
176
- puts "Scanning entire codebase with AI..."
189
+ puts 'Scanning entire codebase with AI...'
177
190
  current_risks_to_check = get_risks_to_check
178
191
  # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
179
192
  if full_code_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
180
193
  puts "Full code content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
181
- ai_analysis_result = @diff_splitter.process_in_chunks(full_code_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
194
+ ai_analysis_result = @diff_splitter.process_in_chunks(full_code_content, llm_client, JSON_SCHEMA,
195
+ @prompt_manager, current_risks_to_check, model: llm_model)
182
196
  else
183
197
  prompt = @prompt_manager.build_prompt(full_code_content, current_risks_to_check, JSON_SCHEMA)
184
- ai_analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
198
+ ai_analysis_result = llm_client.analyze(prompt, JSON_SCHEMA,
199
+ model: llm_model)
185
200
  end
186
201
  end
187
202
  else
@@ -191,18 +206,23 @@ module Omamori
191
206
  end
192
207
 
193
208
  # Combine results and display report
194
- ai_analysis_result ||= { "security_risks" => [] } # Ensure it's not nil
209
+ ai_analysis_result ||= { 'security_risks' => [] } # Ensure it's not nil
195
210
  combined_results = combine_results(ai_analysis_result, brakeman_result, bundler_audit_result)
196
211
  display_report(combined_results)
197
212
 
198
- puts "Scan complete."
213
+ puts 'Scan complete.'
199
214
 
200
215
  when :ci_setup
216
+ validate_ai_settings
201
217
  generate_ci_setup(@options[:ci_service])
202
218
 
203
219
  when :init
204
220
  generate_initial_files
205
221
 
222
+ when :doctor
223
+ validate_ai_settings
224
+ exit 1 unless Doctor.new(ai_settings, json_schema: JSON_SCHEMA).run
225
+
206
226
  else
207
227
  puts "Unknown command: #{@options[:command]}"
208
228
  puts @opt_parser
@@ -212,72 +232,179 @@ module Omamori
212
232
  private
213
233
 
214
234
  def combine_results(ai_result, brakeman_result, bundler_audit_result)
215
- formatted_bundler_audit_result = if bundler_audit_result && bundler_audit_result["results"]
216
- { "scan" => { "results" => bundler_audit_result["results"] } }
235
+ formatted_bundler_audit_result = if bundler_audit_result && bundler_audit_result['results']
236
+ { 'scan' => { 'results' => bundler_audit_result['results'] } }
217
237
  else
218
- { "scan" => { "results" => [] } }
238
+ { 'scan' => { 'results' => [] } }
219
239
  end
220
- combined = {
221
- "ai_security_risks" => ai_result && ai_result["security_risks"] ? ai_result["security_risks"] : [],
222
- "static_analysis_results" => {
223
- "brakeman" => brakeman_result,
224
- "bundler_audit" => formatted_bundler_audit_result
240
+ {
241
+ 'ai_security_risks' => ai_result && ai_result['security_risks'] ? ai_result['security_risks'] : [],
242
+ 'static_analysis_results' => {
243
+ 'brakeman' => brakeman_result,
244
+ 'bundler_audit' => formatted_bundler_audit_result
225
245
  }
226
246
  }
227
- combined
228
247
  end
229
248
 
230
249
  def get_risks_to_check
231
250
  # 設定ファイルからチェック対象のリスクを取得し、シンボルの配列に変換する
232
- # 設定がない場合は DEFAULT_RISKS_TO_CHECK を使用する
233
- configured_checks = @config.get("checks", DEFAULT_RISKS_TO_CHECK)
234
- configured_checks.map(&:to_sym)
251
+ # 設定がない場合は全項目を使う(判定役なしでも、レポート役が全項目を一度に判定する)
252
+ configured_checks = @config.get('checks', AIAnalysisEngine::PromptManager::RISK_PROMPTS.keys).map(&:to_sym)
253
+ configured_checks.filter_map do |risk|
254
+ if (reason = AIAnalysisEngine::PromptManager::RETIRED_RISKS[risk])
255
+ puts "Note: '#{risk}' is no longer checked by omamori. #{reason}"
256
+ next
257
+ end
258
+ AIAnalysisEngine::PromptManager::RISK_ALIASES.fetch(risk, risk)
259
+ end.uniq
260
+ end
261
+
262
+ # Which classifier and reporter to use, from .omamorirc and the command line
263
+ def ai_settings
264
+ @ai_settings ||= AISettings.new(@config, ai_overrides)
265
+ end
266
+
267
+ def ai_overrides
268
+ @ai_overrides ||= {}
269
+ end
270
+
271
+ # Stops before any scanning when the classifier or reporter settings are wrong or missing
272
+ def validate_ai_settings
273
+ return if ai_settings.reporter?
274
+
275
+ puts AISettings.reporter_missing_message
276
+ exit 1
277
+ rescue AISettings::Error => e
278
+ puts "Configuration error: #{e.message}"
279
+ exit 1
280
+ end
281
+
282
+ # The LLM that writes reports (and checks everything itself when there is no classifier)
283
+ def llm_client
284
+ @llm_client ||= ai_settings.build_reporter_client
285
+ end
286
+
287
+ def llm_model
288
+ ai_settings.reporter_model
289
+ end
290
+
291
+ def decision_engine?
292
+ ai_settings.classifier?
293
+ end
294
+
295
+ def checklist_scanner
296
+ AIAnalysisEngine::ChecklistScanner.new(
297
+ decision_client: ai_settings.build_classifier_client,
298
+ llm_client: llm_client,
299
+ prompt_manager: @prompt_manager,
300
+ diff_splitter: @diff_splitter,
301
+ json_schema: JSON_SCHEMA,
302
+ model: llm_model,
303
+ threshold: ai_settings.threshold,
304
+ concurrency: ai_settings.classifier_concurrency,
305
+ report_concurrency: ai_settings.reporter_concurrency,
306
+ sub_checks: ai_settings.sub_checks?
307
+ )
308
+ end
309
+
310
+ def read_units(file_paths)
311
+ file_paths.filter_map do |file_path|
312
+ relative_path = Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
313
+ [relative_path, File.read(file_path)]
314
+ rescue StandardError => e
315
+ puts "Error reading file #{file_path}: #{e.message}"
316
+ nil
317
+ end
235
318
  end
236
319
 
237
320
  def parse_options
238
321
  @opt_parser = OptionParser.new do |opts|
239
- opts.banner = "Usage: omamori [command] [PATH...] [options]"
240
- opts.separator ""
241
- opts.separator "Commands:"
242
- opts.separator " scan [PATH...] [options] : Scan specified files/directories or staged changes"
243
- opts.separator " ci-setup [options] : Generate CI/CD setup files"
244
- opts.separator " init : Generate initial config file (.omamorirc) and .omamoriignore"
245
- opts.separator ""
246
- opts.separator "Scan Options:"
247
- opts.on("--diff", "Scan only the staged differences (default if no PATH is specified)") do
322
+ opts.banner = 'Usage: omamori [command] [PATH...] [options]'
323
+ opts.separator ''
324
+ opts.separator 'Commands:'
325
+ opts.separator ' scan [PATH...] [options] : Scan specified files/directories or staged changes'
326
+ opts.separator ' init --reporter PROVIDER [options]'
327
+ opts.separator ' : Write .omamorirc and .omamoriignore (and a CI file with --ci)'
328
+ opts.separator ' doctor : Send each configured classifier/reporter a small request to check the setup'
329
+ opts.separator ' ci-setup --ci SERVICE : Generate CI/CD setup files from .omamorirc'
330
+ opts.separator ''
331
+ opts.separator 'Scan Options:'
332
+ opts.on('--diff', 'Scan only the staged differences (default if no PATH is specified)') do
248
333
  @options[:scan_mode_explicit] = :diff
249
334
  end
250
- opts.on("--all", "Scan the entire codebase") do
335
+ opts.on('--all', 'Scan the entire codebase') do
251
336
  @options[:scan_mode_explicit] = :all
252
337
  end
253
- opts.on("--format FORMAT", [:console, :html, :json], "Output format (console, html, json)") do |format|
338
+ opts.on('--format FORMAT', %i[console html json], 'Output format (console, html, json)') do |format|
254
339
  @options[:format] = format
255
340
  end
256
- opts.on("--ai", "Run only AI analysis, skipping static analysers") do
341
+ opts.on('--ai', 'Run only AI analysis, skipping static analysers') do
257
342
  @options[:only_ai] = true
258
343
  end
259
- opts.on("--force-scan-ignored", "Force scan files and directories listed in .omamoriignore") do
344
+ opts.on('--base REF', 'Scan the diff between REF and HEAD (e.g. main) instead of staged changes') do |ref|
345
+ @options[:base] = ref
346
+ @options[:scan_mode_explicit] ||= :diff
347
+ end
348
+ opts.on('--classifier PROVIDER',
349
+ 'Classifier that checks each risk as a yes/no question (typesafe, openai, ollama, or none)') do |provider|
350
+ ai_overrides[:classifier] = provider
351
+ end
352
+ opts.on('--classifier-model MODEL', 'Classifier model (e.g. jev-1.13.0, gpt-6-luna)') do |model|
353
+ ai_overrides[:classifier_model] = model
354
+ end
355
+ opts.on('--reporter PROVIDER',
356
+ 'LLM that writes reports: any RubyLLM provider (gemini, anthropic, openai, ...), ' \
357
+ 'or claude_code / codex to use a subscription CLI') do |provider|
358
+ ai_overrides[:reporter] = provider
359
+ end
360
+ opts.on('--reporter-model MODEL', 'Reporter model (e.g. gemini-3.8-flash, claude-sonnet-5-5, sonnet)') do |model|
361
+ ai_overrides[:reporter_model] = model
362
+ end
363
+ opts.on('--reporter-fallback PROVIDER',
364
+ 'Reporter asked only when the reporter refuses or fails (e.g. claude_code), or none') do |provider|
365
+ ai_overrides[:reporter_fallback] = provider
366
+ end
367
+ opts.on('--reporter-fallback-model MODEL', 'Model for the fallback reporter') do |model|
368
+ ai_overrides[:reporter_fallback_model] = model
369
+ end
370
+ # Older options, kept for compatibility
371
+ opts.on('--engine ENGINE', %w[llm gemini jev openai], '(Deprecated) Use --classifier') do |engine|
372
+ ai_overrides[:classifier] = AISettings::LEGACY_ENGINES.fetch(engine, 'none')
373
+ end
374
+ opts.on('--llm LLM', %w[api gemini claude codex], '(Deprecated) Use --reporter') do |llm|
375
+ ai_overrides[:reporter] = AISettings::LEGACY_LLMS[llm] if AISettings::LEGACY_LLMS.key?(llm)
376
+ end
377
+ opts.on('--force-scan-ignored', 'Force scan files and directories listed in .omamoriignore') do
260
378
  @options[:force_scan_ignored] = true
261
379
  end
262
- opts.separator ""
263
- opts.separator "CI Setup Options:"
264
- opts.on("--ci SERVICE", [:github_actions, :gitlab_ci], "Generate setup for specified CI service (github_actions, gitlab_ci)") do |service|
380
+ opts.separator ''
381
+ opts.separator 'Init Options (also --classifier, --classifier-model, --reporter, --reporter-model, ...):'
382
+ opts.on('--language LANG', 'Language of the report details written to .omamorirc (e.g. ja)') do |language|
383
+ @options[:language] = language
384
+ end
385
+ opts.on('--force', 'Overwrite an existing .omamorirc / CI file') do
386
+ @options[:force] = true
387
+ end
388
+ opts.separator ''
389
+ opts.separator 'CI Setup Options:'
390
+ opts.on('--ci SERVICE', %i[github_actions gitlab_ci],
391
+ 'Generate setup for specified CI service (github_actions, gitlab_ci)') do |service|
265
392
  @options[:ci_service] = service
266
393
  end
267
- opts.separator ""
268
- opts.separator "General Options:"
269
- opts.on("-h", "--help", "Prints this help") do
394
+ opts.separator ''
395
+ opts.separator 'General Options:'
396
+ opts.on('-h', '--help', 'Prints this help') do
270
397
  puts opts
271
398
  exit
272
399
  end
273
400
  end
274
401
 
275
- command_candidate = @args.first.to_s.downcase.to_sym
276
- if [:scan, :ci_setup, :init].include?(command_candidate)
277
- @options[:command] = @args.shift.to_sym
278
- else
279
- @options[:command] = :scan # Default command
280
- end
402
+ command_candidate = @args.first.to_s.downcase.tr('-', '_').to_sym
403
+ @options[:command] = if %i[scan ci_setup init doctor].include?(command_candidate)
404
+ @args.shift.tr('-', '_').to_sym
405
+ else
406
+ :scan # Default command
407
+ end
281
408
 
282
409
  begin
283
410
  @opt_parser.parse!(@args) # Parse remaining arguments for options
@@ -291,26 +418,25 @@ module Omamori
291
418
  exit 1
292
419
  end
293
420
 
294
-
295
421
  @target_paths = @args.dup
296
422
 
297
423
  # scan コマンドの場合の scan_mode の決定ロジック
298
- if @options[:command] == :scan
299
- if @options[:scan_mode_explicit]
300
- # --diff または --all が明示的に指定された場合
301
- @options[:scan_mode] = @options[:scan_mode_explicit]
302
- # パス指定があり、かつ --all や --diff もある場合、パス指定を優先する
303
- if !@target_paths.empty? && (@options[:scan_mode] == :all || @options[:scan_mode] == :diff)
304
- puts "Warning: Paths provided with --#{@options[:scan_mode]}. Scanning specified paths instead of full codebase/diff."
305
- @options[:scan_mode] = :paths
306
- end
307
- elsif !@target_paths.empty?
308
- # パス指定があり、--diff や --all がない場合
424
+ return unless @options[:command] == :scan
425
+
426
+ if @options[:scan_mode_explicit]
427
+ # --diff または --all が明示的に指定された場合
428
+ @options[:scan_mode] = @options[:scan_mode_explicit]
429
+ # パス指定があり、かつ --all や --diff もある場合、パス指定を優先する
430
+ if !@target_paths.empty? && %i[all diff].include?(@options[:scan_mode])
431
+ puts "Warning: Paths provided with --#{@options[:scan_mode]}. Scanning specified paths instead of full codebase/diff."
309
432
  @options[:scan_mode] = :paths
310
- else
311
- # パス指定がなく、--diff や --all もない場合 (例: omamori scan, omamori scan --ai)
312
- @options[:scan_mode] = :diff # デフォルトは diff
313
433
  end
434
+ elsif !@target_paths.empty?
435
+ # パス指定があり、--diff や --all がない場合
436
+ @options[:scan_mode] = :paths
437
+ else
438
+ # パス指定がなく、--diff や --all もない場合 (例: omamori scan, omamori scan --ai)
439
+ @options[:scan_mode] = :diff # デフォルトは diff
314
440
  end
315
441
  end
316
442
 
@@ -336,14 +462,12 @@ module Omamori
336
462
  relative_file_path[current_pattern.chomp('/').length] == '/') # ディレクトリ内のファイルにマッチ
337
463
  return !negated # マッチし、かつ否定パターンでなければ無視する
338
464
  end
339
- else
465
+ elsif File.fnmatch(current_pattern, relative_file_path, File::FNM_PATHNAME | File::FNM_DOTMATCH) || # FNM_DOTMATCH で隠しファイルも考慮
466
+ File.fnmatch(current_pattern, File.basename(relative_file_path), File::FNM_PATHNAME | File::FNM_DOTMATCH)
340
467
  # ファイル名またはglobパターンにマッチするか確認
341
468
  # File.fnmatch はシェルのglobのように動作する
342
469
  # File::FNM_PATHNAME は '*' が '/' にマッチしないようにする
343
- if File.fnmatch(current_pattern, relative_file_path, File::FNM_PATHNAME | File::FNM_DOTMATCH) || # FNM_DOTMATCH で隠しファイルも考慮
344
- File.fnmatch(current_pattern, File.basename(relative_file_path), File::FNM_PATHNAME | File::FNM_DOTMATCH) # ファイル名のみでのマッチも考慮
345
- return !negated # マッチし、かつ否定パターンでなければ無視する
346
- end
470
+ return !negated # ファイル名のみでのマッチも考慮
347
471
  end
348
472
  end
349
473
  false # どのパターンにもマッチしなければ無視しない
@@ -355,14 +479,15 @@ module Omamori
355
479
  expanded_path = File.expand_path(path) # パスを絶対パスに展開
356
480
  if File.file?(expanded_path)
357
481
  # ファイルの場合、Rubyファイルであり、かつ無視パターンにマッチしないか確認
358
- if File.extname(expanded_path) == '.rb' && !matches_ignore_pattern?(expanded_path, ignore_patterns, force_scan_ignored)
482
+ if File.extname(expanded_path) == '.rb' && !matches_ignore_pattern?(expanded_path, ignore_patterns,
483
+ force_scan_ignored)
359
484
  collected_files << expanded_path
360
485
  end
361
486
  elsif File.directory?(expanded_path)
362
487
  # ディレクトリの場合、再帰的にRubyファイルを取得し、無視パターンを適用
363
- Dir.glob(File.join(expanded_path, "**", "*.rb")).each do |file_path|
488
+ Dir.glob(File.join(expanded_path, '**', '*.rb')).each do |file_path|
364
489
  abs_file_path = File.expand_path(file_path) # globで見つかったパスも絶対パスに
365
- if !matches_ignore_pattern?(abs_file_path, ignore_patterns, force_scan_ignored)
490
+ unless matches_ignore_pattern?(abs_file_path, ignore_patterns, force_scan_ignored)
366
491
  collected_files << abs_file_path
367
492
  end
368
493
  end
@@ -373,31 +498,38 @@ module Omamori
373
498
  collected_files.uniq # 重複を除いて返す
374
499
  end
375
500
 
501
+ def get_diff
502
+ @options[:base] ? get_branch_diff(@options[:base]) : get_staged_diff
503
+ end
504
+
376
505
  def get_staged_diff
377
506
  `git diff --staged`
378
507
  end
379
508
 
509
+ # Changes on the current branch since it diverged from base (what a pull request contains)
510
+ def get_branch_diff(base)
511
+ `git diff #{Shellwords.escape("#{base}...HEAD")}`
512
+ end
513
+
380
514
  def get_full_codebase
381
- code_content = ""
515
+ code_content = ''
382
516
  ignore_patterns = @config.ignore_patterns
383
517
  force_scan_ignored = @options.fetch(:force_scan_ignored, false)
384
518
  # カレントディレクトリ ('.') 内のRubyファイルを収集
385
519
  files_to_scan = collect_files_from_paths(['.'], ignore_patterns, force_scan_ignored)
386
520
 
387
521
  files_to_scan.each do |file_path|
388
- begin
389
- # 表示用に相対パスを試みるが、エラーなら絶対パスを使用
390
- relative_display_path = begin
391
- Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
392
- rescue ArgumentError
393
- file_path # fallback to absolute path
394
- end
395
- code_content += "# File: #{relative_display_path}\n"
396
- code_content += File.read(file_path)
397
- code_content += "\n\n"
398
- rescue => e
399
- puts "Error reading file #{file_path}: #{e.message}"
522
+ # 表示用に相対パスを試みるが、エラーなら絶対パスを使用
523
+ relative_display_path = begin
524
+ Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
525
+ rescue ArgumentError
526
+ file_path # fallback to absolute path
400
527
  end
528
+ code_content += "# File: #{relative_display_path}\n"
529
+ code_content += File.read(file_path)
530
+ code_content += "\n\n"
531
+ rescue StandardError => e
532
+ puts "Error reading file #{file_path}: #{e.message}"
401
533
  end
402
534
  code_content
403
535
  end
@@ -407,251 +539,92 @@ module Omamori
407
539
  when :console
408
540
  puts @console_formatter.format(combined_results)
409
541
  when :html
410
- report_config = @config.get("report", {})
411
- output_path_prefix = report_config.fetch("output_path", "./omamori_report")
542
+ report_config = @config.get('report', {})
543
+ output_path_prefix = report_config.fetch('output_path', './omamori_report')
412
544
  output_path = "#{output_path_prefix}.html"
413
545
  File.write(output_path, @html_formatter.format(combined_results))
414
546
  puts "HTML report generated: #{output_path}"
415
547
  when :json
416
- report_config = @config.get("report", {})
417
- output_path_prefix = report_config.fetch("output_path", "./omamori_report")
548
+ report_config = @config.get('report', {})
549
+ output_path_prefix = report_config.fetch('output_path', './omamori_report')
418
550
  output_path = "#{output_path_prefix}.json"
419
551
  File.write(output_path, @json_formatter.format(combined_results))
420
552
  puts "JSON report generated: #{output_path}"
421
553
  end
422
554
  end
423
555
 
424
- def generate_ci_setup(ci_service)
556
+ def generate_ci_setup(ci_service, settings = ai_settings)
425
557
  case ci_service
426
558
  when :github_actions
427
- generate_github_actions_workflow
559
+ generate_github_actions_workflow(settings)
428
560
  when :gitlab_ci
429
- generate_gitlab_ci_workflow
561
+ generate_gitlab_ci_workflow(settings)
430
562
  else
431
563
  puts "Unsupported CI service: #{ci_service}. Supported: github_actions, gitlab_ci"
432
564
  puts @opt_parser # ヘルプメッセージを表示
433
565
  end
434
566
  end
435
567
 
436
- def generate_github_actions_workflow
437
- workflow_content = <<~YAML
438
- # .github/workflows/omamori_scan.yml
439
- name: Omamori Security Scan
440
-
441
- on: [push, pull_request]
442
-
443
- jobs:
444
- security_scan:
445
- runs-on: ubuntu-latest
446
-
447
- steps:
448
- - name: Checkout code
449
- uses: actions/checkout@v4 # Recommended to use specific version
450
-
451
- - name: Set up Ruby
452
- uses: ruby/setup-ruby@v1 # Recommended to use specific version
453
- with:
454
- ruby-version: '3.0' # Specify your project's Ruby version
455
-
456
- - name: Install dependencies
457
- run: bundle install
458
-
459
- # Optional: Cache gems to speed up future builds
460
- # - name: Cache gems
461
- # uses: actions/cache@v3
462
- # with:
463
- # path: vendor/bundle
464
- # key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
465
- # restore-keys: |
466
- # ${{ runner.os }}-gems-
467
-
468
- - name: Install Brakeman (if not in Gemfile)
469
- run: gem install brakeman --no-document || true
470
-
471
- - name: Install Bundler-Audit (if not in Gemfile)
472
- run: gem install bundler-audit --no-document || true
473
-
474
- - name: Run Omamori Scan
475
- env:
476
- GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} # Ensure GEMINI_API_KEY is set in GitHub Secrets
477
- # Example: Scan all files on push to main, diff on PRs
478
- # This logic might need adjustment based on your workflow preference
479
- run: |
480
- if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
481
- bundle exec omamori scan --diff --format console
482
- else
483
- bundle exec omamori scan --all --format console
484
- fi
485
- YAML
486
- ci_config = @config.get("ci_setup", {})
487
- output_path = ci_config.fetch("github_actions_path", ".github/workflows/omamori_scan.yml")
488
- FileUtils.mkdir_p(File.dirname(output_path)) # Ensure directory exists
489
- File.write(output_path, workflow_content)
490
- puts "GitHub Actions workflow generated: #{output_path}"
568
+ def generate_github_actions_workflow(settings = ai_settings)
569
+ path = @config.get('ci_setup', {}).fetch('github_actions_path', '.github/workflows/omamori_scan.yml')
570
+ write_setup_file(path, SetupFiles.github_actions(settings), 'GitHub Actions workflow')
571
+ puts "Register these as GitHub secrets: #{SetupFiles.secret_names(settings).join(', ')}" if SetupFiles.secret_names(settings).any?
572
+ puts "Note: #{SetupFiles::CLI_IN_CI_NOTE}" if SetupFiles.cli_providers(settings).any?
491
573
  end
492
574
 
493
- def generate_gitlab_ci_workflow
494
- workflow_content = <<~YAML
495
- # .gitlab-ci.yml
496
- stages:
497
- - security_scan
498
-
499
- omamori_security_scan:
500
- stage: security_scan
501
- image: ruby:3.0 # Specify your project's Ruby version
502
- # Cache gems
503
- cache:
504
- key:
505
- files:
506
- - Gemfile.lock
507
- paths:
508
- - vendor/bundle
509
- before_script:
510
- - apt-get update -qq && apt-get install -y --no-install-recommends nodejs # If needed for JS runtime
511
- - gem install bundler --no-document
512
- - bundle install --jobs $(nproc) --retry 3 --path vendor/bundle
513
- - gem install brakeman --no-document || true
514
- - gem install bundler-audit --no-document || true
515
- script:
516
- # Example: Scan all files on pipelines for the default branch, diff on merge requests
517
- - |
518
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
519
- bundle exec omamori scan --diff --format console
520
- else
521
- bundle exec omamori scan --all --format console
522
- fi
523
- variables:
524
- GEMINI_API_KEY: $GEMINI_API_KEY # Set GEMINI_API_KEY as a CI/CD variable in GitLab
525
- rules:
526
- - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
527
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
528
- YAML
529
- ci_config = @config.get("ci_setup", {})
530
- output_path = ci_config.fetch("gitlab_ci_path", ".gitlab-ci.yml")
531
- File.write(output_path, workflow_content)
532
- puts "GitLab CI workflow generated: #{output_path}"
575
+ def generate_gitlab_ci_workflow(settings = ai_settings)
576
+ path = @config.get('ci_setup', {}).fetch('gitlab_ci_path', '.gitlab-ci.yml')
577
+ write_setup_file(path, SetupFiles.gitlab_ci(settings), 'GitLab CI workflow')
578
+ puts "Register these as masked CI/CD variables: #{SetupFiles.secret_names(settings).join(', ')}" if SetupFiles.secret_names(settings).any?
579
+ puts "Note: #{SetupFiles::CLI_IN_CI_NOTE}" if SetupFiles.cli_providers(settings).any?
533
580
  end
534
581
 
535
- DEFAULT_OMAMORIIGNORE_CONTENT = <<~IGNORE
536
- # Omamori ignore file
537
- # Add files and directories to ignore during Omamori scans.
538
- # Lines starting with # are comments.
539
- # Globs are supported (e.g., *.tmp, spec/fixtures/)
540
- # Negation with ! (e.g., !important.log) - not fully implemented in current basic matcher
541
-
542
- # Log files
543
- log/
544
- *.log
582
+ # Settings from the command line only, so `init` does not depend on an existing .omamorirc
583
+ EMPTY_CONFIG = Struct.new(:values) { def get(key, default = nil) = values.fetch(key.to_s, default) }.new({})
545
584
 
546
- # Temporary files
547
- tmp/
548
- *.tmp
549
- *.swp
550
- *.swo
551
-
552
- # OS-specific files
553
- .DS_Store
554
- Thumbs.db
555
-
556
- # Vendor directory (often contains third-party code)
557
- vendor/bundle/
558
-
559
- # Coverage reports
560
- coverage/
561
-
562
- # Node.js dependencies
563
- node_modules/
564
-
565
- # Build artifacts
566
- pkg/
567
-
568
- # Test files and fixtures (optional, consider if they contain sensitive examples)
569
- # spec/
570
- # test/
571
- # features/
585
+ # `omamori init --reporter PROVIDER [--reporter-model MODEL] [--classifier PROVIDER] [--language LANG] [--ci SERVICE]`
586
+ def generate_initial_files
587
+ settings = init_settings
588
+ ok = write_setup_file(Omamori::Config::DEFAULT_CONFIG_PATH,
589
+ SetupFiles.omamorirc(settings, language: @options[:language]), 'Config file')
590
+ unless File.exist?(Omamori::Config::DEFAULT_IGNORE_PATH)
591
+ File.write(Omamori::Config::DEFAULT_IGNORE_PATH, SetupFiles::OMAMORIIGNORE)
592
+ puts ".omamoriignore file generated: #{Omamori::Config::DEFAULT_IGNORE_PATH}"
593
+ end
594
+ exit 1 unless ok
595
+ generate_ci_setup(@options[:ci_service], settings) if @options[:ci_service]
596
+
597
+ puts ''
598
+ puts 'Next steps:'
599
+ SetupFiles.credential_steps(settings).each { |step| puts " - #{step}" }
600
+ puts ' - Run `omamori doctor` to check the setup'
601
+ puts ' - Run `omamori scan --base main` to scan a branch'
602
+ end
572
603
 
573
- # Database schema and migrations (usually not directly exploitable via code injection)
574
- # db/schema.rb
575
- # db/migrate/
604
+ def init_settings
605
+ unless ai_overrides[:reporter]
606
+ puts AISettings.reporter_missing_message(command: 'init')
607
+ puts "Reporter providers: claude_code, codex, or a RubyLLM provider (#{RubyLLM::Provider.providers.keys.join(', ')})"
608
+ exit 1
609
+ end
576
610
 
577
- # Assets (compiled or static, less likely to have Ruby vulnerabilities)
578
- # app/assets/builds/
579
- # public/assets/
580
- IGNORE
611
+ AISettings.new(EMPTY_CONFIG, ai_overrides)
612
+ rescue AISettings::Error => e
613
+ puts "Configuration error: #{e.message}"
614
+ exit 1
615
+ end
581
616
 
582
- def generate_initial_files
583
- config_content = <<~YAML
584
- # .omamorirc
585
- # Configuration file for omamori gem
586
-
587
- # Gemini API Key (required for AI analysis)
588
- # You can also set this via the GEMINI_API_KEY environment variable.
589
- # Example: api_key: "YOUR_GEMINI_API_KEY_HERE"
590
- api_key: YOUR_GEMINI_API_KEY
591
-
592
- # Gemini Model to use (optional, default: g emini-2.5-flash-preview-04-17)
593
- # Example: model: "gemini-2.5-pro-preview-05-06"
594
- # model: "gemini-2.5-flash-preview-04-17"
595
-
596
- # Security checks to enable (optional, default: all implemented checks).
597
- # Provide a list of symbols. Example:
598
- # checks:
599
- # - xss
600
- # - csrf
601
- # - idor
602
- # - open_redirect
603
- # # Add other risk symbols from Omamori::AIAnalysisEngine::PromptManager::RISK_PROMPTS.keys
604
-
605
- # Custom prompt templates (optional).
606
- # prompt_templates:
607
- # default: |
608
- # Analyze the following Ruby code for security vulnerabilities.
609
- # Focus on: %{risk_list}.
610
- # Report in JSON format: %{json_schema}.
611
- # Code:
612
- # %{code_content}
613
-
614
- # Report output settings (optional).
615
- # report:
616
- # output_path: "./omamori_scan_results" # Prefix for html/json reports
617
- # html_template: "custom_report_template.erb" # Path to custom ERB template
618
-
619
- # Static analyser options (optional).
620
- # Provide options as a hash.
621
- # static_analysers:
622
- # brakeman:
623
- # options: {"--skip-checks": "BasicAuth", "--no-progress": true}
624
- # bundler_audit:
625
- # options: {quiet: true}
626
-
627
- # Language for AI analysis details (optional, default: "en").
628
- # Supported languages depend on the AI model.
629
- # language: "ja"
630
-
631
- # Chunk size for splitting large code content for AI analysis (optional, default: 8000 characters).
632
- # chunk_size: 10000
633
-
634
- # CI setup file paths (optional).
635
- # ci_setup:
636
- # github_actions_path: ".github/workflows/custom_omamori_scan.yml"
637
- # gitlab_ci_path: ".custom-gitlab-ci.yml"
638
- YAML
639
- config_output_path = Omamori::Config::DEFAULT_CONFIG_PATH
640
- if File.exist?(config_output_path)
641
- puts "Config file already exists at #{config_output_path}. Aborting .omamorirc generation."
642
- else
643
- File.write(config_output_path, config_content)
644
- puts "Config file generated: #{config_output_path}"
645
- puts "IMPORTANT: Please open #{config_output_path} and replace 'YOUR_GEMINI_API_KEY' with your actual Gemini API key."
617
+ # Writes a file unless it exists (or --force). Returns false when it was left as it was.
618
+ def write_setup_file(path, content, label)
619
+ if File.exist?(path) && !@options[:force]
620
+ puts "#{label} already exists at #{path}. Use --force to overwrite it."
621
+ return false
646
622
  end
647
623
 
648
- ignore_output_path = Omamori::Config::DEFAULT_IGNORE_PATH
649
- if File.exist?(ignore_output_path)
650
- puts ".omamoriignore file already exists at #{ignore_output_path}. Aborting .omamoriignore generation."
651
- else
652
- File.write(ignore_output_path, DEFAULT_OMAMORIIGNORE_CONTENT)
653
- puts ".omamoriignore file generated: #{ignore_output_path}"
654
- end
624
+ FileUtils.mkdir_p(File.dirname(path))
625
+ File.write(path, content)
626
+ puts "#{label} generated: #{path}"
627
+ true
655
628
  end
656
629
  end
657
630
  end