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,101 +34,109 @@ module Rubik
34
34
  scope, clean_args = Base.parse_scope(args)
35
35
  sub = clean_args.first&.downcase || "list"
36
36
 
37
- handle_add = lambda do |tokens_to_add|
38
- entry_tokens = tokens_to_add.reject { |a| a == "--force" }
39
- entry = entry_tokens.join(" ").strip
40
- if entry.empty?
41
- ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
42
- return
43
- end
44
-
45
- tokens = entry.split(/\s+/)
46
- # Fail closed (G3 triage, Priority-1 site): a classifier crash
47
- # must not silently skip the broad-token RCE check — treat the
48
- # token as broad and let the operator pass a narrow prefix.
49
- broad = begin
50
- base = Rubik::Security::Classifier.classify(tokens.first)
51
- base.classification == :dangerous && tokens.size == 1
52
- rescue StandardError => e
53
- Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
54
- true
55
- end
56
-
57
- # Fail-closed against unbounded execution: a broad, single-token
58
- # dangerous entry (e.g. "node", "python", "bash") grants
59
- # command execution for EVERY invocation of that binary, so
60
- # `node` → `node -e 'malicious()'` RCE. Such broad tokens are
61
- # blocked with or without --force — the operator should use a
62
- # narrow prefix (e.g. "node .gitnexus/run.cjs").
63
- if broad
64
- ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
65
- "unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
66
- "Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
67
- return
68
- end
69
-
70
- # The catastrophic destructive-pattern fallback always runs:
71
- # a dangerous multi-token entry may never add `rm -rf /`-style
72
- # chains to the allowlist, with or without --force.
73
- catastrophic = false
74
- begin
75
- ctx.guard.verify_command_fallback_regex!(entry)
76
- rescue Rubik::Security::BlockedWriteError => e
77
- ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
78
- catastrophic = true
79
- end
80
- return if catastrophic
81
-
82
- current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
83
- if current.include?(entry)
84
- ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
85
- return
86
- end
87
- current << entry
88
- res = ctx.config.set("security.command_allowlist", current, persist: scope)
89
- ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
90
- next if Base.report_persist_failure(ctx, res, scope)
91
-
92
- ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
93
- end
94
-
95
37
  case sub
96
38
  when "list", "status"
97
- entries = Array(ctx.config.fetch("security.command_allowlist", []))
98
- ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
99
- if entries.empty?
100
- ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
101
- else
102
- entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
103
- end
39
+ handle_allow_cmd_list(ctx)
104
40
  when "add"
105
- handle_add.call(clean_args[1..])
41
+ handle_allow_cmd_add(ctx, clean_args[1..], scope)
106
42
  when "remove", "rm", "delete"
107
- entry = clean_args[1..].join(" ").strip
108
- if entry.empty?
109
- ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
110
- next
111
- end
112
-
113
- current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
114
- unless current.include?(entry)
115
- ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
116
- next
117
- end
118
- current.delete(entry)
119
- res = ctx.config.set("security.command_allowlist", current, persist: scope)
120
- ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
121
- next if Base.report_persist_failure(ctx, res, scope)
122
-
123
- ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
43
+ handle_allow_cmd_remove(ctx, clean_args, scope)
124
44
  when "help", "-h", "--help"
125
45
  ctx.out.puts USAGE_ALLOW_CMD
126
46
  else
127
- handle_add.call(clean_args)
47
+ handle_allow_cmd_add(ctx, clean_args, scope)
128
48
  end
129
49
  end
130
50
  end
131
51
 
