rubik 0.3.3 → 0.3.5

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/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +102 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +99 -69
  9. data/lib/rubik/config/defaults.rb +17 -20
  10. data/lib/rubik/config.rb +0 -6
  11. data/lib/rubik/context/git_lister.rb +3 -1
  12. data/lib/rubik/llm/anthropic.rb +5 -2
  13. data/lib/rubik/llm/client.rb +102 -72
  14. data/lib/rubik/llm/openai_compatible.rb +83 -68
  15. data/lib/rubik/lsp/client.rb +320 -96
  16. data/lib/rubik/lsp/manager.rb +83 -12
  17. data/lib/rubik/lsp/server_command.rb +102 -0
  18. data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
  19. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
  21. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  22. data/lib/rubik/platform/process.rb +6 -6
  23. data/lib/rubik/platform/sanitize.rb +37 -16
  24. data/lib/rubik/prompt/doctrine.rb +4 -0
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +183 -155
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/agent_providers.rb +190 -156
  29. data/lib/rubik/repl/commands/buffer.rb +122 -88
  30. data/lib/rubik/repl/commands/lsp.rb +212 -179
  31. data/lib/rubik/repl/commands/mcp.rb +120 -92
  32. data/lib/rubik/repl/commands/session.rb +90 -59
  33. data/lib/rubik/repl/commands/workspace.rb +266 -246
  34. data/lib/rubik/repl/shell.rb +10 -1
  35. data/lib/rubik/runtime/discovery.rb +12 -6
  36. data/lib/rubik/runtime/overlay_storage.rb +59 -2
  37. data/lib/rubik/sandbox/driver/docker.rb +2 -2
  38. data/lib/rubik/session/replayer.rb +1 -2
  39. data/lib/rubik/storage/store.rb +3 -2
  40. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  41. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  42. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  43. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  44. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  45. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  46. data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
  47. data/lib/rubik/version.rb +1 -1
  48. metadata +2 -1
@@ -34,173 +34,207 @@ module Rubik
34
34
  when "list", "ls", "status"
35
35
  render_provider_catalog(ctx)
36
36
  when "add"
37
- p_name = clean_args[1]&.downcase
38
- arg2 = clean_args[2]
39
- arg3 = clean_args[3]
40
-
41
- p_type = "openai_compatible"
42
- p_endpoint = nil
43
-
44
- if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
45
- p_endpoint = arg2
46
- elsif arg2 && arg3
47
- p_type = arg2.downcase
48
- p_endpoint = arg3
49
- end
50
-
51
- if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
52
- ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
53
- "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
54
- elsif !p_endpoint.start_with?("http://") && !p_endpoint.start_with?("https://")
55
- ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
56
- "Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
57
- "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
58
- else
59
- key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
60
- key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
61
- model_arg = clean_args.find { |a| a.start_with?("--model=") }
62
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
63
-
64
- api_key = key_arg&.split("=", 2)&.last
65
- key_env = key_env_arg&.split("=", 2)&.last
66
- m_name = model_arg ? model_arg.split("=", 2).last : "default"
67
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
68
-
69
- normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
70
- Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
71
- else
72
- p_endpoint
73
- end
74
-
75
- p_spec = {
76
- "type" => p_type,
77
- "endpoint" => normalized_endpoint,
78
- "api_key" => api_key,
79
- "api_key_env" => key_env,
80
- "models" => [
81
- { "name" => m_name, "context_limit" => c_limit }
82
- ]
83
- }.compact
84
-
85
- res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
86
- ctx.refresh_llm_registry!
87
- next if Base.report_persist_failure(ctx, res, scope)
88
-
89
- ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
90
- "with model '#{m_name}' [#{Base.scope_label(scope)}]."
91
- end
37
+ handle_add_provider(clean_args, ctx, scope)
92
38
  when "add-model", "model-add"
93
- p_name = clean_args[1]&.downcase
94
- m_name = clean_args[2]
95
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
96
- ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
97
- else
98
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
99
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
100
-
101
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
102
- ctx.refresh_llm_registry!
103
- if res[:ok]
104
- ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
105
- elsif res[:reason] == :unknown_provider
106
- ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
107
- else
108
- Base.report_persist_failure(ctx, res, scope)
109
- end
110
- end
39
+ handle_add_provider_model(clean_args, ctx, scope)
111
40
  when "remove-model", "model-remove", "model-rm"
