vibecode 0.0.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.
- checksums.yaml +7 -0
- data/.gitignore +33 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +56 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/Rakefile +8 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/exe/vibecode +4 -0
- data/lib/vibecode/agent.rb +365 -0
- data/lib/vibecode/cli.rb +148 -0
- data/lib/vibecode/git.rb +141 -0
- data/lib/vibecode/ollama_client.rb +111 -0
- data/lib/vibecode/version.rb +5 -0
- data/lib/vibecode/workspace.rb +225 -0
- data/lib/vibecode.rb +13 -0
- data/sig/vibecode.rbs +4 -0
- data/test/test_helper.rb +6 -0
- data/test/test_vibecode.rb +13 -0
- data/vibecode.gemspec +39 -0
- metadata +140 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
require_relative "workspace"
|
|
2
|
+
require_relative "git"
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module Vibecode
|
|
7
|
+
class Agent
|
|
8
|
+
def initialize(model:, ollama_client:, root: Dir.pwd)
|
|
9
|
+
@model = model
|
|
10
|
+
@ollama = ollama_client
|
|
11
|
+
@workspace = Workspace.new(root)
|
|
12
|
+
@git = Git.new(root)
|
|
13
|
+
@conversation = []
|
|
14
|
+
@prompt = TTY::Prompt.new
|
|
15
|
+
@pastel = Pastel.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# -----------------------------------------
|
|
19
|
+
# Public Entry Point
|
|
20
|
+
# -----------------------------------------
|
|
21
|
+
def handle_user_input(input)
|
|
22
|
+
@conversation << { role: "user", content: input }
|
|
23
|
+
|
|
24
|
+
response = ask_model
|
|
25
|
+
return unless response
|
|
26
|
+
|
|
27
|
+
parsed = parse_response(response)
|
|
28
|
+
missing_read_suggestions = {}
|
|
29
|
+
if parsed[:files_to_read]
|
|
30
|
+
missing_read_suggestions = perform_file_reads(parsed[:files_to_read], input)
|
|
31
|
+
response = ask_model
|
|
32
|
+
return unless response
|
|
33
|
+
|
|
34
|
+
parsed = parse_response(response)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
files_to_write = normalize_files_to_write(parsed[:files_to_write], input, missing_read_suggestions)
|
|
38
|
+
actions = plan_actions(files_to_write, input)
|
|
39
|
+
|
|
40
|
+
show_plan(parsed[:plan]) if parsed[:plan]
|
|
41
|
+
preview_diffs(files_to_write) if files_to_write && !files_to_write.empty?
|
|
42
|
+
|
|
43
|
+
if actions && !actions.empty?
|
|
44
|
+
puts "\nVibecode plans to:\n- #{actions.join("\n- ")}\n"
|
|
45
|
+
approved = @prompt.yes?("Proceed?")
|
|
46
|
+
unless approved
|
|
47
|
+
puts @pastel.red("Plan cancelled.")
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
written_files = perform_file_writes(files_to_write)
|
|
53
|
+
run_results = run_ruby_files(written_files, files_to_write)
|
|
54
|
+
|
|
55
|
+
if parsed[:commands] && user_asked_for_git?(input)
|
|
56
|
+
perform_commands(parsed[:commands])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if run_results.any?
|
|
60
|
+
@conversation << { role: "user", content: format_execution_results(run_results) }
|
|
61
|
+
report = ask_model(stage: :report)
|
|
62
|
+
if report
|
|
63
|
+
report_parsed = parse_response(report)
|
|
64
|
+
print_response(report_parsed[:response]) if report_parsed[:response]
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
print_response(parsed[:response]) if parsed[:response]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -----------------------------------------
|
|
73
|
+
# Model Interaction
|
|
74
|
+
# -----------------------------------------
|
|
75
|
+
def ask_model(stage: :normal)
|
|
76
|
+
system_prompt = agent_system_prompt(stage: stage)
|
|
77
|
+
user_context = build_context_prompt
|
|
78
|
+
|
|
79
|
+
raw = @ollama.chat(@model, system_prompt, user_context)
|
|
80
|
+
return nil unless raw && !raw.strip.empty?
|
|
81
|
+
|
|
82
|
+
raw
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_context_prompt
|
|
86
|
+
convo_text = @conversation.map { |m| "#{m[:role].upcase}: #{m[:content]}" }.join("\n")
|
|
87
|
+
repo_tree = @workspace.tree
|
|
88
|
+
|
|
89
|
+
<<~PROMPT
|
|
90
|
+
Conversation so far:
|
|
91
|
+
#{convo_text}
|
|
92
|
+
|
|
93
|
+
Project file tree:
|
|
94
|
+
#{repo_tree}
|
|
95
|
+
|
|
96
|
+
Respond using the required structured format.
|
|
97
|
+
PROMPT
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -----------------------------------------
|
|
101
|
+
# Response Parsing
|
|
102
|
+
# -----------------------------------------
|
|
103
|
+
def parse_response(text)
|
|
104
|
+
sections = {
|
|
105
|
+
plan: extract_section(text, "PLAN"),
|
|
106
|
+
files_to_read: extract_list(text, "FILES_TO_READ"),
|
|
107
|
+
files_to_write: extract_file_blocks(text),
|
|
108
|
+
commands: extract_list(text, "COMMANDS"),
|
|
109
|
+
response: extract_section(text, "RESPONSE")
|
|
110
|
+
}
|
|
111
|
+
sections
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_section(text, name)
|
|
115
|
+
text[/#{name}:\s*(.*?)\n(?=[A-Z_]+:|\z)/m, 1]&.strip
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_list(text, name)
|
|
119
|
+
section = extract_section(text, name)
|
|
120
|
+
return nil unless section
|
|
121
|
+
|
|
122
|
+
section.lines.map(&:strip).reject(&:empty?)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_file_blocks(text)
|
|
126
|
+
blocks = text.scan(/FILE:\s*(.*?)\n```.*?\n(.*?)```/m)
|
|
127
|
+
return nil if blocks.empty?
|
|
128
|
+
|
|
129
|
+
blocks.map do |path, content|
|
|
130
|
+
{ path: path.strip, content: content.rstrip }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# -----------------------------------------
|
|
135
|
+
# Actions
|
|
136
|
+
# -----------------------------------------
|
|
137
|
+
def show_plan(plan)
|
|
138
|
+
puts "\n🧠 Plan:\n#{plan}\n\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def perform_file_reads(files, task_description)
|
|
142
|
+
missing = {}
|
|
143
|
+
used = {}
|
|
144
|
+
|
|
145
|
+
files.each do |path|
|
|
146
|
+
if @workspace.file_exists?(path)
|
|
147
|
+
puts "\n📖 Reading #{path}...\n\n"
|
|
148
|
+
content = @workspace.read_file(path)
|
|
149
|
+
next unless content
|
|
150
|
+
|
|
151
|
+
@conversation << {
|
|
152
|
+
role: "user",
|
|
153
|
+
content: "FILE CONTENT (#{path}):\n#{content}"
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
suggested = resolve_new_file_path(task_description, used)
|
|
157
|
+
missing[path] = suggested
|
|
158
|
+
@conversation << {
|
|
159
|
+
role: "user",
|
|
160
|
+
content: "REQUESTED FILE NOT FOUND (#{path}). Create new file: #{suggested}."
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
missing
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def preview_diffs(files)
|
|
169
|
+
files.each do |file|
|
|
170
|
+
puts "\n✏️ Proposed edit for #{file[:path]}"
|
|
171
|
+
diff = @workspace.diff_for(file[:path], file[:content])
|
|
172
|
+
puts diff.empty? ? @pastel.dim("(No changes)") : diff
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def perform_file_writes(files)
|
|
177
|
+
return [] unless files
|
|
178
|
+
|
|
179
|
+
written = []
|
|
180
|
+
files.each do |file|
|
|
181
|
+
puts "\n✏️ Writing #{file[:path]}"
|
|
182
|
+
success = @workspace.write_file(file[:path], file[:content], show_diff: false)
|
|
183
|
+
written << file[:path] if success
|
|
184
|
+
end
|
|
185
|
+
written
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def perform_commands(commands)
|
|
189
|
+
commands.each do |cmd|
|
|
190
|
+
puts "\n⚙️ Running command: #{cmd}"
|
|
191
|
+
@git.run(cmd)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def run_ruby_files(written_paths, files_to_write)
|
|
196
|
+
return [] if written_paths.nil? || written_paths.empty?
|
|
197
|
+
|
|
198
|
+
by_path = (files_to_write || []).each_with_object({}) do |file, acc|
|
|
199
|
+
acc[file[:path]] = file[:content]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
results = []
|
|
203
|
+
written_paths.each do |path|
|
|
204
|
+
next unless path.end_with?(".rb")
|
|
205
|
+
|
|
206
|
+
content = by_path[path]
|
|
207
|
+
unless @workspace.ruby_executable_content?(content.to_s)
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
puts "\n▶️ Running: ruby #{path}"
|
|
212
|
+
result = @workspace.run_ruby(path)
|
|
213
|
+
next if result[:skipped]
|
|
214
|
+
|
|
215
|
+
results << result.merge(path: path)
|
|
216
|
+
print_run_result(result)
|
|
217
|
+
end
|
|
218
|
+
results
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def print_run_result(result)
|
|
222
|
+
stdout = result[:stdout].to_s
|
|
223
|
+
stderr = result[:stderr].to_s
|
|
224
|
+
|
|
225
|
+
puts stdout unless stdout.empty?
|
|
226
|
+
puts @pastel.red(stderr) unless stderr.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def format_execution_results(results)
|
|
230
|
+
lines = ["RUBY EXECUTION RESULTS:"]
|
|
231
|
+
results.each do |res|
|
|
232
|
+
status = res[:status]&.success? ? "success" : "failure"
|
|
233
|
+
lines << "COMMAND: #{res[:command]}"
|
|
234
|
+
lines << "STATUS: #{status}"
|
|
235
|
+
lines << "STDOUT:\n#{res[:stdout].to_s.strip}"
|
|
236
|
+
lines << "STDERR:\n#{res[:stderr].to_s.strip}"
|
|
237
|
+
end
|
|
238
|
+
lines.join("\n")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def normalize_files_to_write(files, task_description, missing_read_suggestions)
|
|
242
|
+
return [] unless files
|
|
243
|
+
|
|
244
|
+
used = {}
|
|
245
|
+
files.map do |file|
|
|
246
|
+
if @workspace.file_exists?(file[:path])
|
|
247
|
+
{ path: file[:path], content: file[:content] }
|
|
248
|
+
else
|
|
249
|
+
suggested = missing_read_suggestions[file[:path]] || resolve_new_file_path(task_description, used)
|
|
250
|
+
{ path: suggested, content: file[:content] }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def resolve_new_file_path(task_description, used)
|
|
256
|
+
base = @workspace.suggest_filename(task_description)
|
|
257
|
+
candidate = base
|
|
258
|
+
index = 2
|
|
259
|
+
|
|
260
|
+
while @workspace.file_exists?(candidate) || used[candidate]
|
|
261
|
+
stem = base.sub(/\.rb\z/, "")
|
|
262
|
+
candidate = "#{stem}_#{index}.rb"
|
|
263
|
+
index += 1
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
used[candidate] = true
|
|
267
|
+
candidate
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def user_asked_for_git?(input)
|
|
271
|
+
input.to_s.downcase.match?(/\bgit\b/)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def plan_actions(files_to_write, user_input)
|
|
275
|
+
actions = []
|
|
276
|
+
|
|
277
|
+
(files_to_write || []).each do |file|
|
|
278
|
+
if @workspace.file_exists?(file[:path])
|
|
279
|
+
actions << "update file #{file[:path]}"
|
|
280
|
+
else
|
|
281
|
+
actions << "create file #{file[:path]}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
if file[:path].end_with?(".rb") && @workspace.ruby_executable_content?(file[:content].to_s)
|
|
285
|
+
actions << "run ruby #{file[:path]}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if user_asked_for_git?(user_input)
|
|
290
|
+
actions << "run git commands" if actions.empty?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
actions
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def approve_actions!(actions)
|
|
297
|
+
return if actions.nil? || actions.empty?
|
|
298
|
+
|
|
299
|
+
puts "\nVibecode plans to:\n- #{actions.join("\n- ")}\n\nProceed?"
|
|
300
|
+
approved = @prompt.yes?(" ")
|
|
301
|
+
unless approved
|
|
302
|
+
puts @pastel.red("Plan cancelled.")
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def print_response(response)
|
|
308
|
+
puts "\n#{response}"
|
|
309
|
+
@conversation << { role: "assistant", content: response }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# -----------------------------------------
|
|
313
|
+
# System Prompt
|
|
314
|
+
# -----------------------------------------
|
|
315
|
+
def agent_system_prompt(stage: :normal)
|
|
316
|
+
base = <<~PROMPT
|
|
317
|
+
You are Vibecode, an autonomous terminal coding agent.
|
|
318
|
+
|
|
319
|
+
You can:
|
|
320
|
+
- Read project files
|
|
321
|
+
- Modify files
|
|
322
|
+
|
|
323
|
+
ALWAYS respond in this format:
|
|
324
|
+
|
|
325
|
+
PLAN:
|
|
326
|
+
Brief reasoning about what you will do
|
|
327
|
+
|
|
328
|
+
FILES_TO_READ:
|
|
329
|
+
existing/file.rb
|
|
330
|
+
another/existing/file.js
|
|
331
|
+
|
|
332
|
+
FILE:
|
|
333
|
+
filename.rb
|
|
334
|
+
```
|
|
335
|
+
full updated file contents
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
COMMANDS:
|
|
339
|
+
git status
|
|
340
|
+
git diff
|
|
341
|
+
|
|
342
|
+
RESPONSE:
|
|
343
|
+
What you want to tell the user
|
|
344
|
+
|
|
345
|
+
Rules:
|
|
346
|
+
- Only list existing files in FILES_TO_READ
|
|
347
|
+
- If you need to create a new file, propose a reasonable filename like hello_world.rb (never placeholders like path/to/file.rb)
|
|
348
|
+
- Do not include COMMANDS unless the user explicitly asked for git
|
|
349
|
+
- Keep edits minimal and complete
|
|
350
|
+
- Prefer reading files before editing
|
|
351
|
+
PROMPT
|
|
352
|
+
|
|
353
|
+
return base if stage == :normal
|
|
354
|
+
|
|
355
|
+
<<~PROMPT
|
|
356
|
+
#{base}
|
|
357
|
+
|
|
358
|
+
REPORT BACK STAGE:
|
|
359
|
+
- Only provide the RESPONSE section
|
|
360
|
+
- Do not request file reads or file writes
|
|
361
|
+
- Do not include COMMANDS
|
|
362
|
+
PROMPT
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
data/lib/vibecode/cli.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "json"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require "tty-spinner"
|
|
6
|
+
require "pastel"
|
|
7
|
+
|
|
8
|
+
require_relative "ollama_client"
|
|
9
|
+
require_relative "agent"
|
|
10
|
+
|
|
11
|
+
module Vibecode
|
|
12
|
+
class CLI
|
|
13
|
+
CONFIG_DIR = File.join(Dir.home, ".vibecode")
|
|
14
|
+
CONFIG_PATH = File.join(CONFIG_DIR, "config.json")
|
|
15
|
+
DEFAULT_MODEL = "qwen3-coder:latest"
|
|
16
|
+
|
|
17
|
+
def self.start(argv)
|
|
18
|
+
new.run(argv)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@pastel = Pastel.new
|
|
23
|
+
@prompt = TTY::Prompt.new
|
|
24
|
+
ensure_config!
|
|
25
|
+
@config = load_config
|
|
26
|
+
@ollama = OllamaClient.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(argv)
|
|
30
|
+
options = parse_options(argv)
|
|
31
|
+
|
|
32
|
+
case
|
|
33
|
+
when options[:list]
|
|
34
|
+
list_models
|
|
35
|
+
when options[:use]
|
|
36
|
+
use_model(options[:use])
|
|
37
|
+
when options[:pull]
|
|
38
|
+
pull_model(options[:pull])
|
|
39
|
+
when options[:doctor]
|
|
40
|
+
doctor_check
|
|
41
|
+
else
|
|
42
|
+
interactive_session
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# -------------------------
|
|
47
|
+
# Interactive Agent Session
|
|
48
|
+
# -------------------------
|
|
49
|
+
def interactive_session
|
|
50
|
+
puts @pastel.cyan("Vibecode Agent using model: #{@config['model']}")
|
|
51
|
+
puts @pastel.dim("Type 'exit' to quit.\n\n")
|
|
52
|
+
|
|
53
|
+
agent = Vibecode::Agent.new(
|
|
54
|
+
model: @config["model"],
|
|
55
|
+
ollama_client: @ollama,
|
|
56
|
+
root: Dir.pwd
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
input = @prompt.ask(@pastel.green("vibecode> "), required: false)
|
|
61
|
+
break if input.nil? || input.strip.downcase == "exit"
|
|
62
|
+
|
|
63
|
+
spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
|
|
64
|
+
spinner.auto_spin
|
|
65
|
+
|
|
66
|
+
spinner.stop
|
|
67
|
+
agent.handle_user_input(input)
|
|
68
|
+
puts
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -------------------------
|
|
73
|
+
# Model Commands
|
|
74
|
+
# -------------------------
|
|
75
|
+
def list_models
|
|
76
|
+
models = @ollama.list_models
|
|
77
|
+
puts @pastel.cyan("Installed Ollama Models:\n\n")
|
|
78
|
+
models.each do |m|
|
|
79
|
+
marker = (m == @config["model"]) ? @pastel.green(" (active)") : ""
|
|
80
|
+
puts " - #{m}#{marker}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def use_model(model_name)
|
|
85
|
+
unless @ollama.model_installed?(model_name)
|
|
86
|
+
puts @pastel.yellow("Model not found locally. Pulling #{model_name}...")
|
|
87
|
+
pull_model(model_name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@config["model"] = model_name
|
|
91
|
+
save_config
|
|
92
|
+
puts @pastel.green("Now using model: #{model_name}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def pull_model(model_name)
|
|
96
|
+
success = @ollama.pull_model(model_name)
|
|
97
|
+
puts(success ? @pastel.green("\nModel pulled successfully.") : @pastel.red("\nFailed to pull model."))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -------------------------
|
|
101
|
+
# Doctor Check
|
|
102
|
+
# -------------------------
|
|
103
|
+
def doctor_check
|
|
104
|
+
puts @pastel.cyan("Running Vibecode system check...\n\n")
|
|
105
|
+
check("Ollama installed") { system("which ollama > /dev/null 2>&1") }
|
|
106
|
+
check("Ollama server running") { @ollama.server_alive? }
|
|
107
|
+
check("Git installed") { system("which git > /dev/null 2>&1") }
|
|
108
|
+
puts
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check(label)
|
|
112
|
+
print "#{label.ljust(28)}"
|
|
113
|
+
puts(yield ? @pastel.green("OK") : @pastel.red("MISSING"))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# -------------------------
|
|
117
|
+
# Config Helpers
|
|
118
|
+
# -------------------------
|
|
119
|
+
def ensure_config!
|
|
120
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
121
|
+
return if File.exist?(CONFIG_PATH)
|
|
122
|
+
File.write(CONFIG_PATH, { model: DEFAULT_MODEL }.to_json)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def load_config
|
|
126
|
+
JSON.parse(File.read(CONFIG_PATH))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def save_config
|
|
130
|
+
File.write(CONFIG_PATH, JSON.pretty_generate(@config))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# -------------------------
|
|
134
|
+
# Options
|
|
135
|
+
# -------------------------
|
|
136
|
+
def parse_options(argv)
|
|
137
|
+
options = {}
|
|
138
|
+
OptionParser.new do |opts|
|
|
139
|
+
opts.on("-list") { options[:list] = true }
|
|
140
|
+
opts.on("-use MODEL") { |m| options[:use] = m }
|
|
141
|
+
opts.on("-pull MODEL") { |m| options[:pull] = m }
|
|
142
|
+
opts.on("-doctor") { options[:doctor] = true }
|
|
143
|
+
end.parse!(argv)
|
|
144
|
+
options
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
data/lib/vibecode/git.rb
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "tty-prompt"
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Vibecode
|
|
6
|
+
class Git
|
|
7
|
+
SAFE_COMMANDS = %w[
|
|
8
|
+
status
|
|
9
|
+
diff
|
|
10
|
+
log
|
|
11
|
+
branch
|
|
12
|
+
remote
|
|
13
|
+
fetch
|
|
14
|
+
pull
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
DANGEROUS_COMMANDS = %w[
|
|
18
|
+
add
|
|
19
|
+
commit
|
|
20
|
+
push
|
|
21
|
+
checkout
|
|
22
|
+
merge
|
|
23
|
+
rebase
|
|
24
|
+
reset
|
|
25
|
+
rm
|
|
26
|
+
stash
|
|
27
|
+
tag
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(root_dir = Dir.pwd)
|
|
31
|
+
@root_dir = root_dir
|
|
32
|
+
@prompt = TTY::Prompt.new
|
|
33
|
+
@pastel = Pastel.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -----------------------------
|
|
37
|
+
# Public Interface
|
|
38
|
+
# -----------------------------
|
|
39
|
+
|
|
40
|
+
def run(command)
|
|
41
|
+
return not_git_repo unless git_repo?
|
|
42
|
+
|
|
43
|
+
cmd_parts = command.strip.split
|
|
44
|
+
git_subcommand = cmd_parts[1] # "git status" -> "status"
|
|
45
|
+
|
|
46
|
+
unless cmd_parts.first == "git"
|
|
47
|
+
return error("Only git commands are allowed.")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if SAFE_COMMANDS.include?(git_subcommand)
|
|
51
|
+
execute(command)
|
|
52
|
+
elsif DANGEROUS_COMMANDS.include?(git_subcommand)
|
|
53
|
+
confirm_and_execute(command)
|
|
54
|
+
else
|
|
55
|
+
confirm_and_execute(command) # Unknown = treat as dangerous
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def status
|
|
60
|
+
execute("git status")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def diff
|
|
64
|
+
execute("git diff")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current_branch
|
|
68
|
+
stdout, _stderr, _status = execute("git branch --show-current", capture: true)
|
|
69
|
+
stdout.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def branches
|
|
73
|
+
execute("git branch")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def log(limit = 10)
|
|
77
|
+
execute("git log --oneline -n #{limit}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def add_all
|
|
81
|
+
confirm_and_execute("git add .")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def commit(message)
|
|
85
|
+
confirm_and_execute(%(git commit -m "#{message}"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def push(remote = "origin", branch = current_branch)
|
|
89
|
+
confirm_and_execute("git push #{remote} #{branch}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# -----------------------------
|
|
93
|
+
# Core Execution
|
|
94
|
+
# -----------------------------
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def execute(command, capture: false)
|
|
99
|
+
stdout, stderr, status = Open3.capture3(command, chdir: @root_dir)
|
|
100
|
+
|
|
101
|
+
unless status.success?
|
|
102
|
+
puts @pastel.red(stderr.strip)
|
|
103
|
+
return [stdout, stderr, status] if capture
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts @pastel.cyan(stdout.strip) unless capture
|
|
108
|
+
[stdout, stderr, status]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def confirm_and_execute(command)
|
|
112
|
+
puts @pastel.yellow("\nAI wants to run:\n #{command}\n")
|
|
113
|
+
|
|
114
|
+
approved = @prompt.yes?("Allow this git command?")
|
|
115
|
+
unless approved
|
|
116
|
+
puts @pastel.red("Command cancelled.")
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
execute(command)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# -----------------------------
|
|
124
|
+
# Safety / Checks
|
|
125
|
+
# -----------------------------
|
|
126
|
+
|
|
127
|
+
def git_repo?
|
|
128
|
+
system("git rev-parse --is-inside-work-tree > /dev/null 2>&1", chdir: @root_dir)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def not_git_repo
|
|
132
|
+
error("Not inside a git repository.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def error(message)
|
|
136
|
+
puts @pastel.red(message)
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|