rubik 0.3.4 → 0.4.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +105 -2
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +9 -2
  10. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  11. data/lib/rubik/buffer/manager.rb +78 -66
  12. data/lib/rubik/config/defaults.rb +1 -0
  13. data/lib/rubik/context/git_lister.rb +3 -1
  14. data/lib/rubik/diagnostics/parser.rb +53 -1
  15. data/lib/rubik/llm/anthropic.rb +5 -2
  16. data/lib/rubik/llm/openai_compatible.rb +83 -68
  17. data/lib/rubik/llm/registry.rb +81 -74
  18. data/lib/rubik/lsp/client.rb +320 -96
  19. data/lib/rubik/lsp/manager.rb +83 -12
  20. data/lib/rubik/lsp/server_command.rb +102 -0
  21. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  22. data/lib/rubik/orchestrator/step_executor.rb +138 -154
  23. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  24. data/lib/rubik/prompt/token_estimator.rb +16 -12
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/buffer.rb +122 -88
  29. data/lib/rubik/repl/commands/mcp.rb +120 -92
  30. data/lib/rubik/repl/commands/session.rb +90 -59
  31. data/lib/rubik/repl/commands/workspace.rb +110 -94
  32. data/lib/rubik/repl/shell.rb +10 -1
  33. data/lib/rubik/runtime/discovery.rb +12 -6
  34. data/lib/rubik/security/classifier/classifier.rb +3 -1
  35. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  36. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  37. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  38. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  39. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  40. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  41. data/lib/rubik/tools/truncator.rb +21 -8
  42. data/lib/rubik/version.rb +1 -1
  43. metadata +2 -1
@@ -27,104 +27,19 @@ module Rubik
27
27
  when "list"
28
28
  list_tools(ctx, manager, clean_args[1])
29
29
  when "add"
30
- name = clean_args[1]
31
- cmd = clean_args[2]
32
- server_args = clean_args[3..] || []
33
- if name.nil? || cmd.nil?
34
- ctx.out.puts "Usage: !mcp add <name> <command> [args...] [-p|-g]"
35
- next
36
- end
37
- defn = { "command" => cmd, "args" => server_args }
38
- begin
39
- res = manager.add_server(name, defn, auto_start: true, persist: scope)
40
- if res[:ok]
41
- ctx.out.puts "MCP server '#{name}' added and started (#{res[:tools]} tool(s) registered) [#{Base.scope_label(scope)}]."
42
- else
43
- Base.report_persist_failure(ctx, res, scope)
44
- end
45
- rescue StandardError => e
46
- ctx.out.puts "Failed to add/start MCP server '#{name}': #{e.message}"
47
- end
30
+ handle_add(ctx, manager, clean_args, scope)
48
31
  when "remove", "rm", "delete"
49
- name = clean_args[1]
50
- if name.nil?
51
- ctx.out.puts "Usage: !mcp remove <name> [-p|-g]"
52
- next
53
- end
54
- begin
55
- res = manager.remove_server(name, persist: scope)
56
- if res[:ok]
57
- ctx.out.puts "MCP server '#{name}' removed [#{Base.scope_label(scope)}]."
58
- else
59
- Base.report_persist_failure(ctx, res, scope)
60
- end
61
- rescue StandardError => e
62
- ctx.out.puts "Failed to remove MCP server '#{name}': #{e.message}"
63
- end
32
+ handle_remove(ctx, manager, clean_args, scope)
64
33
  when "enable", "on", "start"
65
- name = clean_args[1]
66
- if name.nil?
67
- ctx.out.puts "Usage: !mcp enable <name>"
68
- next
69
- end
70
- begin
71
- manager.enable_server(name)
72
- tool_count = (manager.tools[name] || []).size
73
- ctx.out.puts "MCP server '#{name}' enabled (#{tool_count} tool(s) registered)."
74
- rescue StandardError => e
75
- ctx.out.puts "Failed to enable MCP server '#{name}': #{e.message}"
76
- end
34
+ handle_enable(ctx, manager, clean_args)
77
35
  when "disable", "off", "stop"
78
- name = clean_args[1]
79
- if name.nil?
80
- ctx.out.puts "Usage: !mcp disable <name>"
81
- next
82
- end
83
- begin
84
- manager.disable_server(name)
85
- ctx.out.puts "MCP server '#{name}' disabled and tools unregistered."
86
- rescue StandardError => e
87
- ctx.out.puts "Failed to disable MCP server '#{name}': #{e.message}"
88
- end
36
+ handle_disable(ctx, manager, clean_args)
89
37
  when "connect"