112
- p_name = clean_args[1]&.downcase
113
- m_name = clean_args[2]
114
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
115
- ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
116
- else
117
- res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
118
- ctx.refresh_llm_registry!
119
- if res[:ok]
120
- ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
121
- elsif res[:reason] == :unknown_provider
122
- ctx.out.puts "Provider '#{p_name}' not found."
123
- else
124
- Base.report_persist_failure(ctx, res, scope)
125
- end
126
- end
41
+ handle_remove_provider_model(clean_args, ctx, scope)
127
42
  when "set"
128
- p_name = clean_args[1]&.downcase
129
- field = clean_args[2]&.downcase
130
- val = clean_args[3]
131
- if p_name.nil? || p_name.empty? || field.nil? || val.nil?
132
- ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
133
- else
134
- p_spec = ctx.config.provider_spec(p_name)
135
- if p_spec.nil?
136
- ctx.out.puts "Provider '#{p_name}' not found."
137
- else
138
- case field
139
- when "key", "api_key"
140
- p_spec["api_key"] = val
141
- when "key-env", "key_env", "api_key_env"
142
- p_spec["api_key_env"] = val
143
- when "endpoint", "url", "host"
144
- if !val.start_with?("http://") && !val.start_with?("https://")
145
- ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
146
- next
147
- end
148
- p_spec["endpoint"] = val
149
- when "type"
150
- p_spec["type"] = val
151
- else
152
- # Free-form fields (stream, tool_native, num_ctx, ...) are
153
- # typed through parse_scalar_value so "false" becomes a
154
- # real false — a raw "false" String is truthy in Ruby and
155
- # would silently re-enable the flag it means to disable.
156
- p_spec[field] = ctx.config.parse_scalar_value(val)
157
- end
158
-
159
- res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
160
- ctx.refresh_llm_registry!
161
- next if Base.report_persist_failure(ctx, res, scope)
162
-
163
- ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
164
- end
165
- end
43
+ handle_set_provider_field(clean_args, ctx, scope)
166
44
  when "remove", "rm", "delete"
167
- p_name = clean_args[1]&.downcase
168
- if p_name.nil? || p_name.empty?
169
- ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
170
- else
171
- res = ctx.config.remove_provider(p_name, persist: scope)
172
- ctx.refresh_llm_registry!
173
- if res[:ok]
174
- ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
175
- elsif res[:reason] == :unknown_provider
176
- ctx.out.puts "Provider '#{p_name}' not found."
177
- else
178
- Base.report_persist_failure(ctx, res, scope)
179
- end
180
- end
45
+ handle_remove_provider(clean_args, ctx, scope)
181
46
  when "set-context", "context", "limit"
182
- p_name = clean_args[1]&.downcase
183
- m_name = clean_args[2]
184
- limit_val = clean_args[3]&.to_i
185
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
186
- ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
187
- else
188
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
189
- ctx.refresh_llm_registry!
190
- if res[:ok]
191
- ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
192
- elsif res[:reason] == :unknown_provider
193
- ctx.out.puts "Provider '#{p_name}' not found."
194
- else
195
- Base.report_persist_failure(ctx, res, scope)
196
- end
197
- end
47
+ handle_set_provider_context(clean_args, ctx, scope)
198
48
  else
199
49
  ctx.out.puts USAGE_PROVIDER
200
50
  end
201
51
  end
202
52
  end
203
53
 