52
+ private_class_method def self.handle_allow_cmd_list(ctx)
53
+ entries = Array(ctx.config.fetch("security.command_allowlist", []))
54
+ ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
55
+ if entries.empty?
56
+ ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
57
+ else
58
+ entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
59
+ end
60
+ end
61
+
62
+ private_class_method def self.handle_allow_cmd_add(ctx, tokens_to_add, scope)
63
+ entry_tokens = tokens_to_add.reject { |a| a == "--force" }
64
+ entry = entry_tokens.join(" ").strip
65
+ if entry.empty?
66
+ ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
67
+ return
68
+ end
69
+
70
+ tokens = entry.split(/\s+/)
71
+ # Fail closed (G3 triage, Priority-1 site): a classifier crash
72
+ # must not silently skip the broad-token RCE check — treat the
73
+ # token as broad and let the operator pass a narrow prefix.
74
+ broad = begin
75
+ base = Rubik::Security::Classifier.classify(tokens.first)
76
+ base.classification == :dangerous && tokens.size == 1
77
+ rescue StandardError => e
78
+ Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
79
+ true
80
+ end
81
+
82
+ # Fail-closed against unbounded execution: a broad, single-token
83
+ # dangerous entry (e.g. "node", "python", "bash") grants
84
+ # command execution for EVERY invocation of that binary, so
85
+ # `node` → `node -e 'malicious()'` RCE. Such broad tokens are
86
+ # blocked with or without --force — the operator should use a
87
+ # narrow prefix (e.g. "node .gitnexus/run.cjs").
88
+ if broad
89
+ ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
90
+ "unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
91
+ "Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
92
+ return
93
+ end
94
+
95
+ # The catastrophic destructive-pattern fallback always runs:
96
+ # a dangerous multi-token entry may never add `rm -rf /`-style
97
+ # chains to the allowlist, with or without --force.
98
+ catastrophic = false
99
+ begin
100
+ ctx.guard.verify_command_fallback_regex!(entry)
101
+ rescue Rubik::Security::BlockedWriteError => e
102
+ ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
103
+ catastrophic = true
104
+ end
105
+ return if catastrophic
106
+
107
+ current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
108
+ if current.include?(entry)
109
+ ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
110
+ return
111
+ end
112
+ current << entry
113
+ res = ctx.config.set("security.command_allowlist", current, persist: scope)
114
+ ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
115
+ return if Base.report_persist_failure(ctx, res, scope)
116
+
117
+ ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
118
+ end
119
+
120
+ private_class_method def self.handle_allow_cmd_remove(ctx, clean_args, scope)
121
+ entry = clean_args[1..].join(" ").strip
122
+ if entry.empty?
123
+ ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
124
+ return
125
+ end
126
+
127
+ current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
128
+ unless current.include?(entry)
129
+ ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
130
+ return
131
+ end
132
+ current.delete(entry)
133
+ res = ctx.config.set("security.command_allowlist", current, persist: scope)
134
+ ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
135
+ return if Base.report_persist_failure(ctx, res, scope)
136
+
137
+ ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
138
+ end
139
+
132
140
  private_class_method def self.register_workspace_command(shell)
133
141
  workspace_handler = lambda do |args, ctx|
134
142
  scope, clean_args = Base.parse_scope(args)
@@ -141,114 +149,130 @@ module Rubik
141
149
 
142
150
  case sub
143
151
  when "list", "status"
144
- extra = ctx.buffer_manager&.additional_roots || []
145
- rw_set = ctx.buffer_manager&.rw_roots || Set.new
146
- allowed_ext = ctx.buffer_manager&.allowed_external_dirs || []
147
- rw_allowed_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
148
- glob_rw = ctx.buffer_manager&.allow_external_writes || false
149
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
150
-
151
- ctx.out.puts "=== Configured Workspace Roots ==="
152
- ctx.out.puts " • [PRIMARY] #{effective_root} (Read-Write)"
153
- if extra.empty?
154
- ctx.out.puts " (No additional workspace roots configured)"
155
- else
156
- extra.each_with_index do |r, idx|
157
- mode_str = glob_rw || rw_set.include?(r) ? "Read-Write" : "Read-Only"
158
- ctx.out.puts " • [EXTRA #{idx + 1}] #{r} (#{mode_str})"
159
- end
160
- end
152
+ handle_workspace_list(ctx, effective_root)
153
+ when "allow"
154
+ handle_workspace_allow(clean_args[1], ctx, is_ro, scope)
155
+ when "add"
156
+ handle_workspace_add(clean_args[1], ctx, is_rw, scope)
157
+ when "remove", "rm", "delete"
158
+ handle_workspace_remove(clean_args[1], ctx, scope, effective_root)
159
+ else
160
+ ctx.out.puts USAGE_WORKSPACE
161
+ end
162
+ end
161
163
 