90
- name = clean_args[1]
91
- cmd = clean_args[2]
92
- server_args = clean_args[3..] || []
93
- if name.nil? || cmd.nil?
94
- ctx.out.puts "Usage: !mcp connect <name> <command> [args...]"
95
- next
96
- end
97
- begin
98
- manager.connect(name, cmd, server_args)
99
- tool_count = (manager.tools[name] || []).size
100
- ctx.out.puts "MCP server '#{name}' connected [Session only] (#{tool_count} tool(s) registered)."
101
- rescue StandardError => e
102
- ctx.out.puts "Failed to connect MCP server '#{name}': #{e.message}"
103
- end
38
+ handle_connect(ctx, manager, clean_args)
104
39
  when "disconnect"
105
- name = clean_args[1]
106
- if name.nil?
107
- ctx.out.puts "Usage: !mcp disconnect <name>"
108
- next
109
- end
110
- begin
111
- manager.disconnect(name)
112
- ctx.out.puts "MCP server '#{name}' disconnected."
113
- rescue StandardError => e
114
- ctx.out.puts "Failed to disconnect MCP server '#{name}': #{e.message}"
115
- end
40
+ handle_disconnect(ctx, manager, clean_args)
116
41
  when "ping"
117
- name = clean_args[1]
118
- if name.nil?
119
- ctx.out.puts "Usage: !mcp ping <name>"
120
- next
121
- end
122
- begin
123
- latency = manager.ping(name)
124
- ctx.out.puts "MCP server '#{name}' is alive (ping: #{latency} ms)."
125
- rescue StandardError => e
126
- ctx.out.puts "Ping failed for MCP server '#{name}': #{e.message}"
127
- end
42
+ handle_ping(ctx, manager, clean_args)
128
43
  else
129
44
  ctx.out.puts "Unknown !mcp action '#{sub}'. Valid actions: status, list, add, remove, enable, disable, connect, disconnect, ping."
130
45
  end
@@ -188,6 +103,119 @@ module Rubik
188
103
  ctx.out.puts "No active MCP tools across servers."
189
104
  end
190
105
  end