54
+ private_class_method def self.handle_add_provider(clean_args, ctx, scope)
55
+ p_name = clean_args[1]&.downcase
56
+ arg2 = clean_args[2]
57
+ arg3 = clean_args[3]
58
+
59
+ p_type = "openai_compatible"
60
+ p_endpoint = nil
61
+
62
+ if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
63
+ p_endpoint = arg2
64
+ elsif arg2 && arg3
65
+ p_type = arg2.downcase
66
+ p_endpoint = arg3
67
+ end
68
+
69
+ if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
70
+ ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
71
+ "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
72
+ return
73
+ end
74
+
75
+ unless p_endpoint.start_with?("http://") || p_endpoint.start_with?("https://")
76
+ ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
77
+ "Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
78
+ "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
79
+ return
80
+ end
81
+
82
+ key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
83
+ key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
84
+ model_arg = clean_args.find { |a| a.start_with?("--model=") }
85
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
86
+
87
+ api_key = key_arg&.split("=", 2)&.last
88
+ key_env = key_env_arg&.split("=", 2)&.last
89
+ m_name = model_arg ? model_arg.split("=", 2).last : "default"
90
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
91
+
92
+ normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
93
+ Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
94
+ else
95
+ p_endpoint
96
+ end
97
+
98
+ p_spec = {
99
+ "type" => p_type,
100
+ "endpoint" => normalized_endpoint,
101
+ "api_key" => api_key,
102
+ "api_key_env" => key_env,
103
+ "models" => [
104
+ { "name" => m_name, "context_limit" => c_limit }
105
+ ]
106
+ }.compact
107
+
108
+ res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
109
+ ctx.refresh_llm_registry!
110
+ return if Base.report_persist_failure(ctx, res, scope)
111
+
112
+ ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
113
+ "with model '#{m_name}' [#{Base.scope_label(scope)}]."
114
+ end
115
+
116
+ private_class_method def self.handle_add_provider_model(clean_args, ctx, scope)
117
+ p_name = clean_args[1]&.downcase
118
+ m_name = clean_args[2]
119
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
120
+ ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
121
+ return
122
+ end
123
+
124
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
125
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
126
+
127
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
128
+ ctx.refresh_llm_registry!
129
+ if res[:ok]
130
+ ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
131
+ elsif res[:reason] == :unknown_provider
132
+ ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
133
+ else
134
+ Base.report_persist_failure(ctx, res, scope)
135
+ end
136
+ end
137
+
138
+ private_class_method def self.handle_remove_provider_model(clean_args, ctx, scope)
139
+ p_name = clean_args[1]&.downcase
140
+ m_name = clean_args[2]
141
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
142
+ ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
143
+ return
144
+ end
145
+
146
+ res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
147
+ ctx.refresh_llm_registry!
148
+ if res[:ok]
149
+ ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
150
+ elsif res[:reason] == :unknown_provider
151
+ ctx.out.puts "Provider '#{p_name}' not found."
152
+ else
153
+ Base.report_persist_failure(ctx, res, scope)
154
+ end
155
+ end
156
+
157
+ private_class_method def self.handle_set_provider_field(clean_args, ctx, scope)
158
+ p_name = clean_args[1]&.downcase
159
+ field = clean_args[2]&.downcase
160
+ val = clean_args[3]
161
+ if p_name.nil? || p_name.empty? || field.nil? || val.nil?
162
+ ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
163
+ return
164
+ end
165
+
166
+ p_spec = ctx.config.provider_spec(p_name)
167
+ if p_spec.nil?
168
+ ctx.out.puts "Provider '#{p_name}' not found."
169
+ return
170
+ end
171
+
172
+ case field
173
+ when "key", "api_key"
174
+ p_spec["api_key"] = val
175
+ when "key-env", "key_env", "api_key_env"
176
+ p_spec["api_key_env"] = val
177
+ when "endpoint", "url", "host"
178
+ unless val.start_with?("http://") || val.start_with?("https://")
179
+ ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
180
+ return
181
+ end
182
+ p_spec["endpoint"] = val
183
+ when "type"
184
+ p_spec["type"] = val
185
+ else
186
+ # Free-form fields (stream, tool_native, num_ctx, ...) are
187
+ # typed through parse_scalar_value so "false" becomes a
188
+ # real false — a raw "false" String is truthy in Ruby and
189
+ # would silently re-enable the flag it means to disable.
190
+ p_spec[field] = ctx.config.parse_scalar_value(val)
191
+ end
192
+
193
+ res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
194
+ ctx.refresh_llm_registry!
195
+ return if Base.report_persist_failure(ctx, res, scope)
196
+
197
+ ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
198
+ end
199
+
200
+ private_class_method def self.handle_remove_provider(clean_args, ctx, scope)
201
+ p_name = clean_args[1]&.downcase
202
+ if p_name.nil? || p_name.empty?
203
+ ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
204
+ return
205
+ end
206
+
207
+ res = ctx.config.remove_provider(p_name, persist: scope)
208
+ ctx.refresh_llm_registry!
209
+ if res[:ok]
210
+ ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
211
+ elsif res[:reason] == :unknown_provider
212
+ ctx.out.puts "Provider '#{p_name}' not found."
213
+ else
214
+ Base.report_persist_failure(ctx, res, scope)
215
+ end
216
+ end
217
+
218
+ private_class_method def self.handle_set_provider_context(clean_args, ctx, scope)
219
+ p_name = clean_args[1]&.downcase
220
+ m_name = clean_args[2]
221
+ limit_val = clean_args[3]&.to_i
222
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
223
+ ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
224
+ return
225
+ end
226
+
227
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
228
+ ctx.refresh_llm_registry!
229
+ if res[:ok]
230
+ ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
231
+ elsif res[:reason] == :unknown_provider
232
+ ctx.out.puts "Provider '#{p_name}' not found."
233
+ else
234
+ Base.report_persist_failure(ctx, res, scope)
235
+ end
236
+ end
237
+
204
238
  private_class_method def self.register_providers_command(shell)
