fantasy-cli 1.2.14 → 1.3.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.
- checksums.yaml +4 -4
- data/README.md +8 -1
- data/lib/gsd/agents/communication.rb +260 -0
- data/lib/gsd/agents/swarm.rb +341 -0
- data/lib/gsd/lsp/client.rb +394 -0
- data/lib/gsd/lsp/completion.rb +266 -0
- data/lib/gsd/lsp/diagnostics.rb +259 -0
- data/lib/gsd/lsp/hover.rb +244 -0
- data/lib/gsd/lsp/protocol.rb +434 -0
- data/lib/gsd/lsp/server_manager.rb +290 -0
- data/lib/gsd/lsp/symbols.rb +368 -0
- data/lib/gsd/plugins/api.rb +340 -0
- data/lib/gsd/plugins/hooks.rb +117 -95
- data/lib/gsd/plugins/hot_reload.rb +293 -0
- data/lib/gsd/plugins/registry.rb +273 -0
- data/lib/gsd/tui/agent_panel.rb +182 -0
- data/lib/gsd/tui/animations.rb +320 -0
- data/lib/gsd/tui/app.rb +442 -2
- data/lib/gsd/tui/colors.rb +15 -0
- data/lib/gsd/tui/effects.rb +263 -0
- data/lib/gsd/tui/header.rb +13 -5
- data/lib/gsd/tui/input_box.rb +10 -7
- data/lib/gsd/tui/mouse.rb +388 -0
- data/lib/gsd/tui/persistence.rb +192 -0
- data/lib/gsd/tui/session.rb +273 -0
- data/lib/gsd/tui/status_bar.rb +63 -15
- data/lib/gsd/tui/tab.rb +112 -0
- data/lib/gsd/tui/tab_manager.rb +191 -0
- data/lib/gsd/tui/transitions.rb +262 -0
- data/lib/gsd/version.rb +1 -1
- metadata +22 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module LSP
|
|
5
|
+
# Symbols - Handles LSP document and workspace symbols
|
|
6
|
+
class Symbols
|
|
7
|
+
attr_reader :client, :cache, :cache_ttl
|
|
8
|
+
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
@cache = {}
|
|
12
|
+
@cache_ttl = 30 # seconds
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get document symbols
|
|
17
|
+
def document_symbols(uri)
|
|
18
|
+
return nil unless @client&.running?
|
|
19
|
+
|
|
20
|
+
cache_key = "doc:#{uri}"
|
|
21
|
+
|
|
22
|
+
# Check cache
|
|
23
|
+
cached = check_cache(cache_key)
|
|
24
|
+
return cached if cached
|
|
25
|
+
|
|
26
|
+
# Request from server
|
|
27
|
+
result = @client.document_symbol(uri)
|
|
28
|
+
return nil unless result
|
|
29
|
+
|
|
30
|
+
# Parse symbols
|
|
31
|
+
symbols = parse_symbols(result)
|
|
32
|
+
|
|
33
|
+
# Cache result
|
|
34
|
+
store_cache(cache_key, symbols)
|
|
35
|
+
|
|
36
|
+
symbols
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get workspace symbols
|
|
40
|
+
def workspace_symbols(query = '')
|
|
41
|
+
return nil unless @client&.running?
|
|
42
|
+
|
|
43
|
+
cache_key = "ws:#{query}"
|
|
44
|
+
|
|
45
|
+
# Check cache
|
|
46
|
+
cached = check_cache(cache_key)
|
|
47
|
+
return cached if cached
|
|
48
|
+
|
|
49
|
+
# Request from server
|
|
50
|
+
result = @client.workspace_symbol(query)
|
|
51
|
+
return nil unless result
|
|
52
|
+
|
|
53
|
+
# Parse symbols
|
|
54
|
+
symbols = parse_symbols(result)
|
|
55
|
+
|
|
56
|
+
# Cache result
|
|
57
|
+
store_cache(cache_key, symbols)
|
|
58
|
+
|
|
59
|
+
symbols
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get symbols at position
|
|
63
|
+
def symbol_at(symbols, line, character)
|
|
64
|
+
return nil unless symbols
|
|
65
|
+
|
|
66
|
+
symbols.find do |s|
|
|
67
|
+
range = s[:range]
|
|
68
|
+
next unless range
|
|
69
|
+
|
|
70
|
+
start_pos = range[:start]
|
|
71
|
+
end_pos = range[:end]
|
|
72
|
+
|
|
73
|
+
line >= start_pos[:line] && line <= end_pos[:line] &&
|
|
74
|
+
(line != start_pos[:line] || character >= start_pos[:character]) &&
|
|
75
|
+
(line != end_pos[:line] || character <= end_pos[:character])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Filter symbols by name
|
|
80
|
+
def filter(symbols, pattern)
|
|
81
|
+
return symbols if pattern.empty?
|
|
82
|
+
|
|
83
|
+
regex = Regexp.new(pattern, Regexp::IGNORECASE)
|
|
84
|
+
symbols.select { |s| s[:name] =~ regex }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Group symbols by kind
|
|
88
|
+
def group_by_kind(symbols)
|
|
89
|
+
symbols.group_by { |s| s[:kind] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Format symbol for display
|
|
93
|
+
def format_symbol(symbol, width: 50)
|
|
94
|
+
kind_icon = kind_to_icon(symbol[:kind])
|
|
95
|
+
name = symbol[:name]
|
|
96
|
+
detail = symbol[:detail]
|
|
97
|
+
line = symbol[:range]&.[]('start')&.[]('line') || symbol[:range]&.[](:start)&.[](:line)
|
|
98
|
+
|
|
99
|
+
text = "#{kind_icon} #{name}"
|
|
100
|
+
text += " (#{detail})" if detail && text.length + detail.length < width - 5
|
|
101
|
+
text += " L#{line + 1}" if line
|
|
102
|
+
|
|
103
|
+
text[0...width]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Format symbol list for TUI
|
|
107
|
+
def format_list(symbols, selected_index: 0, width: 50, max_items: 15)
|
|
108
|
+
return [] if symbols.empty?
|
|
109
|
+
|
|
110
|
+
display_items = symbols.first(max_items)
|
|
111
|
+
|
|
112
|
+
display_items.map.with_index do |symbol, index|
|
|
113
|
+
prefix = index == selected_index ? '>' : ' '
|
|
114
|
+
formatted = format_symbol(symbol, width: width - 2)
|
|
115
|
+
"#{prefix} #{formatted}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get breadcrumb path for symbol
|
|
120
|
+
def breadcrumb_path(symbols, line, character)
|
|
121
|
+
containing = symbols.select do |s|
|
|
122
|
+
range = s[:range]
|
|
123
|
+
next false unless range && s[:children]
|
|
124
|
+
|
|
125
|
+
start_pos = range[:start]
|
|
126
|
+
end_pos = range[:end]
|
|
127
|
+
|
|
128
|
+
line >= start_pos[:line] && line <= end_pos[:line] &&
|
|
129
|
+
(line != start_pos[:line] || character >= start_pos[:character]) &&
|
|
130
|
+
(line != end_pos[:line] || character <= end_pos[:character])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
return [] if containing.empty?
|
|
134
|
+
|
|
135
|
+
# Find most specific container
|
|
136
|
+
innermost = containing.min_by do |s|
|
|
137
|
+
range = s[:range]
|
|
138
|
+
lines = range[:end][:line] - range[:start][:line]
|
|
139
|
+
chars = range[:end][:character] - range[:start][:character]
|
|
140
|
+
lines * 1000 + chars
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
path = [innermost]
|
|
144
|
+
path.concat(breadcrumb_path(innermost[:children] || [], line, character))
|
|
145
|
+
path
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Clear cache
|
|
149
|
+
def clear_cache
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
@cache.clear
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Symbol outline (tree view)
|
|
156
|
+
def outline(symbols, indent: 0, max_depth: 5)
|
|
157
|
+
return [] if symbols.nil? || symbols.empty? || indent >= max_depth
|
|
158
|
+
|
|
159
|
+
lines = []
|
|
160
|
+
|
|
161
|
+
symbols.each do |symbol|
|
|
162
|
+
icon = kind_to_icon(symbol[:kind])
|
|
163
|
+
indent_str = ' ' * indent
|
|
164
|
+
name = symbol[:name]
|
|
165
|
+
|
|
166
|
+
lines << "#{indent_str}#{icon} #{name}"
|
|
167
|
+
|
|
168
|
+
# Add children
|
|
169
|
+
children = symbol[:children] || symbol['children']
|
|
170
|
+
if children && !children.empty?
|
|
171
|
+
lines.concat(outline(children, indent: indent + 1, max_depth: max_depth))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
lines
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def parse_symbols(result)
|
|
181
|
+
return [] unless result.is_a?(Array)
|
|
182
|
+
|
|
183
|
+
result.map { |s| normalize_symbol(s) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_symbol(symbol)
|
|
187
|
+
{
|
|
188
|
+
name: symbol['name'] || symbol[:name],
|
|
189
|
+
kind: symbol['kind'] || symbol[:kind],
|
|
190
|
+
detail: symbol['detail'] || symbol[:detail],
|
|
191
|
+
range: symbol['location']&.[]('range') || symbol['range'] || symbol[:range],
|
|
192
|
+
uri: symbol['location']&.[]('uri') || symbol['uri'] || symbol[:uri],
|
|
193
|
+
children: parse_children(symbol['children'] || symbol[:children])
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_children(children)
|
|
198
|
+
return nil unless children.is_a?(Array)
|
|
199
|
+
|
|
200
|
+
children.map { |c| normalize_symbol(c) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def check_cache(key)
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
entry = @cache[key]
|
|
206
|
+
return nil unless entry
|
|
207
|
+
|
|
208
|
+
if Time.now - entry[:time] > @cache_ttl
|
|
209
|
+
@cache.delete(key)
|
|
210
|
+
nil
|
|
211
|
+
else
|
|
212
|
+
entry[:symbols]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def store_cache(key, symbols)
|
|
218
|
+
@mutex.synchronize do
|
|
219
|
+
@cache[key] = {
|
|
220
|
+
symbols: symbols,
|
|
221
|
+
time: Time.now
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Limit cache size
|
|
225
|
+
while @cache.size > 50
|
|
226
|
+
@cache.shift
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def kind_to_icon(kind)
|
|
232
|
+
icons = {
|
|
233
|
+
1 => '📄', # File
|
|
234
|
+
2 => '📦', # Module
|
|
235
|
+
3 => '📦', # Namespace
|
|
236
|
+
4 => '📦', # Package
|
|
237
|
+
5 => 'ⓒ', # Class
|
|
238
|
+
6 => 'ⓜ', # Method
|
|
239
|
+
7 => 'ⓟ', # Property
|
|
240
|
+
8 => 'ⓕ', # Field
|
|
241
|
+
9 => 'ⓒ', # Constructor
|
|
242
|
+
10 => 'ⓔ', # Enum
|
|
243
|
+
11 => 'ⓘ', # Interface
|
|
244
|
+
12 => 'ⓕ', # Function
|
|
245
|
+
13 => 'ⓥ', # Variable
|
|
246
|
+
14 => 'ⓒ', # Constant
|
|
247
|
+
15 => '"', # String
|
|
248
|
+
16 => '#', # Number
|
|
249
|
+
17 => '✓', # Boolean
|
|
250
|
+
18 => '[]', # Array
|
|
251
|
+
19 => '{}', # Object
|
|
252
|
+
20 => 'ⓚ', # Key
|
|
253
|
+
21 => 'ⓘ', # Null
|
|
254
|
+
22 => 'ⓔ', # EnumMember
|
|
255
|
+
23 => '📄', # Struct
|
|
256
|
+
24 => '🔄', # Event
|
|
257
|
+
25 => 'ⓞ', # Operator
|
|
258
|
+
26 => 'ⓣ' # TypeParameter
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
icons[kind] || '❓'
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# SymbolOutline - TUI panel for symbol outline
|
|
266
|
+
class SymbolOutline
|
|
267
|
+
attr_reader :visible, :width, :height, :symbols, :selected_index
|
|
268
|
+
|
|
269
|
+
def initialize(width: 40, height: 20)
|
|
270
|
+
@visible = false
|
|
271
|
+
@width = width
|
|
272
|
+
@height = height
|
|
273
|
+
@symbols = []
|
|
274
|
+
@selected_index = 0
|
|
275
|
+
@flattened = []
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def show
|
|
279
|
+
@visible = true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def hide
|
|
283
|
+
@visible = false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def toggle
|
|
287
|
+
@visible = !@visible
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def update(symbols)
|
|
291
|
+
@symbols = symbols || []
|
|
292
|
+
@selected_index = 0
|
|
293
|
+
flatten_symbols
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def move_up
|
|
297
|
+
@selected_index = [@selected_index - 1, 0].max if @visible
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def move_down
|
|
301
|
+
@selected_index = [@selected_index + 1, @flattened.length - 1].max if @visible
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def current_symbol
|
|
305
|
+
@flattened[@selected_index]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def render
|
|
309
|
+
return [] unless @visible
|
|
310
|
+
|
|
311
|
+
lines = []
|
|
312
|
+
lines << '─' * @width
|
|
313
|
+
lines << ' Outline'.ljust(@width)
|
|
314
|
+
lines << '─' * @width
|
|
315
|
+
|
|
316
|
+
if @flattened.empty?
|
|
317
|
+
lines << ' No symbols'
|
|
318
|
+
else
|
|
319
|
+
display_items = @flattened.first(@height - 4)
|
|
320
|
+
display_items.each_with_index do |item, index|
|
|
321
|
+
prefix = (index + @selected_index.zero? ? '>' : ' ') rescue ' '
|
|
322
|
+
icon = kind_to_icon(item[:kind])
|
|
323
|
+
indent = ' ' * item[:depth]
|
|
324
|
+
name = item[:name][0...@width - indent.length - 5]
|
|
325
|
+
lines << "#{prefix} #{indent}#{icon} #{name}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Pad to height
|
|
330
|
+
while lines.length < @height
|
|
331
|
+
lines << ''
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
lines.first(@height)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private
|
|
338
|
+
|
|
339
|
+
def flatten_symbols
|
|
340
|
+
@flattened = []
|
|
341
|
+
flatten_recursive(@symbols, 0)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def flatten_recursive(symbols, depth)
|
|
345
|
+
return unless symbols
|
|
346
|
+
|
|
347
|
+
symbols.each do |s|
|
|
348
|
+
@flattened << s.merge(depth: depth)
|
|
349
|
+
children = s[:children] || s['children']
|
|
350
|
+
flatten_recursive(children, depth + 1) if children
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def kind_to_icon(kind)
|
|
355
|
+
icons = {
|
|
356
|
+
1 => '📄', 2 => '📦', 3 => '📦', 4 => '📦',
|
|
357
|
+
5 => 'ⓒ', 6 => 'ⓜ', 7 => 'ⓟ', 8 => 'ⓕ',
|
|
358
|
+
9 => 'ⓒ', 10 => 'ⓔ', 11 => 'ⓘ', 12 => 'ⓕ',
|
|
359
|
+
13 => 'ⓥ', 14 => 'ⓒ', 15 => '"', 16 => '#',
|
|
360
|
+
17 => '✓', 18 => '[]', 19 => '{}', 20 => 'ⓚ',
|
|
361
|
+
21 => 'ⓘ', 22 => 'ⓔ', 23 => '📄', 24 => '🔄',
|
|
362
|
+
25 => 'ⓞ', 26 => 'ⓣ'
|
|
363
|
+
}
|
|
364
|
+
icons[kind] || '❓'
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module Plugins
|
|
5
|
+
# API - Interface pública para plugins
|
|
6
|
+
#
|
|
7
|
+
# Fornece métodos seguros para plugins interagirem com o sistema,
|
|
8
|
+
# sem expor detalhes internos perigosos.
|
|
9
|
+
class API
|
|
10
|
+
VERSION = '1.0.0'
|
|
11
|
+
|
|
12
|
+
def initialize(plugin_name)
|
|
13
|
+
@plugin_name = plugin_name
|
|
14
|
+
@registry = Registry.instance
|
|
15
|
+
@hooks = Hooks.instance
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# === Informações do Sistema ===
|
|
19
|
+
|
|
20
|
+
# Versão da API
|
|
21
|
+
def api_version
|
|
22
|
+
VERSION
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Versão do Fantasy CLI
|
|
26
|
+
def cli_version
|
|
27
|
+
Gsd::VERSION
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Plataforma atual
|
|
31
|
+
def platform
|
|
32
|
+
RUBY_PLATFORM
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# === Registro de Plugins ===
|
|
36
|
+
|
|
37
|
+
# Lista plugins instalados
|
|
38
|
+
def list_plugins
|
|
39
|
+
@registry.all.map(&:to_h)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Verifica se plugin existe
|
|
43
|
+
def plugin_exists?(name)
|
|
44
|
+
@registry.exists?(name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Obtém informações de um plugin
|
|
48
|
+
def plugin_info(name)
|
|
49
|
+
plugin = @registry.get(name)
|
|
50
|
+
plugin&.to_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# === Hooks e Eventos ===
|
|
54
|
+
|
|
55
|
+
# Registra handler para evento
|
|
56
|
+
def on(event, priority: 0, &block)
|
|
57
|
+
full_event = "#{@plugin_name}.#{event}"
|
|
58
|
+
@hooks.on(full_event, priority: priority, &block)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Emite evento
|
|
62
|
+
def emit(event, *args)
|
|
63
|
+
full_event = "#{@plugin_name}.#{event}"
|
|
64
|
+
@hooks.trigger(full_event, *args)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Escuta eventos de outros plugins
|
|
68
|
+
def listen_to(plugin, event, &block)
|
|
69
|
+
full_event = "#{plugin}.#{event}"
|
|
70
|
+
@hooks.on(full_event, &block)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Escuta eventos globais
|
|
74
|
+
def listen_global(event, &block)
|
|
75
|
+
@hooks.on(event, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# === Logging ===
|
|
79
|
+
|
|
80
|
+
# Log informativo
|
|
81
|
+
def log_info(message)
|
|
82
|
+
puts "[#{@plugin_name}] #{message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log de warning
|
|
86
|
+
def log_warn(message)
|
|
87
|
+
warn "[#{@plugin_name}] WARNING: #{message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Log de erro
|
|
91
|
+
def log_error(message)
|
|
92
|
+
warn "[#{@plugin_name}] ERROR: #{message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Log de debug (só em modo debug)
|
|
96
|
+
def log_debug(message)
|
|
97
|
+
return unless ENV['DEBUG']
|
|
98
|
+
puts "[#{@plugin_name}] DEBUG: #{message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# === Configuração ===
|
|
102
|
+
|
|
103
|
+
# Lê configuração do plugin
|
|
104
|
+
def get_config(key, default = nil)
|
|
105
|
+
path = config_path
|
|
106
|
+
return default unless File.exist?(path)
|
|
107
|
+
|
|
108
|
+
config = JSON.parse(File.read(path), symbolize_names: true)
|
|
109
|
+
config[key.to_sym] || default
|
|
110
|
+
rescue StandardError
|
|
111
|
+
default
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Salva configuração do plugin
|
|
115
|
+
def set_config(key, value)
|
|
116
|
+
path = config_path
|
|
117
|
+
config = {}
|
|
118
|
+
|
|
119
|
+
if File.exist?(path)
|
|
120
|
+
config = JSON.parse(File.read(path), symbolize_names: true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
config[key.to_sym] = value
|
|
124
|
+
|
|
125
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
126
|
+
File.write(path, JSON.pretty_generate(config))
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
log_error("Failed to save config: #{e.message}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# === Utilidades ===
|
|
132
|
+
|
|
133
|
+
# Lê arquivo (dentro de ~/.gsd apenas)
|
|
134
|
+
def read_file(path)
|
|
135
|
+
safe_path = File.expand_path(path, File.join(Dir.home, '.gsd'))
|
|
136
|
+
return nil unless File.exist?(safe_path)
|
|
137
|
+
return nil unless safe_path.start_with?(File.join(Dir.home, '.gsd'))
|
|
138
|
+
|
|
139
|
+
File.read(safe_path)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Escreve arquivo (dentro de ~/.gsd apenas)
|
|
145
|
+
def write_file(path, content)
|
|
146
|
+
safe_path = File.expand_path(path, File.join(Dir.home, '.gsd'))
|
|
147
|
+
return false unless safe_path.start_with?(File.join(Dir.home, '.gsd'))
|
|
148
|
+
|
|
149
|
+
FileUtils.mkdir_p(File.dirname(safe_path))
|
|
150
|
+
File.write(safe_path, content)
|
|
151
|
+
true
|
|
152
|
+
rescue StandardError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# HTTP GET (com timeout)
|
|
157
|
+
def http_get(url, timeout: 10)
|
|
158
|
+
require 'net/http'
|
|
159
|
+
require 'uri'
|
|
160
|
+
|
|
161
|
+
uri = URI.parse(url)
|
|
162
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
163
|
+
http.use_ssl = uri.scheme == 'https'
|
|
164
|
+
http.open_timeout = timeout
|
|
165
|
+
http.read_timeout = timeout
|
|
166
|
+
|
|
167
|
+
response = http.get(uri.request_uri)
|
|
168
|
+
response.body
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
log_error("HTTP GET failed: #{e.message}")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# === UI (para plugins TUI) ===
|
|
175
|
+
|
|
176
|
+
# Adiciona mensagem ao output (se TUI estiver ativo)
|
|
177
|
+
def ui_message(text, type: :system)
|
|
178
|
+
return unless defined?(Gsd::TUI::App)
|
|
179
|
+
|
|
180
|
+
# Envia via hook para ser capturado pelo App
|
|
181
|
+
@hooks.trigger('tui.message', { text: text, type: type, plugin: @plugin_name })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Adiciona comando à palette (se TUI estiver ativo)
|
|
185
|
+
def ui_command(name, description, &block)
|
|
186
|
+
@hooks.trigger('tui.register_command', {
|
|
187
|
+
name: name,
|
|
188
|
+
description: description,
|
|
189
|
+
plugin: @plugin_name,
|
|
190
|
+
callback: block
|
|
191
|
+
})
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# === Timers ===
|
|
195
|
+
|
|
196
|
+
# Executa código após delay
|
|
197
|
+
def set_timeout(seconds, &block)
|
|
198
|
+
Thread.new do
|
|
199
|
+
sleep(seconds)
|
|
200
|
+
block.call
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Executa código periodicamente
|
|
205
|
+
def set_interval(seconds, &block)
|
|
206
|
+
Thread.new do
|
|
207
|
+
loop do
|
|
208
|
+
sleep(seconds)
|
|
209
|
+
block.call
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# === Storage ===
|
|
215
|
+
|
|
216
|
+
# Salva dados (key-value store)
|
|
217
|
+
def storage_set(key, value)
|
|
218
|
+
storage_path = File.join(Dir.home, '.gsd', 'storage', "#{@plugin_name}.json")
|
|
219
|
+
data = {}
|
|
220
|
+
|
|
221
|
+
if File.exist?(storage_path)
|
|
222
|
+
data = JSON.parse(File.read(storage_path), symbolize_names: true)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
data[key.to_sym] = value
|
|
226
|
+
FileUtils.mkdir_p(File.dirname(storage_path))
|
|
227
|
+
File.write(storage_path, JSON.pretty_generate(data))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Lê dados
|
|
231
|
+
def storage_get(key, default = nil)
|
|
232
|
+
storage_path = File.join(Dir.home, '.gsd', 'storage', "#{@plugin_name}.json")
|
|
233
|
+
return default unless File.exist?(storage_path)
|
|
234
|
+
|
|
235
|
+
data = JSON.parse(File.read(storage_path), symbolize_names: true)
|
|
236
|
+
data.fetch(key.to_sym, default)
|
|
237
|
+
rescue StandardError
|
|
238
|
+
default
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Limpa storage
|
|
242
|
+
def storage_clear
|
|
243
|
+
storage_path = File.join(Dir.home, '.gsd', 'storage', "#{@plugin_name}.json")
|
|
244
|
+
File.delete(storage_path) if File.exist?(storage_path)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def config_path
|
|
250
|
+
File.join(Dir.home, '.gsd', 'plugins', @plugin_name, 'config.json')
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# PluginContext - Contexto para execução de plugins
|
|
255
|
+
# Fornece um ambiente isolado onde o plugin roda
|
|
256
|
+
class PluginContext
|
|
257
|
+
SAFE_METHODS = %i[
|
|
258
|
+
puts print p puts print
|
|
259
|
+
map select reject each each_with_index
|
|
260
|
+
chomp split gsub sub strip
|
|
261
|
+
length size empty? nil?
|
|
262
|
+
to_s to_i to_f to_a to_h to_sym
|
|
263
|
+
freeze dup clone
|
|
264
|
+
upcase downcase capitalize
|
|
265
|
+
include? start_with? end_with?
|
|
266
|
+
+ - * / % ** << >>
|
|
267
|
+
== != < > <= >= <=>
|
|
268
|
+
[] []= fetch dig
|
|
269
|
+
keys values has_key?
|
|
270
|
+
new create
|
|
271
|
+
sleep rand srand
|
|
272
|
+
time Time.now
|
|
273
|
+
].freeze
|
|
274
|
+
|
|
275
|
+
def initialize(api)
|
|
276
|
+
@api = api
|
|
277
|
+
@exports = {}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Executa plugin em contexto seguro
|
|
281
|
+
def run(code)
|
|
282
|
+
create_binding.instance_eval(code)
|
|
283
|
+
rescue StandardError => e
|
|
284
|
+
@api.log_error("Execution error: #{e.message}")
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Exporta função para outros plugins
|
|
289
|
+
def export(name, &block)
|
|
290
|
+
@exports[name] = block
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Chama função exportada de outro plugin
|
|
294
|
+
def import(plugin_name, function_name, *args)
|
|
295
|
+
# Implementação via registry
|
|
296
|
+
plugin = Registry.instance.get(plugin_name)
|
|
297
|
+
return nil unless plugin
|
|
298
|
+
|
|
299
|
+
context = plugin.instance_variable_get(:@context)
|
|
300
|
+
return nil unless context
|
|
301
|
+
|
|
302
|
+
handler = context.instance_variable_get(:@exports)[function_name]
|
|
303
|
+
return nil unless handler
|
|
304
|
+
|
|
305
|
+
handler.call(*args)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
def create_binding
|
|
311
|
+
binding_context = Object.new
|
|
312
|
+
|
|
313
|
+
# Define métodos seguros
|
|
314
|
+
SAFE_METHODS.each do |method|
|
|
315
|
+
define_safe_method(binding_context, method)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Expõe API
|
|
319
|
+
binding_context.define_singleton_method(:api) { @api }
|
|
320
|
+
binding_context.define_singleton_method(:export) { |n, &b| export(n, &b) }
|
|
321
|
+
binding_context.define_singleton_method(:import) { |p, f, *a| import(p, f, *a) }
|
|
322
|
+
|
|
323
|
+
binding_context.instance_variable_set(:@api, @api)
|
|
324
|
+
binding_context.instance_variable_set(:@context, self)
|
|
325
|
+
|
|
326
|
+
binding_context
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def define_safe_method(obj, method_name)
|
|
330
|
+
obj.define_singleton_method(method_name) do |*args, &block|
|
|
331
|
+
if args.first.respond_to?(method_name)
|
|
332
|
+
args.first.send(method_name, *args[1..-1], &block)
|
|
333
|
+
else
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|