162
- ctx.out.puts "=== Allowed External Dirs (Zero-AST) ==="
163
- if allowed_ext.empty?
164
- ctx.out.puts " (No allowed external directories configured)"
165
- else
166
- allowed_ext.each_with_index do |d, idx|
167
- mode_str = glob_rw || rw_allowed_set.include?(d) ? "Read-Write" : "Read-Only"
168
- ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
169
- end
170
- end
164
+ shell.register_command("workspace", description: "Manage multiple workspace roots (list|add <path> [--rw]|allow <path>|remove <path>)",
165
+ usage: USAGE_WORKSPACE, &workspace_handler)
166
+ shell.register_command("roots", description: "Alias for !workspace", usage: USAGE_WORKSPACE, &workspace_handler)
167
+ end
171
168
 
172
- ctx.out.puts "External Writes Allowed Globally : #{glob_rw}"
173
- ctx.out.puts "Auto-Index Additional Roots : #{auto_idx}"
174
- when "allow"
175
- target_path = clean_args[1]
176
- if target_path.nil? || target_path.empty?
177
- ctx.out.puts "Usage: !workspace allow <path> [--rw|--ro] [--project|--global]"
178
- else
179
- begin
180
- ctx.guard.verify_root_safety!(target_path)
181
- rescue Rubik::Security::BlockedWriteError => e
182
- ctx.out.puts "Security Error: #{e.message}"
183
- next
184
- end
169
+ private_class_method def self.handle_workspace_list(ctx, effective_root)
170
+ extra = ctx.buffer_manager&.additional_roots || []
171
+ rw_set = ctx.buffer_manager&.rw_roots || Set.new
172
+ allowed_ext = ctx.buffer_manager&.allowed_external_dirs || []
173
+ rw_allowed_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
174
+ glob_rw = ctx.buffer_manager&.allow_external_writes || false
175
+ auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
176
+
177
+ ctx.out.puts "=== Configured Workspace Roots ==="
178
+ ctx.out.puts " • [PRIMARY] #{effective_root} (Read-Write)"
179
+ if extra.empty?
180
+ ctx.out.puts " (No additional workspace roots configured)"
181
+ else
182
+ extra.each_with_index do |r, idx|
183
+ mode_str = glob_rw || rw_set.include?(r) ? "Read-Write" : "Read-Only"
184
+ ctx.out.puts " • [EXTRA #{idx + 1}] #{r} (#{mode_str})"
185
+ end
186
+ end
185
187
 
186
- mode_rw = !is_ro
187
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
188
- ctx.sandbox_runner&.add_allowed_dir(target_path)
188
+ ctx.out.puts "=== Allowed External Dirs (Zero-AST) ==="
189
+ if allowed_ext.empty?
190
+ ctx.out.puts " (No allowed external directories configured)"
191
+ else
192
+ allowed_ext.each_with_index do |d, idx|
193
+ mode_str = glob_rw || rw_allowed_set.include?(d) ? "Read-Write" : "Read-Only"
194
+ ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
195
+ end
196
+ end
189
197
 
190
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
191
- current_dirs << target_path unless current_dirs.include?(target_path)
192
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
193
- next if Base.report_persist_failure(ctx, res, scope)
198
+ ctx.out.puts "External Writes Allowed Globally : #{glob_rw}"
199
+ ctx.out.puts "Auto-Index Additional Roots : #{auto_idx}"
200
+ end
194
201
 
195
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
196
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
197
- end
198
- when "add"
199
- target_path = clean_args[1]
200
- if target_path.nil? || target_path.empty?
201
- ctx.out.puts "Usage: !workspace add <path> [--rw] [--project|--global]"
202
- else
203
- begin
204
- ctx.guard.verify_root_safety!(target_path)
205
- rescue Rubik::Security::BlockedWriteError => e
206
- ctx.out.puts "Security Error: #{e.message}"
207
- next
208
- end
202
+ private_class_method def self.handle_workspace_allow(target_path, ctx, is_ro, scope)
203
+ if target_path.nil? || target_path.empty?
204
+ ctx.out.puts "Usage: !workspace allow <path> [--rw|--ro] [--project|--global]"
205
+ return
206
+ end
209
207
 