205
239
  shell.register_command("providers", description: "List all configured LLM providers, endpoints, and models", audience: :both) do |_args, ctx|
206
240
  render_provider_catalog(ctx)
@@ -16,6 +16,14 @@ module Rubik
16
16
  USAGE_AUTOAPPLY = "Usage: !autoapply <manual | on_test | on_finish | always | status> [--project|--global]"
17
17
 
18
18
  def self.register(shell)
19
+ register_diff_command(shell)
20
+ register_apply_command(shell)
21
+ register_undo_command(shell)
22
+ register_rollback_command(shell)
23
+ register_autoapply_command(shell)
24
+ end
25
+
26
+ private_class_method def self.register_diff_command(shell)
19
27
  shell.register_command(
20
28
  "diff",
21
29
  description: "Preview unapplied buffer changes (staged edits that are NOT yet on disk). " \
@@ -23,111 +31,137 @@ module Rubik
23
31
  "For ground-truth file content, use read_file; for git history, use run_command with git. " \
24
32
  "(colorized viewer on terminals)",
25
33
  audience: :both
26
- ) do |args, ctx|
27
- theme = ctx.theme
28
- target_path = args.first
29
- if target_path && !target_path.empty?
30
- rel = begin
31
- ctx.buffer_manager.sanitize_path(target_path)
32
- rescue
33
- target_path
34
- end
35
- if ctx.buffer_manager.dirty_files.key?(rel)
36
- full_path = File.expand_path(rel, ctx.buffer_manager.root_dir)
37
- orig_content = File.exist?(full_path) ? File.read(full_path) : ""
38
- new_content = ctx.buffer_manager.read(rel) || ""
39
- Rubik::UI::DiffViewer.render(ctx, theme, Rubik::Buffer::Diff.unified_diff(rel, orig_content, new_content))
40
- else
41
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
42
- end
43
- else
44
- diff_text = ctx.buffer_manager.diff_all
45
- if diff_text.empty?
46
- ctx.out.puts "No unapplied buffer changes."
47
- else
48
- Rubik::UI::DiffViewer.render(ctx, theme, diff_text)
49
- end
50
- end
51
- end
34
+ ) { |args, ctx| handle_diff(args, ctx) }
35
+ end
52
36
 
37
+ private_class_method def self.register_apply_command(shell)
53
38
  shell.register_command("apply", description: "Apply dirty buffer changes to disk") do |args, ctx|