106
+
107
+ private_class_method def self.handle_add(ctx, manager, clean_args, scope)
108
+ name = clean_args[1]
109
+ cmd = clean_args[2]
110
+ server_args = clean_args[3..] || []
111
+ if name.nil? || cmd.nil?
112
+ ctx.out.puts "Usage: !mcp add <name> <command> [args...] [-p|-g]"
113
+ return
114
+ end
115
+ defn = { "command" => cmd, "args" => server_args }
116
+ begin
117
+ res = manager.add_server(name, defn, auto_start: true, persist: scope)
118
+ if res[:ok]
119
+ ctx.out.puts "MCP server '#{name}' added and started (#{res[:tools]} tool(s) registered) [#{Base.scope_label(scope)}]."
120
+ else
121
+ Base.report_persist_failure(ctx, res, scope)
122
+ end
123
+ rescue StandardError => e
124
+ ctx.out.puts "Failed to add/start MCP server '#{name}': #{e.message}"
125
+ end
126
+ end
127
+
128
+ private_class_method def self.handle_remove(ctx, manager, clean_args, scope)
129
+ name = clean_args[1]
130
+ if name.nil?
131
+ ctx.out.puts "Usage: !mcp remove <name> [-p|-g]"
132
+ return
133
+ end
134
+ begin
135
+ res = manager.remove_server(name, persist: scope)
136
+ if res[:ok]
137
+ ctx.out.puts "MCP server '#{name}' removed [#{Base.scope_label(scope)}]."
138
+ else
139
+ Base.report_persist_failure(ctx, res, scope)
140
+ end
141
+ rescue StandardError => e
142
+ ctx.out.puts "Failed to remove MCP server '#{name}': #{e.message}"
143
+ end
144
+ end
145
+
146
+ private_class_method def self.handle_enable(ctx, manager, clean_args)
147
+ name = clean_args[1]
148
+ if name.nil?
149
+ ctx.out.puts "Usage: !mcp enable <name>"
150
+ return
151
+ end
152
+ begin
153
+ manager.enable_server(name)
154
+ tool_count = (manager.tools[name] || []).size
155
+ ctx.out.puts "MCP server '#{name}' enabled (#{tool_count} tool(s) registered)."
156
+ rescue StandardError => e
157
+ ctx.out.puts "Failed to enable MCP server '#{name}': #{e.message}"
158
+ end
159
+ end
160
+
161
+ private_class_method def self.handle_disable(ctx, manager, clean_args)
162
+ name = clean_args[1]
163
+ if name.nil?
164
+ ctx.out.puts "Usage: !mcp disable <name>"
165
+ return
166
+ end
167
+ begin
168
+ manager.disable_server(name)
169
+ ctx.out.puts "MCP server '#{name}' disabled and tools unregistered."
170
+ rescue StandardError => e
171
+ ctx.out.puts "Failed to disable MCP server '#{name}': #{e.message}"
172
+ end
173
+ end
174
+
175
+ private_class_method def self.handle_connect(ctx, manager, clean_args)
176
+ name = clean_args[1]
177
+ cmd = clean_args[2]
178
+ server_args = clean_args[3..] || []
179
+ if name.nil? || cmd.nil?
180
+ ctx.out.puts "Usage: !mcp connect <name> <command> [args...]"
181
+ return
182
+ end
183
+ begin
184
+ manager.connect(name, cmd, server_args)
185
+ tool_count = (manager.tools[name] || []).size
186
+ ctx.out.puts "MCP server '#{name}' connected [Session only] (#{tool_count} tool(s) registered)."
187
+ rescue StandardError => e
188
+ ctx.out.puts "Failed to connect MCP server '#{name}': #{e.message}"
189
+ end
190
+ end
191
+
192
+ private_class_method def self.handle_disconnect(ctx, manager, clean_args)
193
+ name = clean_args[1]
194
+ if name.nil?
195
+ ctx.out.puts "Usage: !mcp disconnect <name>"
196
+ return
197
+ end
198
+ begin
199
+ manager.disconnect(name)
200
+ ctx.out.puts "MCP server '#{name}' disconnected."
201
+ rescue StandardError => e
202
+ ctx.out.puts "Failed to disconnect MCP server '#{name}': #{e.message}"
203
+ end
204
+ end
205
+
206
+ private_class_method def self.handle_ping(ctx, manager, clean_args)
207
+ name = clean_args[1]
208
+ if name.nil?
209
+ ctx.out.puts "Usage: !mcp ping <name>"
210
+ return
211
+ end
212
+ begin
213
+ latency = manager.ping(name)
214
+ ctx.out.puts "MCP server '#{name}' is alive (ping: #{latency} ms)."
215
+ rescue StandardError => e
216
+ ctx.out.puts "Ping failed for MCP server '#{name}': #{e.message}"
217
+ end
218
+ end
191
219
  end
192
220
  end
193
221
  end
@@ -16,6 +16,27 @@ module Rubik
16
16
 
17
17
  def self.register(shell)
18
18
  shell.register_command("history", description: "List recent session history", audience: :both) do |args, ctx|
19
+ run_history(args, ctx)
20
+ end
21
+
22
+ # Plan 91 — explicit multi-line paste mode: collects lines until a
23
+ # lone "." line, then dispatches the joined text as one prompt turn.
24
+ shell.register_command("paste", description: "Multi-line paste mode (finish with lone '.')") do |_args, ctx|
25
+ run_paste(ctx)
26
+ end
27
+
28
+ shell.register_command("replay", description: "Replay session file") do |args, ctx|
29
+ run_replay(args, ctx)
30
+ end
31
+
32
+ shell.register_command("session", description: "Manage recorded sessions (list|resume|info|prune)",
33
+ usage: USAGE_SESSION) do |args, ctx|
34
+ dispatch_session(args, ctx)
35
+ end
36
+ end
37
+
38
+ class << self
39
+ def run_history(args, ctx)
19
40
  limit = (args.first || 20).to_i
20
41
  hist = ctx.recorder.read_history(limit)
21
42
  hist.each_with_index do |item, idx|
@@ -23,14 +44,12 @@ module Rubik
23
44
  end
24
45
  end
25
46
 
26
- # Plan 91 — explicit multi-line paste mode: collects lines until a
27
- # lone "." line, then dispatches the joined text as one prompt turn.
28
- shell.register_command("paste", description: "Multi-line paste mode (finish with lone '.')") do |_args, ctx|
47
+ def run_paste(ctx)
29
48
  ctx.request_paste!