210
- expanded = ctx.buffer_manager.add_root(target_path, rw: is_rw)
208
+ begin
209
+ ctx.guard.verify_root_safety!(target_path)
210
+ rescue Rubik::Security::BlockedWriteError => e
211
+ ctx.out.puts "Security Error: #{e.message}"
212
+ return
213
+ end
211
214
 
212
- # Sync context index & AST coeffects
213
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
214
- ctx.sync_workspace_index! if auto_idx
215
+ mode_rw = !is_ro
216
+ expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
217
+ ctx.sandbox_runner&.add_allowed_dir(target_path)
215
218
 
216
- # Persist to config
217
- current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
218
- current_roots << target_path unless current_roots.include?(target_path)
219
- res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
220
- next if Base.report_persist_failure(ctx, res, scope)
219
+ current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
220
+ current_dirs << target_path unless current_dirs.include?(target_path)
221
+ res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
222
+ return if Base.report_persist_failure(ctx, res, scope)
221
223
 
222
- mode_str = is_rw ? "Read-Write" : "Read-Only"
223
- ctx.out.puts "[Rubik::Workspace] Added additional root: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
224
- end
225
- when "remove", "rm", "delete"
226
- target_path = clean_args[1]
227
- if target_path.nil? || target_path.empty?
228
- ctx.out.puts "Usage: !workspace remove <path> [--project|--global]"
229
- else
230
- expanded = ctx.buffer_manager.remove_root(target_path)
224
+ mode_str = mode_rw ? "Read-Write" : "Read-Only"
225
+ ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
226
+ end
231
227
 
232
- # Sync context index & AST coeffects
233
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
234
- ctx.sync_workspace_index! if auto_idx
228
+ private_class_method def self.handle_workspace_add(target_path, ctx, is_rw, scope)
229
+ if target_path.nil? || target_path.empty?
230
+ ctx.out.puts "Usage: !workspace add <path> [--rw] [--project|--global]"
231
+ return
232
+ end
235
233
 
236
- # Persist to config
237
- current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
238
- current_roots.reject! { |r| File.expand_path(r, effective_root) == expanded || r == target_path }
239
- res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
240
- next if Base.report_persist_failure(ctx, res, scope)
234
+ begin
235
+ ctx.guard.verify_root_safety!(target_path)
236
+ rescue Rubik::Security::BlockedWriteError => e
237
+ ctx.out.puts "Security Error: #{e.message}"
238
+ return
239
+ end
241
240
 
242
- ctx.out.puts "[Rubik::Workspace] Removed additional root: #{expanded} [#{Base.scope_label(scope)}]."
243
- end
244
- else
245
- ctx.out.puts USAGE_WORKSPACE
246
- end
241
+ expanded = ctx.buffer_manager.add_root(target_path, rw: is_rw)
242
+
243
+ # Sync context index & AST coeffects
244
+ auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
245
+ ctx.sync_workspace_index! if auto_idx
246
+
247
+ # Persist to config
248
+ current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
249
+ current_roots << target_path unless current_roots.include?(target_path)
250
+ res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
251
+ return if Base.report_persist_failure(ctx, res, scope)
252
+
253
+ mode_str = is_rw ? "Read-Write" : "Read-Only"
254
+ ctx.out.puts "[Rubik::Workspace] Added additional root: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
255
+ end
256
+
257
+ private_class_method def self.handle_workspace_remove(target_path, ctx, scope, effective_root)
258
+ if target_path.nil? || target_path.empty?
259
+ ctx.out.puts "Usage: !workspace remove <path> [--project|--global]"
260
+ return
247
261
  end
248
262
 
249
- shell.register_command("workspace", description: "Manage multiple workspace roots (list|add <path> [--rw]|allow <path>|remove <path>)",
250
- usage: USAGE_WORKSPACE, &workspace_handler)
251
- shell.register_command("roots", description: "Alias for !workspace", usage: USAGE_WORKSPACE, &workspace_handler)
263
+ expanded = ctx.buffer_manager.remove_root(target_path)
264
+
265
+ # Sync context index & AST coeffects
266
+ auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
267
+ ctx.sync_workspace_index! if auto_idx
268
+
269
+ # Persist to config
270
+ current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
271
+ current_roots.reject! { |r| File.expand_path(r, effective_root) == expanded || r == target_path }
272
+ res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
273
+ return if Base.report_persist_failure(ctx, res, scope)
274
+
275
+ ctx.out.puts "[Rubik::Workspace] Removed additional root: #{expanded} [#{Base.scope_label(scope)}]."
252
276
  end