54
- target_path = args.first
55
- begin
56
- if target_path && !target_path.empty?
57
- applied = ctx.buffer_manager.apply!(target_path)
58
- conflicts = ctx.buffer_manager.last_conflicts || []
59
- if applied.empty? && conflicts.empty?
60
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
61
- else
62
- ctx.sync_context_and_ast(applied) unless applied.empty?
63
- ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
64
- end
65
- else
66
- applied = ctx.buffer_manager.apply!
67
- conflicts = ctx.buffer_manager.last_conflicts || []
68
- if applied.empty? && conflicts.empty?
69
- ctx.out.puts "No unapplied buffer changes."
70
- else
71
- ctx.sync_context_and_ast(applied) unless applied.empty?
72
- ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
73
- end
74
- end
75
- conflicts.each do |c|
76
- ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
77
- end
78
- rescue Rubik::Buffer::ApplyFailedError => e
79
- # Partial apply (issue #146): the report names what DID reach
80
- # disk before the failure — a mixed outcome must not be
81
- # presented as a plain success or a plain failure.
82
- lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
83
- ctx.out.puts "❌ #{lines.first}"
84
- lines[1..].each { |line| ctx.out.puts line }
85
- end
39
+ handle_apply(args, ctx)
86
40
  end
41
+ end
87
42
 
43
+ private_class_method def self.register_undo_command(shell)
88
44
  shell.register_command("undo", description: "Revert the last snapshot", audience: :both) do |_args, ctx|
89
- if ctx.snapshot_store.undo(ctx.buffer_manager)
90
- ctx.out.puts "Undo successful."
91
- else
92
- ctx.out.puts "No snapshots to undo."
93
- end
45
+ handle_undo(ctx)
94
46
  end
47
+ end
95
48
 
49
+ private_class_method def self.register_rollback_command(shell)
96
50
  shell.register_command("rollback", description: "Rollback to a specific snapshot ID", usage: USAGE_ROLLBACK, audience: :both) do |args, ctx|
97
- sid = args.first
98
- if sid.nil? || sid.empty?
99
- ctx.out.puts USAGE_ROLLBACK
100
- elsif ctx.snapshot_store.rollback(sid, ctx.buffer_manager)
101
- ctx.out.puts "Rollback to snapshot #{sid} successful."
102
- else
103
- ctx.out.puts "Snapshot #{sid} not found."
104
- end
51
+ handle_rollback(args, ctx)
105
52
  end
53
+ end
106
54
 
55
+ private_class_method def self.register_autoapply_command(shell)
107
56
  shell.register_command("autoapply",
108
57
  description: "Get or set buffer auto-apply policy (manual|on_test|on_finish|always)",
109
58
  usage: USAGE_AUTOAPPLY, audience: :both) do |args, ctx|
110
- scope, clean_args = Base.parse_scope(args)
111
- sub = clean_args.first&.downcase
112
-
113
- if sub.nil? || sub == "status"
114
- cur = ctx.config.fetch("buffer.auto_apply", "manual")
115
- can_m = ctx.config.fetch("buffer.model_can_apply", true)
116
- ctx.out.puts "Buffer Auto-Apply Policy:"
117
- ctx.out.puts " • Mode : #{cur} (manual | on_test | on_finish | always)"
118
- ctx.out.puts " • Model Can Apply : #{can_m}"
119
- ctx.out.puts " • Prompt Indicator : #{ctx.config.fetch('buffer.prompt_indicator', true)}"
120
- ctx.out.puts " • Reminder Interval: #{ctx.config.fetch('buffer.reminder_interval', 3)} turns"
121
- elsif ["manual", "on_test", "on_finish", "always"].include?(sub)
122
- res = ctx.config.set("buffer.auto_apply", sub, persist: scope)
123
- next if Base.report_persist_failure(ctx, res, scope)
124
-
125
- ctx.out.puts "Buffer auto-apply policy set to '#{sub}' [#{Base.scope_label(scope)}]."
59
+ handle_autoapply(args, ctx)
60
+ end
61
+ end
62
+
63
+ private_class_method def self.handle_diff(args, ctx)
64
+ theme = ctx.theme
65
+ target_path = args.first
66
+ if target_path && !target_path.empty?
67
+ rel = begin
68
+ ctx.buffer_manager.sanitize_path(target_path)
69
+ rescue
70
+ target_path
71
+ end
72
+ if ctx.buffer_manager.dirty_files.key?(rel)
73
+ full_path = File.expand_path(rel, ctx.buffer_manager.root_dir)
74
+ orig_content = File.exist?(full_path) ? File.read(full_path) : ""
75
+ new_content = ctx.buffer_manager.read(rel) || ""
76
+ Rubik::UI::DiffViewer.render(ctx, theme, Rubik::Buffer::Diff.unified_diff(rel, orig_content, new_content))
77
+ else
78
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
79
+ end
80
+ else
81
+ diff_text = ctx.buffer_manager.diff_all
82
+ if diff_text.empty?
83
+ ctx.out.puts "No unapplied buffer changes."
126
84
  else