30
49
  ctx.out.puts "[Rubik] Paste modu bir sonraki prompt'ta başlayacak."
31
50
  end
32
51
 
33
- shell.register_command("replay", description: "Replay session file") do |args, ctx|
52
+ def run_replay(args, ctx)
34
53
  path = args.first
35
54
  replayer = ctx.session_replayer
36
55
  if replayer.replay_file(path)
@@ -40,77 +59,85 @@ module Rubik
40
59
  end
41
60
  end
42
61
 
43
- shell.register_command("session", description: "Manage recorded sessions (list|resume|info|prune)",
44
- usage: USAGE_SESSION) do |args, ctx|
62
+ def dispatch_session(args, ctx)
45
63
  sub = args.first&.downcase || "list"
46
64
  root_d = ctx.buffer_manager&.root_dir || Dir.pwd
47
65
  home_d = ctx.config&.home_dir || ENV.fetch("HOME", nil)
48
66
 
49
67
  case sub
50
68
  when "list"
51
- sessions = Rubik::Session::Manager.list_sessions(root_d, home_d, config: ctx.config)
52
- if sessions.empty?
53
- ctx.out.puts "No recorded sessions found for this workspace."
54
- else
55
- ctx.out.puts "=== Recorded Sessions (#{sessions.size}) ==="
56
- sessions.each_with_index do |s, idx|
57
- mark = s[:path] == ctx.recorder.session_file ? " [CURRENT]" : ""
58
- ctx.out.puts " #{idx + 1}. #{s[:id]} (#{s[:scope]}) - #{s[:event_count]} events - Last: \"#{s[:last_prompt]}\"#{mark}"
59
- end
60
- end
69
+ run_list(ctx, root_d, home_d)
61
70
  when "resume", "continue", "load"
62
- target_id = args[1]
63
- if target_id.nil?
64
- picked = interactive_session_pick(ctx)
65
- if picked == :picker_cancelled
66
- ctx.out.puts "[Rubik::Session] Resume iptal edildi."
67
- next
68
- end
69
- target_id = picked || :latest
70
- end
71
- res = ctx.resume_session(target_id)
72
- ctx.out.puts "Resume failed: #{res[:error]}" unless res[:ok]
71
+ run_resume(args[1], ctx)
73
72
  when "info"
74
- ctx.out.puts "Active Session File: #{ctx.recorder.session_file}"
75
- ctx.out.puts "Total Events in Session: #{ctx.recorder.events.size}"
73
+ run_info(ctx)
76
74
  when "prune", "clean", "cleanup"
77
75
  run_prune(args.drop(1), ctx)
78
76
  when "save"
79
- target_name = args[1]
80
- if target_name.nil? || target_name.strip.empty?
81
- ctx.out.puts "Usage: !session save <name>"
82
- else
83
- safe_name = File.basename(target_name.strip.sub(/\.jsonl$/, ""))
84
- if safe_name.empty? || safe_name.start_with?(".")
85
- ctx.out.puts "Error: Invalid session name '#{target_name}'."
86
- next
87
- end
88
-
89
- target_dir = File.join(root_d, ".rubik", "sessions")
90
- FileUtils.mkdir_p(target_dir)
91
- target_file = File.join(target_dir, "#{safe_name}.jsonl")
92
-
93
- if ctx.recorder.session_file && File.exist?(ctx.recorder.session_file)
94
- FileUtils.cp(ctx.recorder.session_file, target_file)
95
- else
96
- File.open(target_file, "w") do |f|
97
- ctx.recorder.events.each { |ev| f.puts(JSON.dump(ev)) }
98
- end
99
- end
100
- ctx.recorder.switch_session_file(target_file, cleanup_empty_current: false)
101
- ctx.out.puts "[Rubik::Session] Session saved to #{target_file}"
102
- end
77
+ run_save(args[1], ctx, root_d)
103
78
  else
104
79
  ctx.out.puts USAGE_SESSION
105
80
  end
106
81
  end
107
- end
108
82
 
