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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/bin/gsd +8 -0
- data/bin/gsd-core-darwin-amd64 +0 -0
- data/bin/gsd-core-darwin-arm64 +0 -0
- data/bin/gsd-core-linux-amd64 +0 -0
- data/bin/gsd-core-linux-arm64 +0 -0
- data/bin/gsd-core-windows-amd64.exe +0 -0
- data/bin/gsd-core-windows-arm64.exe +0 -0
- data/bin/gsd-core.exe +0 -0
- data/lib/gsd/agents/coordinator.rb +195 -0
- data/lib/gsd/agents/task_manager.rb +158 -0
- data/lib/gsd/agents/worker.rb +162 -0
- data/lib/gsd/agents.rb +30 -0
- data/lib/gsd/ai/chat.rb +486 -0
- data/lib/gsd/ai/cli.rb +248 -0
- data/lib/gsd/ai/command_parser.rb +97 -0
- data/lib/gsd/ai/commands/base.rb +42 -0
- data/lib/gsd/ai/commands/clear.rb +20 -0
- data/lib/gsd/ai/commands/context.rb +30 -0
- data/lib/gsd/ai/commands/cost.rb +30 -0
- data/lib/gsd/ai/commands/export.rb +42 -0
- data/lib/gsd/ai/commands/help.rb +61 -0
- data/lib/gsd/ai/commands/model.rb +67 -0
- data/lib/gsd/ai/commands/reset.rb +22 -0
- data/lib/gsd/ai/config.rb +256 -0
- data/lib/gsd/ai/context.rb +324 -0
- data/lib/gsd/ai/cost_tracker.rb +361 -0
- data/lib/gsd/ai/git_context.rb +169 -0
- data/lib/gsd/ai/history.rb +384 -0
- data/lib/gsd/ai/providers/anthropic.rb +429 -0
- data/lib/gsd/ai/providers/base.rb +282 -0
- data/lib/gsd/ai/providers/lmstudio.rb +279 -0
- data/lib/gsd/ai/providers/ollama.rb +336 -0
- data/lib/gsd/ai/providers/openai.rb +396 -0
- data/lib/gsd/ai/providers/openrouter.rb +429 -0
- data/lib/gsd/ai/reference_resolver.rb +225 -0
- data/lib/gsd/ai/repl.rb +349 -0
- data/lib/gsd/ai/streaming.rb +438 -0
- data/lib/gsd/ai/ui.rb +429 -0
- data/lib/gsd/buddy/cli.rb +284 -0
- data/lib/gsd/buddy/gacha.rb +148 -0
- data/lib/gsd/buddy/renderer.rb +108 -0
- data/lib/gsd/buddy/species.rb +190 -0
- data/lib/gsd/buddy/stats.rb +156 -0
- data/lib/gsd/buddy.rb +28 -0
- data/lib/gsd/cli.rb +455 -0
- data/lib/gsd/commands.rb +198 -0
- data/lib/gsd/config.rb +183 -0
- data/lib/gsd/error.rb +188 -0
- data/lib/gsd/frontmatter.rb +123 -0
- data/lib/gsd/go/bridge.rb +173 -0
- data/lib/gsd/history.rb +76 -0
- data/lib/gsd/milestone.rb +75 -0
- data/lib/gsd/output.rb +184 -0
- data/lib/gsd/phase.rb +102 -0
- data/lib/gsd/plugins/base.rb +92 -0
- data/lib/gsd/plugins/cli.rb +330 -0
- data/lib/gsd/plugins/config.rb +164 -0
- data/lib/gsd/plugins/hooks.rb +132 -0
- data/lib/gsd/plugins/installer.rb +158 -0
- data/lib/gsd/plugins/loader.rb +122 -0
- data/lib/gsd/plugins/manager.rb +187 -0
- data/lib/gsd/plugins/marketplace.rb +142 -0
- data/lib/gsd/plugins/sandbox.rb +114 -0
- data/lib/gsd/plugins/search.rb +131 -0
- data/lib/gsd/plugins/validator.rb +157 -0
- data/lib/gsd/plugins.rb +48 -0
- data/lib/gsd/profile.rb +127 -0
- data/lib/gsd/research.rb +85 -0
- data/lib/gsd/roadmap.rb +90 -0
- data/lib/gsd/skills/bundled/commit.md +58 -0
- data/lib/gsd/skills/bundled/debug.md +28 -0
- data/lib/gsd/skills/bundled/explain.md +41 -0
- data/lib/gsd/skills/bundled/plan.md +42 -0
- data/lib/gsd/skills/bundled/verify.md +26 -0
- data/lib/gsd/skills/loader.rb +189 -0
- data/lib/gsd/state.rb +102 -0
- data/lib/gsd/template.rb +106 -0
- data/lib/gsd/tools/ask_user_question.rb +179 -0
- data/lib/gsd/tools/base.rb +204 -0
- data/lib/gsd/tools/bash.rb +246 -0
- data/lib/gsd/tools/file_edit.rb +297 -0
- data/lib/gsd/tools/file_read.rb +199 -0
- data/lib/gsd/tools/file_write.rb +153 -0
- data/lib/gsd/tools/glob.rb +202 -0
- data/lib/gsd/tools/grep.rb +227 -0
- data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
- data/lib/gsd/tools/gsd_phase.rb +140 -0
- data/lib/gsd/tools/gsd_roadmap.rb +108 -0
- data/lib/gsd/tools/gsd_state.rb +143 -0
- data/lib/gsd/tools/gsd_template.rb +157 -0
- data/lib/gsd/tools/gsd_verify.rb +159 -0
- data/lib/gsd/tools/registry.rb +103 -0
- data/lib/gsd/tools/task.rb +235 -0
- data/lib/gsd/tools/todo_write.rb +290 -0
- data/lib/gsd/tools/web.rb +260 -0
- data/lib/gsd/tui/app.rb +366 -0
- data/lib/gsd/tui/auto_complete.rb +79 -0
- data/lib/gsd/tui/colors.rb +111 -0
- data/lib/gsd/tui/command_palette.rb +126 -0
- data/lib/gsd/tui/header.rb +38 -0
- data/lib/gsd/tui/input_box.rb +199 -0
- data/lib/gsd/tui/spinner.rb +40 -0
- data/lib/gsd/tui/status_bar.rb +51 -0
- data/lib/gsd/tui.rb +17 -0
- data/lib/gsd/validator.rb +216 -0
- data/lib/gsd/verify.rb +175 -0
- data/lib/gsd/version.rb +5 -0
- data/lib/gsd/workstream.rb +91 -0
- 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
|