127
- ctx.out.puts USAGE_AUTOAPPLY
85
+ Rubik::UI::DiffViewer.render(ctx, theme, diff_text)
128
86
  end
129
87
  end
130
88
  end
89
+
90
+ private_class_method def self.handle_apply(args, ctx)
91
+ target_path = args.first
92
+ begin
93
+ if target_path && !target_path.empty?
94
+ applied = ctx.buffer_manager.apply!(target_path)
95
+ conflicts = ctx.buffer_manager.last_conflicts || []
96
+ if applied.empty? && conflicts.empty?
97
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
98
+ else
99
+ ctx.sync_context_and_ast(applied) unless applied.empty?
100
+ ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
101
+ end
102
+ else
103
+ applied = ctx.buffer_manager.apply!
104
+ conflicts = ctx.buffer_manager.last_conflicts || []
105
+ if applied.empty? && conflicts.empty?
106
+ ctx.out.puts "No unapplied buffer changes."
107
+ else
108
+ ctx.sync_context_and_ast(applied) unless applied.empty?
109
+ ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
110
+ end
111
+ end
112
+ conflicts.each do |c|
113
+ ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
114
+ end
115
+ rescue Rubik::Buffer::ApplyFailedError => e
116
+ # Partial apply (issue #146): the report names what DID reach
117
+ # disk before the failure — a mixed outcome must not be
118
+ # presented as a plain success or a plain failure.
119
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
120
+ ctx.out.puts "❌ #{lines.first}"
121
+ lines[1..].each { |line| ctx.out.puts line }
122
+ end
123
+ end
124
+
125
+ private_class_method def self.handle_undo(ctx)
126
+ if ctx.snapshot_store.undo(ctx.buffer_manager)
127
+ ctx.out.puts "Undo successful."
128
+ else
129
+ ctx.out.puts "No snapshots to undo."
130
+ end
131
+ end
132
+
133
+ private_class_method def self.handle_rollback(args, ctx)
134
+ sid = args.first
135
+ if sid.nil? || sid.empty?
136
+ ctx.out.puts USAGE_ROLLBACK
137
+ elsif ctx.snapshot_store.rollback(sid, ctx.buffer_manager)
138
+ ctx.out.puts "Rollback to snapshot #{sid} successful."
139
+ else
140
+ ctx.out.puts "Snapshot #{sid} not found."
141
+ end
142
+ end
143
+
144
+ private_class_method def self.handle_autoapply(args, ctx)
145
+ scope, clean_args = Base.parse_scope(args)
146
+ sub = clean_args.first&.downcase
147
+
148
+ if sub.nil? || sub == "status"
149
+ cur = ctx.config.fetch("buffer.auto_apply", "manual")
150
+ can_m = ctx.config.fetch("buffer.model_can_apply", true)
151
+ ctx.out.puts "Buffer Auto-Apply Policy:"
152
+ ctx.out.puts " • Mode : #{cur} (manual | on_test | on_finish | always)"
153
+ ctx.out.puts " • Model Can Apply : #{can_m}"
154
+ ctx.out.puts " • Prompt Indicator : #{ctx.config.fetch('buffer.prompt_indicator', true)}"
155
+ ctx.out.puts " • Reminder Interval: #{ctx.config.fetch('buffer.reminder_interval', 3)} turns"
156
+ elsif ["manual", "on_test", "on_finish", "always"].include?(sub)
157
+ res = ctx.config.set("buffer.auto_apply", sub, persist: scope)
158
+ return if Base.report_persist_failure(ctx, res, scope)
159
+
160
+ ctx.out.puts "Buffer auto-apply policy set to '#{sub}' [#{Base.scope_label(scope)}]."
161
+ else
162
+ ctx.out.puts USAGE_AUTOAPPLY
163
+ end
164
+ end
131
165
  end
132
166
  end
133
167
  end