253
277
 
254
278
  private_class_method def self.register_allow_command(shell)
@@ -263,80 +287,16 @@ module Rubik
263
287
 
264
288
  case sub
265
289
  when "list", "status"
266
- allowed = ctx.buffer_manager&.allowed_external_dirs || []
267
- rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
268
- glob_rw = ctx.buffer_manager&.allow_external_writes || false
269
-
270
- ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
271
- if allowed.empty?
272
- ctx.out.puts " (No allowed external directories configured)"
273
- else
274
- allowed.each_with_index do |d, idx|
275
- mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
276
- ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
277
- end
278
- end
290
+ handle_allow_list(ctx)
279
291
  when "remove", "rm", "delete"
280
- target_path = clean_args[1]
281
- if target_path.nil? || target_path.empty?
282
- ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
283
- else
284
- expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
285
- ctx.sandbox_runner&.remove_allowed_dir(target_path)
286
-
287
- # Persist to config
288
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
289
- current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
290
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
291
- next if Base.report_persist_failure(ctx, res, scope)
292
-
293
- ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
294
- end
292
+ handle_allow_remove(clean_args[1], ctx, scope, effective_root)
295
293
  when "add"
296
- target_path = clean_args[1]
297
- if target_path.nil? || target_path.empty?
298
- ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
299
- else
300
- begin
301
- ctx.guard.verify_root_safety!(target_path)
302
- rescue Rubik::Security::BlockedWriteError => e
303
- ctx.out.puts "Security Error: #{e.message}"
304
- next
305
- end
306
-
307
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
308
- ctx.sandbox_runner&.add_allowed_dir(target_path)
309
-
310
- # Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
311
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
312
- current_dirs << target_path unless current_dirs.include?(target_path)
313
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
314
- next if Base.report_persist_failure(ctx, res, scope)
315
-
316
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
317
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
318
- end
294
+ handle_allow_add(clean_args[1], ctx, scope, mode_rw)
319
295
  else
320
296
  # First argument is target path directly
321
297
  target_path = clean_args.first
322
298
  if target_path && !target_path.empty?
323
- begin
324
- ctx.guard.verify_root_safety!(target_path)
325
- rescue Rubik::Security::BlockedWriteError => e
326
- ctx.out.puts "Security Error: #{e.message}"
327
- next
328
- end
329
-
330
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
331
- ctx.sandbox_runner&.add_allowed_dir(target_path)
332
-
333
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
334
- current_dirs << target_path unless current_dirs.include?(target_path)
335
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
336
- next if Base.report_persist_failure(ctx, res, scope)
337
-
338
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
339
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
299
+ handle_allow_add(target_path, ctx, scope, mode_rw)
340
300
  else
341
301
  cmd_name = invoked_as == :allow ? "allow" : "allow-dir"
342
302
  ctx.out.puts format(USAGE_ALLOW, cmd: cmd_name)
@@ -359,6 +319,66 @@ module Rubik
359
319
  usage: format(USAGE_ALLOW, cmd: "allow"), &deprecated_allow_wrapper)
360
320
  end
361
321
 