109
- # Interactive session picker (Antigravity /resume style): arrow-key
110
- # selection over recorded sessions on capable TTYs. Returns the
111
- # session id, :picker_cancelled on Ctrl+C (caller aborts the
112
- # resume), or nil in pipes/tests so `resume` falls back to :latest.
113
- class << self
83
+ def run_list(ctx, root_d, home_d)
84
+ sessions = Rubik::Session::Manager.list_sessions(root_d, home_d, config: ctx.config)
85
+ if sessions.empty?
86
+ ctx.out.puts "No recorded sessions found for this workspace."
87
+ else
88
+ ctx.out.puts "=== Recorded Sessions (#{sessions.size}) ==="
89
+ sessions.each_with_index do |s, idx|
90
+ mark = s[:path] == ctx.recorder.session_file ? " [CURRENT]" : ""
91
+ ctx.out.puts " #{idx + 1}. #{s[:id]} (#{s[:scope]}) - #{s[:event_count]} events - Last: \"#{s[:last_prompt]}\"#{mark}"
92
+ end
93
+ end
94
+ end
95
+
96
+ def run_resume(target_id, ctx)
97
+ if target_id.nil?
98
+ picked = interactive_session_pick(ctx)
99
+ if picked == :picker_cancelled
100
+ ctx.out.puts "[Rubik::Session] Resume iptal edildi."
101
+ return
102
+ end
103
+ target_id = picked || :latest
104
+ end
105
+ res = ctx.resume_session(target_id)
106
+ ctx.out.puts "Resume failed: #{res[:error]}" unless res[:ok]
107
+ end
108
+
109
+ def run_info(ctx)
110
+ ctx.out.puts "Active Session File: #{ctx.recorder.session_file}"
111
+ ctx.out.puts "Total Events in Session: #{ctx.recorder.events.size}"
112
+ end
113
+
114
+ def run_save(target_name, ctx, root_d)
115
+ if target_name.nil? || target_name.strip.empty?
116
+ ctx.out.puts "Usage: !session save <name>"
117
+ return
118
+ end
119
+
120
+ safe_name = File.basename(target_name.strip.sub(/\.jsonl$/, ""))
121
+ if safe_name.empty? || safe_name.start_with?(".")
122
+ ctx.out.puts "Error: Invalid session name '#{target_name}'."
123
+ return
124
+ end
125
+
126
+ target_dir = File.join(root_d, ".rubik", "sessions")
127
+ FileUtils.mkdir_p(target_dir)
128
+ target_file = File.join(target_dir, "#{safe_name}.jsonl")
129
+
130
+ if ctx.recorder.session_file && File.exist?(ctx.recorder.session_file)
131
+ FileUtils.cp(ctx.recorder.session_file, target_file)
132
+ else
133
+ File.open(target_file, "w") do |f|
134
+ ctx.recorder.events.each { |ev| f.puts(JSON.dump(ev)) }
135
+ end
136
+ end
137
+ ctx.recorder.switch_session_file(target_file, cleanup_empty_current: false)
138
+ ctx.out.puts "[Rubik::Session] Session saved to #{target_file}"
139
+ end
140
+
114
141
  # `!session prune [days] [--apply] [--all]` — dry-run by default:
115
142
  # prints the blast radius of files that WOULD be deleted and only
116
143
  # unlinks once the operator passes --apply. The active session file
@@ -154,6 +181,10 @@ module Rubik
154
181
  days == days.to_i ? "#{days.to_i}d" : "#{days.round(1)}d"
155
182
  end
156
183
 
184
+ # Interactive session picker (Antigravity /resume style): arrow-key
185
+ # selection over recorded sessions on capable TTYs. Returns the
186
+ # session id, :picker_cancelled on Ctrl+C (caller aborts the
187
+ # resume), or nil in pipes/tests so `resume` falls back to :latest.
157
188
  def interactive_session_pick(ctx)
158
189
  return nil unless Rubik::UI::Capability.interactive?(input: ctx.input, output: ctx.output)
159
190
 
@@ -149,114 +149,130 @@ module Rubik
149
149
 
150
150
  case sub
151
151
  when "list", "status"
152
- extra = ctx.buffer_manager&.additional_roots || []
153
- rw_set = ctx.buffer_manager&.rw_roots || Set.new
154
- allowed_ext = ctx.buffer_manager&.allowed_external_dirs || []
155
- rw_allowed_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
156
- glob_rw = ctx.buffer_manager&.allow_external_writes || false
157
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
158
-
159
- ctx.out.puts "=== Configured Workspace Roots ==="
160
- ctx.out.puts " • [PRIMARY] #{effective_root} (Read-Write)"
161
- if extra.empty?
162
- ctx.out.puts " (No additional workspace roots configured)"
163
- else
164
- extra.each_with_index do |r, idx|
165
- mode_str = glob_rw || rw_set.include?(r) ? "Read-Write" : "Read-Only"
166
- ctx.out.puts " • [EXTRA #{idx + 1}] #{r} (#{mode_str})"
167
- end
168
- 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
169
163
 
