fantasy-cli 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. metadata +231 -0
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Gsd
6
+ module AI
7
+ # Cost Tracker - Rastreamento de custos e tokens em tempo real
8
+ #
9
+ # Responsável por:
10
+ # - Contagem precisa de tokens por provider
11
+ # - Cálculo de custos em tempo real
12
+ # - Budget tracking com alertas
13
+ # - Histórico de usage
14
+ # - Relatórios de custos
15
+ class CostTracker
16
+ attr_reader :total_cost, :total_tokens, :budget, :alerts_enabled
17
+
18
+ # Inicializa o cost tracker
19
+ #
20
+ # @param budget [Float] Orçamento mensal em USD
21
+ # @param alerts_enabled [Boolean] Habilitar alertas
22
+ # @param persist_dir [String] Diretório para persistência
23
+ def initialize(budget: 100.0, alerts_enabled: true, persist_dir: nil)
24
+ @budget = budget
25
+ @alerts_enabled = alerts_enabled
26
+ @persist_dir = persist_dir
27
+ @total_cost = 0.0
28
+ @total_tokens = 0
29
+ @usage_history = []
30
+ @alerts_triggered = []
31
+ @providers = {} # Pricing por provider
32
+
33
+ load_from_disk if @persist_dir
34
+ initialize_pricing
35
+ end
36
+
37
+ # Track uso de tokens e custo
38
+ #
39
+ # @param usage [Hash] Dados de uso (input_tokens, output_tokens)
40
+ # @param provider [String] Nome do provider
41
+ # @param model [String] Modelo usado
42
+ # @return [Hash] Custo e tokens trackeados
43
+ def track(usage, provider:, model:)
44
+ input_tokens = usage[:input_tokens] || usage['input_tokens'] || 0
45
+ output_tokens = usage[:output_tokens] || usage['output_tokens'] || 0
46
+ total_tokens = input_tokens + output_tokens
47
+
48
+ # Calcula custo
49
+ pricing = get_pricing(provider, model)
50
+ input_cost = (input_tokens.to_f / 1_000_000) * pricing[:input_price]
51
+ output_cost = (output_tokens.to_f / 1_000_000) * pricing[:output_price]
52
+ total_cost = input_cost + output_cost
53
+
54
+ # Atualiza totais
55
+ @total_tokens += total_tokens
56
+ @total_cost += total_cost
57
+
58
+ # Adiciona ao histórico
59
+ usage_record = {
60
+ timestamp: Time.now.iso8601,
61
+ provider: provider,
62
+ model: model,
63
+ input_tokens: input_tokens,
64
+ output_tokens: output_tokens,
65
+ total_tokens: total_tokens,
66
+ input_cost: input_cost,
67
+ output_cost: output_cost,
68
+ total_cost: total_cost
69
+ }
70
+ @usage_history << usage_record
71
+
72
+ # Verifica alertas
73
+ check_alerts if @alerts_enabled
74
+
75
+ # Persiste se configurado
76
+ save_to_disk if @persist_dir
77
+
78
+ usage_record
79
+ end
80
+
81
+ # Define orçamento
82
+ #
83
+ # @param budget [Float] Orçamento em USD
84
+ # @return [void]
85
+ def budget=(budget)
86
+ @budget = budget
87
+ check_alerts if @alerts_enabled
88
+ end
89
+
90
+ # Habilita/desabilita alertas
91
+ #
92
+ # @param enabled [Boolean] Habilitar alertas
93
+ # @return [void]
94
+ def alerts_enabled=(enabled)
95
+ @alerts_enabled = enabled
96
+ end
97
+
98
+ # Registra provider com pricing
99
+ #
100
+ # @param provider [String] Nome do provider
101
+ # @param models [Hash] Modelos com preços
102
+ # @return [void]
103
+ def register_provider(provider, models)
104
+ @providers[provider] = models
105
+ end
106
+
107
+ # Retorna estatísticas de uso
108
+ #
109
+ # @return [Hash] Estatísticas
110
+ def stats
111
+ {
112
+ total_cost: @total_cost,
113
+ total_tokens: @total_tokens,
114
+ budget: @budget,
115
+ budget_remaining: [@budget - @total_cost, 0].max,
116
+ budget_used_percent: (@total_cost / @budget * 100).round(2),
117
+ requests_count: @usage_history.count,
118
+ avg_cost_per_request: @usage_history.any? ? @total_cost / @usage_history.count : 0,
119
+ alerts_triggered_count: @alerts_triggered.count
120
+ }
121
+ end
122
+
123
+ # Retorna histórico de uso
124
+ #
125
+ # @param limit [Integer] Limite de registros
126
+ # @return [Array] Histórico
127
+ def history(limit: 100)
128
+ @usage_history.last(limit)
129
+ end
130
+
131
+ # Retorna alertas disparados
132
+ #
133
+ # @return [Array] Alertas
134
+ def alerts
135
+ @alerts_triggered
136
+ end
137
+
138
+ # Limpa histórico e totais
139
+ #
140
+ # @return [void]
141
+ def reset
142
+ @total_cost = 0.0
143
+ @total_tokens = 0
144
+ @usage_history = []
145
+ @alerts_triggered = []
146
+ save_to_disk if @persist_dir
147
+ end
148
+
149
+ # Exporta dados para JSON
150
+ #
151
+ # @return [String] JSON
152
+ def to_json
153
+ JSON.pretty_generate({
154
+ total_cost: @total_cost,
155
+ total_tokens: @total_tokens,
156
+ budget: @budget,
157
+ usage_history: @usage_history,
158
+ alerts_triggered: @alerts_triggered,
159
+ exported_at: Time.now.iso8601
160
+ })
161
+ end
162
+
163
+ # Importa dados de JSON
164
+ #
165
+ # @param json [String] JSON
166
+ # @return [void]
167
+ def from_json(json)
168
+ data = JSON.parse(json, symbolize_names: true)
169
+ @total_cost = data[:total_cost] || 0.0
170
+ @total_tokens = data[:total_tokens] || 0
171
+ @budget = data[:budget] || 100.0
172
+ @usage_history = data[:usage_history] || []
173
+ @alerts_triggered = data[:alerts_triggered] || []
174
+ end
175
+
176
+ private
177
+
178
+ # Inicializa pricing dos providers
179
+ #
180
+ # @return [void]
181
+ def initialize_pricing
182
+ # Anthropic
183
+ @providers['anthropic'] = {
184
+ 'claude-sonnet-4-5-20250929' => { input_price: 3.0, output_price: 15.0 },
185
+ 'claude-3-opus-20240229' => { input_price: 15.0, output_price: 75.0 },
186
+ 'claude-3-haiku-20240307' => { input_price: 0.25, output_price: 1.25 }
187
+ }
188
+
189
+ # OpenAI
190
+ @providers['openai'] = {
191
+ 'gpt-4-turbo-preview' => { input_price: 10.0, output_price: 30.0 },
192
+ 'gpt-4-vision-preview' => { input_price: 10.0, output_price: 30.0 },
193
+ 'gpt-4' => { input_price: 30.0, output_price: 60.0 },
194
+ 'gpt-3.5-turbo' => { input_price: 0.5, output_price: 1.5 }
195
+ }
196
+
197
+ # OpenRouter (Qwen, Llama, Mistral - gratuitos)
198
+ @providers['openrouter'] = {
199
+ 'qwen/qwen-3.6-plus-preview:free' => { input_price: 0.0, output_price: 0.0 },
200
+ 'qwen/qwen-2.5-72b-instruct' => { input_price: 0.0, output_price: 0.0 },
201
+ 'qwen/qwen-2-7b-instruct' => { input_price: 0.0, output_price: 0.0 },
202
+ 'meta-llama/llama-3-8b-instruct' => { input_price: 0.0, output_price: 0.0 },
203
+ 'meta-llama/llama-3-70b-instruct' => { input_price: 0.0, output_price: 0.0 },
204
+ 'mistralai/mistral-7b-instruct' => { input_price: 0.0, output_price: 0.0 },
205
+ 'google/gemma-7b-it' => { input_price: 0.0, output_price: 0.0 }
206
+ }
207
+
208
+ # Ollama (local, gratuito)
209
+ @providers['ollama'] = {
210
+ '*' => { input_price: 0.0, output_price: 0.0 }
211
+ }
212
+
213
+ # LM Studio (local, gratuito)
214
+ @providers['lmstudio'] = {
215
+ '*' => { input_price: 0.0, output_price: 0.0 }
216
+ }
217
+ end
218
+
219
+ # Obtém pricing para provider/modelo
220
+ #
221
+ # @param provider [String] Nome do provider
222
+ # @param model [String] Modelo
223
+ # @return [Hash] Pricing
224
+ def get_pricing(provider, model)
225
+ provider_models = @providers[provider&.downcase]
226
+ return { input_price: 0, output_price: 0 } unless provider_models
227
+
228
+ # Tenta encontrar modelo exato
229
+ pricing = provider_models[model] || provider_models[model&.downcase]
230
+
231
+ # Fallback para wildcard
232
+ pricing ||= provider_models['*']
233
+
234
+ # Default gratuito
235
+ pricing || { input_price: 0, output_price: 0 }
236
+ end
237
+
238
+ # Verifica e dispara alertas
239
+ #
240
+ # @return [void]
241
+ def check_alerts
242
+ usage_percent = (@total_cost / @budget * 100)
243
+
244
+ # Alertas em 50%, 75%, 90%, 100%
245
+ thresholds = [50, 75, 90, 100]
246
+
247
+ thresholds.each do |threshold|
248
+ alert_key = "budget_#{threshold}"
249
+
250
+ # Verifica se já disparou este alerta
251
+ next if @alerts_triggered.any? { |a| a[:key] == alert_key }
252
+
253
+ if usage_percent >= threshold
254
+ alert = {
255
+ key: alert_key,
256
+ threshold: threshold,
257
+ usage_percent: usage_percent.round(2),
258
+ total_cost: @total_cost,
259
+ budget: @budget,
260
+ timestamp: Time.now.iso8601,
261
+ message: "Orçamento: #{usage_percent.round(1)}% utilizado ($#{@total_cost.round(2)} de $#{@budget})"
262
+ }
263
+
264
+ @alerts_triggered << alert
265
+ trigger_alert(alert)
266
+ end
267
+ end
268
+ end
269
+
270
+ # Dispara alerta
271
+ #
272
+ # @param alert [Hash] Dados do alerta
273
+ # @return [void]
274
+ def trigger_alert(alert)
275
+ puts "\n⚠️ ALERTA DE CUSTO: #{alert[:message]}"
276
+
277
+ # Aqui poderia integrar com webhook, email, etc.
278
+ # Exemplo: enviar para Discord, Slack, etc.
279
+ end
280
+
281
+ # Salva dados em disco
282
+ #
283
+ # @return [void]
284
+ def save_to_disk
285
+ return unless @persist_dir
286
+
287
+ FileUtils.mkdir_p(@persist_dir)
288
+ file = File.join(@persist_dir, 'cost_tracker.json')
289
+ File.write(file, to_json)
290
+ end
291
+
292
+ # Carrega dados do disco
293
+ #
294
+ # @return [void]
295
+ def load_from_disk
296
+ return unless @persist_dir
297
+
298
+ file = File.join(@persist_dir, 'cost_tracker.json')
299
+ return unless File.exist?(file)
300
+
301
+ json = File.read(file)
302
+ from_json(json)
303
+ rescue => e
304
+ warn "[CostTracker] Erro ao carregar: #{e.message}"
305
+ end
306
+ end
307
+
308
+ # Budget Manager - Gerenciamento avançado de orçamento
309
+ class BudgetManager
310
+ attr_reader :trackers
311
+
312
+ # Inicializa o budget manager
313
+ #
314
+ # @param global_budget [Float] Orçamento global
315
+ def initialize(global_budget: 500.0)
316
+ @global_budget = global_budget
317
+ @trackers = {} # Trackers por projeto/contexto
318
+ @allocations = {} # Alocações por projeto
319
+ end
320
+
321
+ # Cria tracker para projeto
322
+ #
323
+ # @param project [String] Nome do projeto
324
+ # @param budget [Float] Orçamento do projeto
325
+ # @return [CostTracker] Tracker criado
326
+ def create_tracker(project, budget: nil)
327
+ project_budget = budget || @allocations[project] || (@global_budget * 0.2) # 20% default
328
+
329
+ tracker = CostTracker.new(budget: project_budget)
330
+ @trackers[project] = tracker
331
+ tracker
332
+ end
333
+
334
+ # Aloca orçamento para projeto
335
+ #
336
+ # @param project [String] Nome do projeto
337
+ # @param amount [Float] Valor alocado
338
+ # @return [void]
339
+ def allocate(project, amount)
340
+ @allocations[project] = amount
341
+ @trackers[project]&.budget = amount if @trackers[project]
342
+ end
343
+
344
+ # Retorna visão geral de todos projetos
345
+ #
346
+ # @return [Hash] Visão geral
347
+ def overview
348
+ total_allocated = @allocations.values.sum
349
+ total_spent = @trackers.values.sum(&:total_cost)
350
+
351
+ {
352
+ global_budget: @global_budget,
353
+ total_allocated: total_allocated,
354
+ total_spent: total_spent,
355
+ remaining: @global_budget - total_spent,
356
+ projects: @trackers.transform_values(&:stats)
357
+ }
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module AI
5
+ # GitContext - Coleta informações de status do Git
6
+ #
7
+ # Responsável por:
8
+ # - Branch atual
9
+ # - Status (clean/dirty)
10
+ # - Files modificados
11
+ # - Ahead/behind do remote
12
+ class GitContext
13
+ attr_reader :cwd
14
+
15
+ # Inicializa o GitContext
16
+ #
17
+ # @param cwd [String] Diretório de trabalho
18
+ def initialize(cwd: Dir.pwd)
19
+ @cwd = cwd
20
+ end
21
+
22
+ # Coleta informações do Git
23
+ #
24
+ # @return [Hash] Informações do Git
25
+ def gather
26
+ return nil unless git_available?
27
+
28
+ {
29
+ branch: current_branch,
30
+ clean: clean?,
31
+ modified_files: modified_files,
32
+ staged_files: staged_files,
33
+ untracked_files: untracked_files,
34
+ ahead_behind: ahead_behind,
35
+ last_commit: last_commit
36
+ }
37
+ end
38
+
39
+ # Branch atual
40
+ #
41
+ # @return [String,nil] Nome da branch ou nil
42
+ def current_branch
43
+ return nil unless git_available?
44
+
45
+ branch = git_command('rev-parse --abbrev-ref HEAD')
46
+ branch&.strip
47
+ end
48
+
49
+ # Verifica se o working tree está limpo
50
+ #
51
+ # @return [Boolean] true se limpo
52
+ def clean?
53
+ return true unless git_available?
54
+
55
+ status = git_command('status --porcelain')
56
+ status.nil? || status.strip.empty?
57
+ end
58
+
59
+ # Lista files modificados
60
+ #
61
+ # @return [Array<String>] Lista de files
62
+ def modified_files
63
+ return [] unless git_available?
64
+
65
+ status = git_command('status --porcelain')
66
+ return [] if status.nil?
67
+
68
+ status.lines
69
+ .select { |l| l.start_with?(' M', 'M ') }
70
+ .map { |l| l[3..]&.strip }
71
+ .compact
72
+ end
73
+
74
+ # Lista files staged
75
+ #
76
+ # @return [Array<String>] Lista de files
77
+ def staged_files
78
+ return [] unless git_available?
79
+
80
+ status = git_command('status --porcelain')
81
+ return [] if status.nil?
82
+
83
+ status.lines
84
+ .select { |l| l.start_with?('A ', 'M ') }
85
+ .map { |l| l[3..]&.strip }
86
+ .compact
87
+ end
88
+
89
+ # Lista files não rastreados
90
+ #
91
+ # @return [Array<String>] Lista de files
92
+ def untracked_files
93
+ return [] unless git_available?
94
+
95
+ status = git_command('status --porcelain')
96
+ return [] if status.nil?
97
+
98
+ status.lines
99
+ .select { |l| l.start_with?('??') }
100
+ .map { |l| l[3..]&.strip }
101
+ .compact
102
+ end
103
+
104
+ # Ahead/behind do remote
105
+ #
106
+ # @return [Hash] {ahead: int, behind: int}
107
+ def ahead_behind
108
+ return { ahead: 0, behind: 0 } unless git_available?
109
+
110
+ # Verifica se tem upstream configurado
111
+ upstream = git_command('rev-parse --abbrev-ref --symbolic-full-name @{upstream} 2>nul')
112
+ return { ahead: 0, behind: 0 } if upstream.nil? || upstream.strip.empty?
113
+
114
+ # Conta commits ahead/behind
115
+ counts = git_command('rev-list --left-right --count HEAD...@{upstream} 2>nul')
116
+ return { ahead: 0, behind: 0 } if counts.nil?
117
+
118
+ ahead, behind = counts.strip.split.map(&:to_i)
119
+ { ahead: ahead || 0, behind: behind || 0 }
120
+ rescue
121
+ { ahead: 0, behind: 0 }
122
+ end
123
+
124
+ # Último commit
125
+ #
126
+ # @return [Hash,nil] Informações do último commit
127
+ def last_commit
128
+ return nil unless git_available?
129
+
130
+ hash = git_command('rev-parse --short HEAD')
131
+ return nil if hash.nil?
132
+
133
+ message = git_command('log -1 --pretty=%s')
134
+ author = git_command('log -1 --pretty=%an')
135
+ date = git_command('log -1 --pretty=%ar')
136
+
137
+ {
138
+ hash: hash&.strip,
139
+ message: message&.strip,
140
+ author: author&.strip,
141
+ date: date&.strip
142
+ }
143
+ end
144
+
145
+ private
146
+
147
+ # Verifica se Git está disponível
148
+ #
149
+ # @return [Boolean] true se disponível
150
+ def git_available?
151
+ return @git_available if defined?(@git_available)
152
+
153
+ @git_available = File.directory?(File.join(@cwd, '.git'))
154
+ end
155
+
156
+ # Executa comando git
157
+ #
158
+ # @param args [String] Argumentos do comando
159
+ # @return [String,nil] Output ou nil se falhar
160
+ def git_command(args)
161
+ cmd = "git -C #{@cwd} #{args} 2>nul"
162
+ output = `#{cmd}`
163
+ $?.exitstatus == 0 ? output : nil
164
+ rescue
165
+ nil
166
+ end
167
+ end
168
+ end
169
+ end