322
+ private_class_method def self.handle_allow_list(ctx)
323
+ allowed = ctx.buffer_manager&.allowed_external_dirs || []
324
+ rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
325
+ glob_rw = ctx.buffer_manager&.allow_external_writes || false
326
+
327
+ ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
328
+ if allowed.empty?
329
+ ctx.out.puts " (No allowed external directories configured)"
330
+ else
331
+ allowed.each_with_index do |d, idx|
332
+ mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
333
+ ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
334
+ end
335
+ end
336
+ end
337
+
338
+ private_class_method def self.handle_allow_remove(target_path, ctx, scope, effective_root)
339
+ if target_path.nil? || target_path.empty?
340
+ ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
341
+ return
342
+ end
343
+
344
+ expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
345
+ ctx.sandbox_runner&.remove_allowed_dir(target_path)
346
+
347
+ # Persist to config
348
+ current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
349
+ current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
350
+ res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
351
+ return if Base.report_persist_failure(ctx, res, scope)
352
+
353
+ ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
354
+ end
355
+
356
+ private_class_method def self.handle_allow_add(target_path, ctx, scope, mode_rw)
357
+ if target_path.nil? || target_path.empty?
358
+ ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
359
+ return
360
+ end
361
+
362
+ begin
363
+ ctx.guard.verify_root_safety!(target_path)
364
+ rescue Rubik::Security::BlockedWriteError => e
365
+ ctx.out.puts "Security Error: #{e.message}"
366
+ return
367
+ end
368
+
369
+ expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
370
+ ctx.sandbox_runner&.add_allowed_dir(target_path)
371
+
372
+ # Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
373
+ current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
374
+ current_dirs << target_path unless current_dirs.include?(target_path)
375
+ res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
376
+ return if Base.report_persist_failure(ctx, res, scope)
377
+
378
+ mode_str = mode_rw ? "Read-Write" : "Read-Only"
379
+ ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
380
+ end
381
+
362
382
  private_class_method def self.register_config_command(shell)
363
383
  shell.register_command("config", description: "Get, set, or view runtime configuration (set|get|list|path)",
364
384
  usage: USAGE_CONFIG, audience: :both) do |args, ctx|
@@ -92,7 +92,16 @@ module Rubik
92
92
  files.each do |rel_file|
93
93
  @context_index.update_file(rel_file)
94
94
  full_path = File.join(root_d, rel_file)
95
- File.exist?(full_path) ? @ast_graph.index_file(full_path) : @ast_graph.remove_file(full_path)
95
+ if File.exist?(full_path)
96
+ @ast_graph.index_file(full_path)
97
+ if @lsp_manager&.enabled?
98
+ content = Rubik::Platform.read_text(full_path)
99
+ @lsp_manager.sync_buffer_file(rel_file, content)
100
+ @lsp_manager.did_save_file(rel_file, content)
101
+ end
102
+ else
103
+ @ast_graph.remove_file(full_path)
104
+ end
96
105
  end
97
106
  @ast_graph.save_to_cache(@cache_store) if @cache_store
98
107
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "shellwords"
4
4
  require_relative "../platform"
5
+ require_relative "../lsp/server_command"
5
6
 
6
7
  module Rubik
7
8
  module Runtime
@@ -179,22 +180,27 @@ module Rubik
179
180
  end
180
181
 
181
182
  def binary_available?(cmd, root_dir: nil)
182
- return false if cmd.nil? || cmd.to_s.strip.empty?
183
+ argv = Rubik::LSP::ServerCommand.argv(cmd)
184
+ return false if argv.empty?
183
185
 
184
- bin = cmd.to_s.strip.split.first
185
- if root_dir && bin && (bin.include?("/") || (Rubik::Platform.windows? && bin.include?("\\")))
186
- full_path = File.expand_path(bin, root_dir)
186
+ bin = argv.first
187
+ if bin.include?("/") || (Rubik::Platform.windows? && bin.include?("\\"))
188
+ full_path = File.expand_path(bin, root_dir || Dir.pwd)
187
189
  return true if Rubik::Platform.executable_file?(full_path)
190
+
191
+ return false
188
192
  end
189
193
 
190
- !Rubik::Platform.which(cmd).nil?
194
+ !Rubik::Platform.which(bin).nil?
195
+ rescue ArgumentError
196
+ false
191
197
  end
192
198
 
193
199
  def probe_lsp(lang, dir = nil, root_dir: nil, custom_cmd: nil)
194
200
  effective_root = root_dir || dir || Dir.pwd
195
201
  lang_key = lang.to_s.downcase
196
202
  candidates = []
197
- candidates << custom_cmd.to_s.strip if custom_cmd && !custom_cmd.to_s.strip.empty?
203
+ candidates << custom_cmd if custom_cmd && !Rubik::LSP::ServerCommand.argv(custom_cmd).empty?
198
204
  candidates.concat(lsp_candidates(lang_key))
199
205
  candidates.uniq!
200
206