170
- ctx.out.puts "=== Allowed External Dirs (Zero-AST) ==="
171
- if allowed_ext.empty?
172
- ctx.out.puts " (No allowed external directories configured)"
173
- else
174
- allowed_ext.each_with_index do |d, idx|
175
- mode_str = glob_rw || rw_allowed_set.include?(d) ? "Read-Write" : "Read-Only"
176
- ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
177
- end
178
- 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
179
168
 
180
- ctx.out.puts "External Writes Allowed Globally : #{glob_rw}"
181
- ctx.out.puts "Auto-Index Additional Roots : #{auto_idx}"
182
- when "allow"
183
- target_path = clean_args[1]
184
- if target_path.nil? || target_path.empty?
185
- ctx.out.puts "Usage: !workspace allow <path> [--rw|--ro] [--project|--global]"
186
- else
187
- begin
188
- ctx.guard.verify_root_safety!(target_path)
189
- rescue Rubik::Security::BlockedWriteError => e
190
- ctx.out.puts "Security Error: #{e.message}"
191
- next
192
- 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)
193
176
 
194
- mode_rw = !is_ro
195
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
196
- ctx.sandbox_runner&.add_allowed_dir(target_path)
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
197
187
 
198
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
199
- current_dirs << target_path unless current_dirs.include?(target_path)
200
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
201
- next if Base.report_persist_failure(ctx, res, scope)
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
202
197
 
203
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
204
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
205
- end
206
- when "add"
207
- target_path = clean_args[1]
208
- if target_path.nil? || target_path.empty?
209
- ctx.out.puts "Usage: !workspace add <path> [--rw] [--project|--global]"
210
- else
211
- begin
212
- ctx.guard.verify_root_safety!(target_path)
213
- rescue Rubik::Security::BlockedWriteError => e
214
- ctx.out.puts "Security Error: #{e.message}"
215
- next
216
- end
198
+ ctx.out.puts "External Writes Allowed Globally : #{glob_rw}"
199
+ ctx.out.puts "Auto-Index Additional Roots : #{auto_idx}"
200
+ end
217
201
 
218
- expanded = ctx.buffer_manager.add_root(target_path, rw: is_rw)
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
219
207
 
220
- # Sync context index & AST coeffects
221
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
222
- ctx.sync_workspace_index! if auto_idx
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
223
214
 
224
- # Persist to config
225
- current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
226
- current_roots << target_path unless current_roots.include?(target_path)
227
- res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
228
- next if Base.report_persist_failure(ctx, res, scope)
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)
229
218
 
230
- mode_str = is_rw ? "Read-Write" : "Read-Only"
231
- ctx.out.puts "[Rubik::Workspace] Added additional root: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
232
- end
233
- when "remove", "rm", "delete"
234
- target_path = clean_args[1]
235
- if target_path.nil? || target_path.empty?
236
- ctx.out.puts "Usage: !workspace remove <path> [--project|--global]"
237
- else
238
- expanded = ctx.buffer_manager.remove_root(target_path)
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)
239
223
 
240
- # Sync context index & AST coeffects
241
- auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
242
- ctx.sync_workspace_index! if auto_idx
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
243
227
 
244
- # Persist to config
245
- current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
246
- current_roots.reject! { |r| File.expand_path(r, effective_root) == expanded || r == target_path }
247
- res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
248
- next if Base.report_persist_failure(ctx, res, scope)
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
249
233
 
250
- ctx.out.puts "[Rubik::Workspace] Removed additional root: #{expanded} [#{Base.scope_label(scope)}]."
251
- end
252
- else
253
- ctx.out.puts USAGE_WORKSPACE
254
- end
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
255
239
  end
256
240
 
257
- shell.register_command("workspace", description: "Manage multiple workspace roots (list|add <path> [--rw]|allow <path>|remove <path>)",
258
- usage: USAGE_WORKSPACE, &workspace_handler)
259
- shell.register_command("roots", description: "Alias for !workspace", usage: USAGE_WORKSPACE, &workspace_handler)
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
261
+ end
262
+
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)}]."
260
276
  end
261
277
 
262
278
  private_class_method def self.register_allow_command(shell)