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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module Plugins
5
+ # Plugin - Representa um plugin individual
6
+ #
7
+ # Cada plugin tem:
8
+ # - Metadata (nome, versão, autor, descrição)
9
+ # - Estado (enabled/disabled)
10
+ # - Hooks registrados
11
+ # - Dependências
12
+ class Plugin
13
+ attr_reader :name, :version, :author, :description, :path, :hooks
14
+ attr_accessor :enabled
15
+
16
+ def initialize(name:, version:, path:, author: nil, description: nil, dependencies: [])
17
+ @name = name
18
+ @version = version
19
+ @path = path
20
+ @author = author || 'Unknown'
21
+ @description = description || 'No description'
22
+ @dependencies = dependencies
23
+ @enabled = false
24
+ @hooks = {}
25
+ @instance = nil
26
+ end
27
+
28
+ # Ativa o plugin
29
+ def enable!
30
+ return true if @enabled
31
+
32
+ # Verifica dependências
33
+ missing = check_dependencies
34
+ unless missing.empty?
35
+ raise PluginError, "Missing dependencies: #{missing.join(', ')}"
36
+ end
37
+
38
+ @enabled = true
39
+ trigger_hook(:enable)
40
+ true
41
+ end
42
+
43
+ # Desativa o plugin
44
+ def disable!
45
+ return true unless @enabled
46
+
47
+ trigger_hook(:disable)
48
+ @enabled = false
49
+ true
50
+ end
51
+
52
+ # Registra um hook
53
+ def register_hook(event, &block)
54
+ @hooks[event] ||= []
55
+ @hooks[event] << block
56
+ end
57
+
58
+ # Dispara hooks para um evento
59
+ def trigger_hook(event, *args)
60
+ return unless @hooks[event]
61
+
62
+ @hooks[event].each { |h| h.call(*args) }
63
+ end
64
+
65
+ # Verifica dependências
66
+ def check_dependencies
67
+ registry = Registry.instance
68
+ @dependencies.reject { |dep| registry.get(dep) }
69
+ end
70
+
71
+ # Converte para hash
72
+ def to_h
73
+ {
74
+ name: @name,
75
+ version: @version,
76
+ author: @author,
77
+ description: @description,
78
+ enabled: @enabled,
79
+ dependencies: @dependencies,
80
+ path: @path
81
+ }
82
+ end
83
+
84
+ # Carrega código do plugin
85
+ def load!
86
+ return if @loaded
87
+
88
+ if File.exist?(@path)
89
+ require @path
90
+ @loaded = true
91
+ trigger_hook(:load)
92
+ end
93
+ end
94
+ end
95
+
96
+ # PluginError - Exceção para erros de plugin
97
+ class PluginError < StandardError; end
98
+
99
+ # Registry - Registro central de plugins
100
+ #
101
+ # Singleton que gerencia todos os plugins instalados
102
+ class Registry
103
+ include Singleton
104
+
105
+ attr_reader :plugins_dir
106
+
107
+ def initialize
108
+ @plugins = {}
109
+ @mutex = Mutex.new
110
+ @plugins_dir = File.join(Dir.home, '.gsd', 'plugins')
111
+ ensure_plugins_dir
112
+ end
113
+
114
+ # Registra um novo plugin
115
+ def register(plugin)
116
+ @mutex.synchronize do
117
+ if @plugins[plugin.name]
118
+ raise PluginError, "Plugin '#{plugin.name}' already registered"
119
+ end
120
+
121
+ @plugins[plugin.name] = plugin
122
+ end
123
+ end
124
+
125
+ # Remove um plugin
126
+ def unregister(name)
127
+ @mutex.synchronize do
128
+ plugin = @plugins[name]
129
+ if plugin
130
+ plugin.disable! if plugin.enabled
131
+ @plugins.delete(name)
132
+ end
133
+ end
134
+ end
135
+
136
+ # Obtém um plugin pelo nome
137
+ def get(name)
138
+ @mutex.synchronize { @plugins[name] }
139
+ end
140
+
141
+ # Lista todos os plugins
142
+ def all
143
+ @mutex.synchronize { @plugins.values.dup }
144
+ end
145
+
146
+ # Lista plugins habilitados
147
+ def enabled
148
+ @mutex.synchronize { @plugins.values.select(&:enabled) }
149
+ end
150
+
151
+ # Verifica se plugin existe
152
+ def exists?(name)
153
+ @mutex.synchronize { @plugins.key?(name) }
154
+ end
155
+
156
+ # Habilita um plugin
157
+ def enable(name)
158
+ plugin = get(name)
159
+ raise PluginError, "Plugin '#{name}' not found" unless plugin
160
+
161
+ plugin.enable!
162
+ end
163
+
164
+ # Desabilita um plugin
165
+ def disable(name)
166
+ plugin = get(name)
167
+ raise PluginError, "Plugin '#{name}' not found" unless plugin
168
+
169
+ plugin.disable!
170
+ end
171
+
172
+ # Busca plugins por nome/padrão
173
+ def search(pattern)
174
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
175
+ @mutex.synchronize do
176
+ @plugins.values.select { |p| p.name =~ regex || p.description =~ regex }
177
+ end
178
+ end
179
+
180
+ # Lista plugins que podem ser carregados do disco
181
+ def discover
182
+ discovered = []
183
+ return discovered unless Dir.exist?(@plugins_dir)
184
+
185
+ Dir.glob(File.join(@plugins_dir, '*', 'plugin.rb')).each do |path|
186
+ begin
187
+ metadata = load_metadata(path)
188
+ discovered << Plugin.new(
189
+ name: metadata[:name],
190
+ version: metadata[:version],
191
+ path: path,
192
+ author: metadata[:author],
193
+ description: metadata[:description],
194
+ dependencies: metadata[:dependencies] || []
195
+ )
196
+ rescue StandardError => e
197
+ warn "Failed to load plugin from #{path}: #{e.message}"
198
+ end
199
+ end
200
+
201
+ discovered
202
+ end
203
+
204
+ # Carrega plugin do disco e registra
205
+ def load_from_disk(name)
206
+ path = File.join(@plugins_dir, name, 'plugin.rb')
207
+ return nil unless File.exist?(path)
208
+
209
+ metadata = load_metadata(path)
210
+ plugin = Plugin.new(
211
+ name: metadata[:name],
212
+ version: metadata[:version],
213
+ path: path,
214
+ author: metadata[:author],
215
+ description: metadata[:description],
216
+ dependencies: metadata[:dependencies] || []
217
+ )
218
+
219
+ register(plugin)
220
+ plugin
221
+ end
222
+
223
+ # Salva estado dos plugins
224
+ def save_state(path = nil)
225
+ path ||= File.join(Dir.home, '.gsd', 'plugins.json')
226
+ state = {
227
+ enabled: enabled.map(&:name),
228
+ all: all.map(&:to_h)
229
+ }
230
+ File.write(path, JSON.pretty_generate(state))
231
+ end
232
+
233
+ # Carrega estado dos plugins
234
+ def load_state(path = nil)
235
+ path ||= File.join(Dir.home, '.gsd', 'plugins.json')
236
+ return unless File.exist?(path)
237
+
238
+ state = JSON.parse(File.read(path), symbolize_names: true)
239
+ state[:enabled].each { |name| enable(name) rescue nil }
240
+ end
241
+
242
+ private
243
+
244
+ def ensure_plugins_dir
245
+ FileUtils.mkdir_p(@plugins_dir) unless Dir.exist?(@plugins_dir)
246
+ end
247
+
248
+ def load_metadata(path)
249
+ # Extrai metadata de um arquivo plugin.rb
250
+ content = File.read(path)
251
+ metadata = {}
252
+
253
+ # Procura por comentários de metadata
254
+ if content =~ /#\s*@name\s+(\S+)/
255
+ metadata[:name] = $1
256
+ else
257
+ metadata[:name] = File.basename(File.dirname(path))
258
+ end
259
+
260
+ metadata[:version] = $1 if content =~ /#\s*@version\s+(\S+)/
261
+ metadata[:author] = $1 if content =~ /#\s*@author\s+(.+)/
262
+ metadata[:description] = $1 if content =~ /#\s*@description\s+(.+)/
263
+
264
+ if content =~ /#\s*@dependencies\s+(.+)/
265
+ metadata[:dependencies] = $1.split(',').map(&:strip)
266
+ end
267
+
268
+ metadata[:version] ||= '0.1.0'
269
+ metadata
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gsd/tui/colors'
4
+
5
+ module Gsd
6
+ module TUI
7
+ # AgentPanel - UI component for managing agents in the TUI
8
+ #
9
+ # Displays:
10
+ # - List of active agents with status
11
+ # - Agent types and current tasks
12
+ # - Quick actions (spawn, terminate, restart)
13
+ # - Communication between agents
14
+ class AgentPanel
15
+ def initialize(swarm, width: 60, visible: false)
16
+ @swarm = swarm
17
+ @width = width
18
+ @visible = visible
19
+ @selected_index = 0
20
+ @scroll_offset = 0
21
+ end
22
+
23
+ def show
24
+ @visible = true
25
+ refresh
26
+ end
27
+
28
+ def hide
29
+ @visible = false
30
+ end
31
+
32
+ def visible?
33
+ @visible
34
+ end
35
+
36
+ def toggle
37
+ @visible = !@visible
38
+ refresh if @visible
39
+ end
40
+
41
+ def move_up
42
+ return unless @visible
43
+ @selected_index = [@selected_index - 1, 0].max
44
+ adjust_scroll
45
+ end
46
+
47
+ def move_down
48
+ return unless @visible
49
+ max_index = [@swarm.list.count - 1, 0].max
50
+ @selected_index = [@selected_index + 1, max_index].min
51
+ adjust_scroll
52
+ end
53
+
54
+ def select_current
55
+ return unless @visible
56
+ agents = @swarm.list
57
+ return if agents.empty?
58
+
59
+ agent = agents[@selected_index]
60
+ agent&.id
61
+ end
62
+
63
+ def render
64
+ return '' unless @visible
65
+
66
+ t = Colors.theme
67
+ cyan = t[:accent]
68
+ magenta = t[:accent2]
69
+ pink = t[:accent3]
70
+ white = t[:text]
71
+ dim = t[:dim]
72
+ bg = t[:bg]
73
+ reset = Colors::RESET
74
+
75
+ lines = []
76
+
77
+ # Header
78
+ lines << "#{cyan}╭#{('─' * (@width - 2))}╮#{reset}"
79
+ lines << "#{cyan}│#{reset} #{magenta}⚡ Agents#{reset}#{dim} (#{@swarm.stats[:total_agents]} active)#{reset} #{cyan}│#{reset}"
80
+ lines << "#{cyan}├#{('─' * (@width - 2))}┤#{reset}"
81
+
82
+ # Agent list
83
+ agents = @swarm.list
84
+ if agents.empty?
85
+ lines << "#{cyan}│#{reset} #{dim}No agents running#{reset} #{cyan}│#{reset}"
86
+ else
87
+ display_count = [agents.count, 5].min # Show max 5 agents
88
+ display_count.times do |i|
89
+ idx = @scroll_offset + i
90
+ break if idx >= agents.count
91
+
92
+ agent = agents[idx]
93
+ lines << render_agent_line(agent, idx == @selected_index, cyan, magenta, pink, white, dim, reset)
94
+ end
95
+ end
96
+
97
+ # Footer with actions
98
+ lines << "#{cyan}├#{('─' * (@width - 2))}┤#{reset}"
99
+ lines << "#{cyan}│#{reset} #{dim}↑↓ navigate | s:spawn | t:terminate | r:restart#{reset} #{cyan}│#{reset}"
100
+ lines << "#{cyan}╰#{('─' * (@width - 2))}╯#{reset}"
101
+
102
+ lines.join("\n")
103
+ end
104
+
105
+ def handle_key(char)
106
+ return unless @visible
107
+
108
+ case char
109
+ when 's', 'S'
110
+ :spawn_agent
111
+ when 't', 'T'
112
+ :terminate_agent
113
+ when 'r', 'R'
114
+ :restart_agent
115
+ when 'm', 'M'
116
+ :send_message
117
+ else
118
+ nil
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def render_agent_line(agent, selected, cyan, magenta, pink, white, dim, reset)
125
+ # Status indicator
126
+ status_color = case agent.state
127
+ when :idle then dim
128
+ when :working then magenta
129
+ when :crashed then pink
130
+ else dim
131
+ end
132
+
133
+ status_icon = case agent.state
134
+ when :idle then '○'
135
+ when :working then '●'
136
+ when :crashed then '✗'
137
+ else '○'
138
+ end
139
+
140
+ # Type badge
141
+ type_badge = case agent.type
142
+ when :code then "#{cyan}[CODE]#{reset}"
143
+ when :plan then "#{magenta}[PLAN]#{reset}"
144
+ when :debug then "#{pink}[DEBUG]#{reset}"
145
+ else "#{dim}[#{agent.type.to_s.upcase}]#{reset}"
146
+ end
147
+
148
+ # Selection indicator
149
+ prefix = selected ? "#{cyan}>#{reset}" : "#{dim} #{reset}"
150
+
151
+ # Line content
152
+ content = "#{prefix} #{status_color}#{status_icon}#{reset} #{type_badge} #{white}#{agent.id[0..12]}#{reset}"
153
+
154
+ # Task info if working
155
+ if agent.state == :working && agent.instance_variable_get(:@current_task)
156
+ task_preview = agent.instance_variable_get(:@current_task)[:type] || 'task'
157
+ content += " #{dim}(#{task_preview})#{reset}"
158
+ end
159
+
160
+ # Pad to width
161
+ clean_len = content.gsub(/\e\[\d+m/, '').length
162
+ padding = ' ' * [@width - clean_len - 2, 0].max
163
+
164
+ "#{cyan}│#{reset}#{content}#{padding}#{cyan}│#{reset}"
165
+ end
166
+
167
+ def adjust_scroll
168
+ visible_count = 5
169
+
170
+ if @selected_index < @scroll_offset
171
+ @scroll_offset = @selected_index
172
+ elsif @selected_index >= @scroll_offset + visible_count
173
+ @scroll_offset = @selected_index - visible_count + 1
174
+ end
175
+ end
176
+
177
+ def refresh
178
+ # Trigger refresh of parent TUI
179
+ end
180
+ end
181
+ end
182
+ end