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.
@@ -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
@@ -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
+
@@ -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
+