rubik 0.3.4 → 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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +54 -0
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +78 -66
- data/lib/rubik/config/defaults.rb +1 -0
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- data/lib/rubik/repl/commands/agent_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +83 -68
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +110 -94
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
ctx.out.puts
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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)
|