fantasy-cli 1.2.6
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/bin/gsd +8 -0
- data/bin/gsd-core-darwin-amd64 +0 -0
- data/bin/gsd-core-darwin-arm64 +0 -0
- data/bin/gsd-core-linux-amd64 +0 -0
- data/bin/gsd-core-linux-arm64 +0 -0
- data/bin/gsd-core-windows-amd64.exe +0 -0
- data/bin/gsd-core-windows-arm64.exe +0 -0
- data/bin/gsd-core.exe +0 -0
- data/lib/gsd/agents/coordinator.rb +195 -0
- data/lib/gsd/agents/task_manager.rb +158 -0
- data/lib/gsd/agents/worker.rb +162 -0
- data/lib/gsd/agents.rb +30 -0
- data/lib/gsd/ai/chat.rb +486 -0
- data/lib/gsd/ai/cli.rb +248 -0
- data/lib/gsd/ai/command_parser.rb +97 -0
- data/lib/gsd/ai/commands/base.rb +42 -0
- data/lib/gsd/ai/commands/clear.rb +20 -0
- data/lib/gsd/ai/commands/context.rb +30 -0
- data/lib/gsd/ai/commands/cost.rb +30 -0
- data/lib/gsd/ai/commands/export.rb +42 -0
- data/lib/gsd/ai/commands/help.rb +61 -0
- data/lib/gsd/ai/commands/model.rb +67 -0
- data/lib/gsd/ai/commands/reset.rb +22 -0
- data/lib/gsd/ai/config.rb +256 -0
- data/lib/gsd/ai/context.rb +324 -0
- data/lib/gsd/ai/cost_tracker.rb +361 -0
- data/lib/gsd/ai/git_context.rb +169 -0
- data/lib/gsd/ai/history.rb +384 -0
- data/lib/gsd/ai/providers/anthropic.rb +429 -0
- data/lib/gsd/ai/providers/base.rb +282 -0
- data/lib/gsd/ai/providers/lmstudio.rb +279 -0
- data/lib/gsd/ai/providers/ollama.rb +336 -0
- data/lib/gsd/ai/providers/openai.rb +396 -0
- data/lib/gsd/ai/providers/openrouter.rb +429 -0
- data/lib/gsd/ai/reference_resolver.rb +225 -0
- data/lib/gsd/ai/repl.rb +349 -0
- data/lib/gsd/ai/streaming.rb +438 -0
- data/lib/gsd/ai/ui.rb +429 -0
- data/lib/gsd/buddy/cli.rb +284 -0
- data/lib/gsd/buddy/gacha.rb +148 -0
- data/lib/gsd/buddy/renderer.rb +108 -0
- data/lib/gsd/buddy/species.rb +190 -0
- data/lib/gsd/buddy/stats.rb +156 -0
- data/lib/gsd/buddy.rb +28 -0
- data/lib/gsd/cli.rb +455 -0
- data/lib/gsd/commands.rb +198 -0
- data/lib/gsd/config.rb +183 -0
- data/lib/gsd/error.rb +188 -0
- data/lib/gsd/frontmatter.rb +123 -0
- data/lib/gsd/go/bridge.rb +173 -0
- data/lib/gsd/history.rb +76 -0
- data/lib/gsd/milestone.rb +75 -0
- data/lib/gsd/output.rb +184 -0
- data/lib/gsd/phase.rb +102 -0
- data/lib/gsd/plugins/base.rb +92 -0
- data/lib/gsd/plugins/cli.rb +330 -0
- data/lib/gsd/plugins/config.rb +164 -0
- data/lib/gsd/plugins/hooks.rb +132 -0
- data/lib/gsd/plugins/installer.rb +158 -0
- data/lib/gsd/plugins/loader.rb +122 -0
- data/lib/gsd/plugins/manager.rb +187 -0
- data/lib/gsd/plugins/marketplace.rb +142 -0
- data/lib/gsd/plugins/sandbox.rb +114 -0
- data/lib/gsd/plugins/search.rb +131 -0
- data/lib/gsd/plugins/validator.rb +157 -0
- data/lib/gsd/plugins.rb +48 -0
- data/lib/gsd/profile.rb +127 -0
- data/lib/gsd/research.rb +85 -0
- data/lib/gsd/roadmap.rb +90 -0
- data/lib/gsd/skills/bundled/commit.md +58 -0
- data/lib/gsd/skills/bundled/debug.md +28 -0
- data/lib/gsd/skills/bundled/explain.md +41 -0
- data/lib/gsd/skills/bundled/plan.md +42 -0
- data/lib/gsd/skills/bundled/verify.md +26 -0
- data/lib/gsd/skills/loader.rb +189 -0
- data/lib/gsd/state.rb +102 -0
- data/lib/gsd/template.rb +106 -0
- data/lib/gsd/tools/ask_user_question.rb +179 -0
- data/lib/gsd/tools/base.rb +204 -0
- data/lib/gsd/tools/bash.rb +246 -0
- data/lib/gsd/tools/file_edit.rb +297 -0
- data/lib/gsd/tools/file_read.rb +199 -0
- data/lib/gsd/tools/file_write.rb +153 -0
- data/lib/gsd/tools/glob.rb +202 -0
- data/lib/gsd/tools/grep.rb +227 -0
- data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
- data/lib/gsd/tools/gsd_phase.rb +140 -0
- data/lib/gsd/tools/gsd_roadmap.rb +108 -0
- data/lib/gsd/tools/gsd_state.rb +143 -0
- data/lib/gsd/tools/gsd_template.rb +157 -0
- data/lib/gsd/tools/gsd_verify.rb +159 -0
- data/lib/gsd/tools/registry.rb +103 -0
- data/lib/gsd/tools/task.rb +235 -0
- data/lib/gsd/tools/todo_write.rb +290 -0
- data/lib/gsd/tools/web.rb +260 -0
- data/lib/gsd/tui/app.rb +366 -0
- data/lib/gsd/tui/auto_complete.rb +79 -0
- data/lib/gsd/tui/colors.rb +111 -0
- data/lib/gsd/tui/command_palette.rb +126 -0
- data/lib/gsd/tui/header.rb +38 -0
- data/lib/gsd/tui/input_box.rb +199 -0
- data/lib/gsd/tui/spinner.rb +40 -0
- data/lib/gsd/tui/status_bar.rb +51 -0
- data/lib/gsd/tui.rb +17 -0
- data/lib/gsd/validator.rb +216 -0
- data/lib/gsd/verify.rb +175 -0
- data/lib/gsd/version.rb +5 -0
- data/lib/gsd/workstream.rb +91 -0
- metadata +231 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tools/base'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module Tools
|
|
7
|
+
# AskUserQuestionTool - Faz perguntas ao usuário e aguarda resposta
|
|
8
|
+
#
|
|
9
|
+
# Uso:
|
|
10
|
+
# tool = AskUserQuestionTool.new(cwd: '/path')
|
|
11
|
+
# result = tool.execute(question: 'Qual arquivo editar?', options: ['a.rb', 'b.rb'])
|
|
12
|
+
class AskUserQuestionTool < Base
|
|
13
|
+
class << self
|
|
14
|
+
tool_name('ask_user_question')
|
|
15
|
+
tool_description('Ask the user a question and wait for their response')
|
|
16
|
+
tool_input_schema({
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
question: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'The question to ask the user'
|
|
22
|
+
},
|
|
23
|
+
options: {
|
|
24
|
+
type: 'array',
|
|
25
|
+
description: 'Optional list of options for the user to choose from',
|
|
26
|
+
items: {
|
|
27
|
+
type: 'string'
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
allow_multiple: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Allow multiple selections (default: false)',
|
|
33
|
+
default: false
|
|
34
|
+
},
|
|
35
|
+
default: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Default value if user presses Enter'
|
|
38
|
+
},
|
|
39
|
+
timeout: {
|
|
40
|
+
type: 'integer',
|
|
41
|
+
description: 'Timeout in seconds (default: 300, 0 for no timeout)',
|
|
42
|
+
default: 300
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
required: ['question']
|
|
46
|
+
})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Faz pergunta ao usuário
|
|
50
|
+
#
|
|
51
|
+
# @param args [Hash] Argumentos
|
|
52
|
+
# @return [Hash] Resposta do usuário
|
|
53
|
+
def execute(args)
|
|
54
|
+
question = args[:question] || args['question']
|
|
55
|
+
options = args[:options] || args['options']
|
|
56
|
+
allow_multiple = args[:allow_multiple] || args['allow_multiple'] || false
|
|
57
|
+
default = args[:default] || args['default']
|
|
58
|
+
timeout = args[:timeout] || args['timeout'] || 300
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, 'Question is required' unless question
|
|
61
|
+
|
|
62
|
+
log_debug("Asking: #{question}")
|
|
63
|
+
log_debug("Options: #{options.inspect}") if options
|
|
64
|
+
|
|
65
|
+
# Exibe pergunta
|
|
66
|
+
puts "\n" + "=" * 60
|
|
67
|
+
puts "❓ #{question}"
|
|
68
|
+
puts "=" * 60
|
|
69
|
+
|
|
70
|
+
# Exibe opções se existirem
|
|
71
|
+
if options && options.any?
|
|
72
|
+
options.each_with_index do |opt, i|
|
|
73
|
+
puts " [#{i + 1}] #{opt}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if allow_multiple
|
|
77
|
+
puts "\nDigite os números separados por vírgula (ex: 1,3,5)"
|
|
78
|
+
else
|
|
79
|
+
puts "\nDigite o número da opção desejada"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
puts "Ou pressione Enter para usar o default#{default ? " (#{default})" : ''}"
|
|
83
|
+
else
|
|
84
|
+
puts "\nDigite sua resposta:"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Lê resposta com timeout
|
|
88
|
+
answer = read_input_with_timeout(timeout)
|
|
89
|
+
|
|
90
|
+
# Processa resposta
|
|
91
|
+
result = process_answer(answer, options, allow_multiple, default)
|
|
92
|
+
|
|
93
|
+
puts "\n✅ Resposta registrada: #{result[:answer]}"
|
|
94
|
+
puts "=" * 60 + "\n"
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Verifica se é safe
|
|
100
|
+
#
|
|
101
|
+
# @param args [Hash] Argumentos
|
|
102
|
+
# @return [Boolean] true
|
|
103
|
+
def self.safe?(args)
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Verifica se é read-only
|
|
108
|
+
#
|
|
109
|
+
# @param args [Hash] Argumentos
|
|
110
|
+
# @return [Boolean] true
|
|
111
|
+
def self.read_only?(args)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Lê input com timeout
|
|
118
|
+
#
|
|
119
|
+
# @param timeout [Integer] Timeout em segundos
|
|
120
|
+
# @return [String] Input do usuário
|
|
121
|
+
def read_input_with_timeout(timeout)
|
|
122
|
+
require 'io/select'
|
|
123
|
+
|
|
124
|
+
if timeout > 0
|
|
125
|
+
# Usa IO.select para timeout
|
|
126
|
+
if IO.select([$stdin], nil, nil, timeout)
|
|
127
|
+
$stdin.gets&.chomp
|
|
128
|
+
else
|
|
129
|
+
puts "\n⏰ Timeout atingido!"
|
|
130
|
+
''
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
# Sem timeout
|
|
134
|
+
$stdin.gets&.chomp
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Processa resposta do usuário
|
|
139
|
+
#
|
|
140
|
+
# @param answer [String] Resposta do usuário
|
|
141
|
+
# @param options [Array] Opções disponíveis
|
|
142
|
+
# @param allow_multiple [Boolean] Permite múltiplas
|
|
143
|
+
# @param default [String] Valor default
|
|
144
|
+
# @return [Hash] Resposta processada
|
|
145
|
+
def process_answer(answer, options, allow_multiple, default)
|
|
146
|
+
# Usa default se resposta vazia
|
|
147
|
+
if answer.nil? || answer.strip.empty?
|
|
148
|
+
return {
|
|
149
|
+
answer: default,
|
|
150
|
+
selected_options: default ? [default] : [],
|
|
151
|
+
indices: []
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Se há opções, converte índices
|
|
156
|
+
if options && options.any?
|
|
157
|
+
indices = answer.split(',').map(&:strip).map(&:to_i)
|
|
158
|
+
selected = indices.map { |i| options[i - 1] }.compact
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
answer: allow_multiple ? selected.join(', ') : selected.first,
|
|
162
|
+
selected_options: selected,
|
|
163
|
+
indices: indices
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Resposta livre
|
|
168
|
+
{
|
|
169
|
+
answer: answer,
|
|
170
|
+
selected_options: [answer],
|
|
171
|
+
indices: []
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Registra a tool
|
|
179
|
+
Gsd::Tools::Registry.register('ask_user_question', Gsd::Tools::AskUserQuestionTool, category: :user)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module Tools
|
|
7
|
+
# Base Tool Class - Classe base para todas as tools
|
|
8
|
+
#
|
|
9
|
+
# Todas as tools devem herdar desta classe e implementar:
|
|
10
|
+
# - name (nome da tool)
|
|
11
|
+
# - description (descrição)
|
|
12
|
+
# - input_schema (schema JSON dos inputs)
|
|
13
|
+
# - execute (execução da tool)
|
|
14
|
+
class Base
|
|
15
|
+
class << self
|
|
16
|
+
attr_accessor :name, :description, :input_schema
|
|
17
|
+
|
|
18
|
+
# Define o nome da tool
|
|
19
|
+
#
|
|
20
|
+
# @param name [String] Nome da tool
|
|
21
|
+
# @return [void]
|
|
22
|
+
def tool_name(name)
|
|
23
|
+
@name = name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Define a descrição da tool
|
|
27
|
+
#
|
|
28
|
+
# @param desc [String] Descrição
|
|
29
|
+
# @return [void]
|
|
30
|
+
def tool_description(desc)
|
|
31
|
+
@description = desc
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Define o schema de input da tool
|
|
35
|
+
#
|
|
36
|
+
# @param schema [Hash] Schema JSON
|
|
37
|
+
# @return [void]
|
|
38
|
+
def tool_input_schema(schema)
|
|
39
|
+
@input_schema = schema
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Verifica se a tool é segura para execução automática
|
|
43
|
+
#
|
|
44
|
+
# @param args [Hash] Argumentos
|
|
45
|
+
# @return [Boolean] true se segura
|
|
46
|
+
def safe?(args = {})
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Verifica se a tool é read-only
|
|
51
|
+
#
|
|
52
|
+
# @param args [Hash] Argumentos
|
|
53
|
+
# @return [Boolean] true se read-only
|
|
54
|
+
def read_only?(args = {})
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Verifica se a tool é destrutiva
|
|
59
|
+
#
|
|
60
|
+
# @param args [Hash] Argumentos
|
|
61
|
+
# @return [Boolean] true se destrutiva
|
|
62
|
+
def destructive?(args = {})
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Inicializa a tool
|
|
68
|
+
#
|
|
69
|
+
# @param cwd [String] Diretório de trabalho
|
|
70
|
+
# @param debug [Boolean] Habilitar debug
|
|
71
|
+
def initialize(cwd: Dir.pwd, debug: false)
|
|
72
|
+
@cwd = cwd
|
|
73
|
+
@debug = debug
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Executa a tool
|
|
77
|
+
#
|
|
78
|
+
# @param args [Hash] Argumentos da tool
|
|
79
|
+
# @return [Object] Resultado da execução
|
|
80
|
+
def execute(args)
|
|
81
|
+
raise NotImplementedError, 'Subclasses must implement #execute'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Valida os argumentos da tool
|
|
85
|
+
#
|
|
86
|
+
# @param args [Hash] Argumentos
|
|
87
|
+
# @return [Boolean] true se válido
|
|
88
|
+
def validate(args)
|
|
89
|
+
return true unless self.class.input_schema
|
|
90
|
+
|
|
91
|
+
# Validação básica
|
|
92
|
+
required = self.class.input_schema['required'] || []
|
|
93
|
+
properties = self.class.input_schema['properties'] || {}
|
|
94
|
+
|
|
95
|
+
required.each do |field|
|
|
96
|
+
return false unless args.key?(field.to_sym) || args.key?(field.to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Log de debug
|
|
103
|
+
#
|
|
104
|
+
# @param message [String] Mensagem de log
|
|
105
|
+
# @return [void]
|
|
106
|
+
def log_debug(message)
|
|
107
|
+
return unless @debug
|
|
108
|
+
|
|
109
|
+
puts "[#{self.class.name}] #{message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Executa comando shell com segurança
|
|
113
|
+
#
|
|
114
|
+
# @param command [String] Comando
|
|
115
|
+
# @param timeout [Integer] Timeout em segundos
|
|
116
|
+
# @return [Hash] Resultado (stdout, stderr, status)
|
|
117
|
+
def run_shell_command(command, timeout: 30)
|
|
118
|
+
require 'open3'
|
|
119
|
+
|
|
120
|
+
log_debug("Running: #{command}")
|
|
121
|
+
|
|
122
|
+
stdout, stderr, status = Open3.capture3(command, chdir: @cwd)
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
stdout: stdout,
|
|
126
|
+
stderr: stderr,
|
|
127
|
+
status: status.exitstatus,
|
|
128
|
+
success: status.success?
|
|
129
|
+
}
|
|
130
|
+
rescue => e
|
|
131
|
+
log_debug("Error: #{e.message}")
|
|
132
|
+
{
|
|
133
|
+
stdout: '',
|
|
134
|
+
stderr: e.message,
|
|
135
|
+
status: -1,
|
|
136
|
+
success: false
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Lê arquivo com limite de tamanho
|
|
141
|
+
#
|
|
142
|
+
# @param path [String] Caminho do arquivo
|
|
143
|
+
# @param max_size [Integer] Tamanho máximo em bytes
|
|
144
|
+
# @return [String] Conteúdo do arquivo
|
|
145
|
+
def read_file(path, max_size: 1_000_000)
|
|
146
|
+
full_path = File.expand_path(path, @cwd)
|
|
147
|
+
|
|
148
|
+
raise "File not found: #{path}" unless File.exist?(full_path)
|
|
149
|
+
|
|
150
|
+
size = File.size(full_path)
|
|
151
|
+
raise "File too large: #{size} bytes (max: #{max_size})" if size > max_size
|
|
152
|
+
|
|
153
|
+
File.read(full_path)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Escreve arquivo com backup
|
|
157
|
+
#
|
|
158
|
+
# @param path [String] Caminho do arquivo
|
|
159
|
+
# @param content [String] Conteúdo
|
|
160
|
+
# @param create_backup [Boolean] Criar backup
|
|
161
|
+
# @return [void]
|
|
162
|
+
def write_file(path, content, create_backup: true)
|
|
163
|
+
full_path = File.expand_path(path, @cwd)
|
|
164
|
+
|
|
165
|
+
# Cria diretórios se necessário
|
|
166
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
167
|
+
|
|
168
|
+
# Backup se arquivo existe
|
|
169
|
+
if File.exist?(full_path) && create_backup
|
|
170
|
+
backup_path = "#{full_path}.bak.#{Time.now.to_i}"
|
|
171
|
+
File.rename(full_path, backup_path)
|
|
172
|
+
log_debug("Backup created: #{backup_path}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
File.write(full_path, content)
|
|
176
|
+
log_debug("File written: #{full_path}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Verifica se path existe
|
|
180
|
+
#
|
|
181
|
+
# @param path [String] Caminho
|
|
182
|
+
# @return [Boolean] true se existe
|
|
183
|
+
def path_exists?(path)
|
|
184
|
+
full_path = File.expand_path(path, @cwd)
|
|
185
|
+
File.exist?(full_path)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Normaliza path (previne directory traversal)
|
|
189
|
+
#
|
|
190
|
+
# @param path [String] Caminho
|
|
191
|
+
# @return [String] Path normalizado
|
|
192
|
+
def normalize_path(path)
|
|
193
|
+
full_path = File.expand_path(path, @cwd)
|
|
194
|
+
|
|
195
|
+
# Verifica se está dentro do cwd
|
|
196
|
+
unless full_path.start_with?(File.expand_path(@cwd))
|
|
197
|
+
raise "Path outside working directory: #{path}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
full_path
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tools/base'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module Tools
|
|
8
|
+
# BashTool - Executa comandos shell
|
|
9
|
+
#
|
|
10
|
+
# Uso:
|
|
11
|
+
# tool = BashTool.new(cwd: '/path')
|
|
12
|
+
# result = tool.execute(command: 'ls -la')
|
|
13
|
+
class BashTool < Base
|
|
14
|
+
class << self
|
|
15
|
+
tool_name('bash')
|
|
16
|
+
tool_description('Execute shell commands with output streaming')
|
|
17
|
+
tool_input_schema({
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
command: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'The shell command to execute'
|
|
23
|
+
},
|
|
24
|
+
timeout: {
|
|
25
|
+
type: 'integer',
|
|
26
|
+
description: 'Timeout in seconds (default: 30)',
|
|
27
|
+
default: 30
|
|
28
|
+
},
|
|
29
|
+
stream: {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
description: 'Stream output in real-time (default: false)',
|
|
32
|
+
default: false
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: ['command']
|
|
36
|
+
})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Comandos bloqueados por segurança
|
|
40
|
+
BLOCKED_COMMANDS = [
|
|
41
|
+
'rm -rf /',
|
|
42
|
+
'mkfs',
|
|
43
|
+
'dd if=/dev/zero',
|
|
44
|
+
':(){:|:&};:',
|
|
45
|
+
'chmod -R 000',
|
|
46
|
+
'chown -R'
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Comandos permitidos sem confirmação
|
|
50
|
+
SAFE_COMMANDS = [
|
|
51
|
+
'ls', 'dir', 'pwd', 'echo', 'cat', 'head', 'tail',
|
|
52
|
+
'grep', 'find', 'which', 'whoami', 'date', 'git',
|
|
53
|
+
'npm', 'yarn', 'bundle', 'gem', 'go', 'cargo',
|
|
54
|
+
'ruby', 'python', 'node', 'make'
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Executa comando bash
|
|
58
|
+
#
|
|
59
|
+
# @param args [Hash] Argumentos
|
|
60
|
+
# @return [Hash] Resultado da execução
|
|
61
|
+
def execute(args)
|
|
62
|
+
command = args[:command] || args['command']
|
|
63
|
+
timeout = args[:timeout] || args['timeout'] || 30
|
|
64
|
+
stream = args[:stream] || args['stream'] || false
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, 'Command is required' unless command
|
|
67
|
+
|
|
68
|
+
# Validações de segurança
|
|
69
|
+
validate_command(command)
|
|
70
|
+
|
|
71
|
+
log_debug("Executing: #{command}")
|
|
72
|
+
log_debug("Timeout: #{timeout}s, Stream: #{stream}")
|
|
73
|
+
|
|
74
|
+
if stream
|
|
75
|
+
execute_with_stream(command, timeout)
|
|
76
|
+
else
|
|
77
|
+
execute_sync(command, timeout)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Verifica se comando é seguro
|
|
82
|
+
#
|
|
83
|
+
# @param args [Hash] Argumentos
|
|
84
|
+
# @return [Boolean] true se seguro
|
|
85
|
+
def self.safe?(args)
|
|
86
|
+
command = args[:command] || args['command'] || ''
|
|
87
|
+
SAFE_COMMANDS.any? { |safe| command.start_with?(safe) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Verifica se é read-only
|
|
91
|
+
#
|
|
92
|
+
# @param args [Hash] Argumentos
|
|
93
|
+
# @return [Boolean] true se read-only
|
|
94
|
+
def self.read_only?(args)
|
|
95
|
+
command = args[:command] || args['command'] || ''
|
|
96
|
+
# Comandos que não modificam arquivos
|
|
97
|
+
['ls', 'dir', 'pwd', 'cat', 'head', 'tail', 'grep', 'find', 'which', 'whoami', 'date'].any? { |c| command.start_with?(c) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Verifica se é destrutivo
|
|
101
|
+
#
|
|
102
|
+
# @param args [Hash] Argumentos
|
|
103
|
+
# @return [Boolean] true se destrutivo
|
|
104
|
+
def self.destructive?(args)
|
|
105
|
+
command = args[:command] || args['command'] || ''
|
|
106
|
+
# Comandos que podem modificar/deletar
|
|
107
|
+
['rm', 'mv', 'cp', 'chmod', 'chown', 'mkdir', 'touch'].any? { |c| command.start_with?(c) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Valida comando por segurança
|
|
113
|
+
#
|
|
114
|
+
# @param command [String] Comando
|
|
115
|
+
# @raise [SecurityError] Se comando for perigoso
|
|
116
|
+
def validate_command(command)
|
|
117
|
+
# Verifica comandos bloqueados
|
|
118
|
+
BLOCKED_COMMANDS.each do |blocked|
|
|
119
|
+
if command.include?(blocked)
|
|
120
|
+
raise SecurityError, "Command blocked for security: #{blocked}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Verifica se tenta acessar paths fora do cwd
|
|
125
|
+
if command.include?('..') && !command.start_with?('git')
|
|
126
|
+
log_debug("Warning: Command contains '..'")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Verifica se é sudo
|
|
130
|
+
if command.start_with?('sudo')
|
|
131
|
+
raise SecurityError, 'Sudo commands are not allowed'
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Executa comando com sync
|
|
136
|
+
#
|
|
137
|
+
# @param command [String] Comando
|
|
138
|
+
# @param timeout [Integer] Timeout
|
|
139
|
+
# @return [Hash] Resultado
|
|
140
|
+
def execute_sync(command, timeout)
|
|
141
|
+
start_time = Time.now
|
|
142
|
+
|
|
143
|
+
stdout, stderr, status = Open3.capture3(command, chdir: @cwd)
|
|
144
|
+
|
|
145
|
+
elapsed = Time.now - start_time
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
stdout: stdout,
|
|
149
|
+
stderr: stderr,
|
|
150
|
+
status: status.exitstatus,
|
|
151
|
+
success: status.success?,
|
|
152
|
+
elapsed: elapsed.round(2),
|
|
153
|
+
command: command
|
|
154
|
+
}
|
|
155
|
+
rescue => e
|
|
156
|
+
log_debug("Error: #{e.message}")
|
|
157
|
+
{
|
|
158
|
+
stdout: '',
|
|
159
|
+
stderr: e.message,
|
|
160
|
+
status: -1,
|
|
161
|
+
success: false,
|
|
162
|
+
elapsed: 0,
|
|
163
|
+
command: command,
|
|
164
|
+
error: e.class.name
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Executa comando com streaming
|
|
169
|
+
#
|
|
170
|
+
# @param command [String] Comando
|
|
171
|
+
# @param timeout [Integer] Timeout
|
|
172
|
+
# @return [Hash] Resultado
|
|
173
|
+
def execute_with_stream(command, timeout)
|
|
174
|
+
start_time = Time.now
|
|
175
|
+
stdout_data = +''
|
|
176
|
+
stderr_data = +''
|
|
177
|
+
|
|
178
|
+
Open3.popen3(command, chdir: @cwd) do |stdin, stdout, stderr, wait_thr|
|
|
179
|
+
# Threads para ler stdout e stderr
|
|
180
|
+
out_thread = Thread.new do
|
|
181
|
+
while line = stdout.gets
|
|
182
|
+
stdout_data << line
|
|
183
|
+
print line # Streaming em tempo real
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
err_thread = Thread.new do
|
|
188
|
+
while line = stderr.gets
|
|
189
|
+
stderr_data << line
|
|
190
|
+
$stderr.print line # Streaming em tempo real
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Aguarda threads
|
|
195
|
+
out_thread.join(timeout)
|
|
196
|
+
err_thread.join(timeout)
|
|
197
|
+
|
|
198
|
+
# Aguarda processo
|
|
199
|
+
status = wait_thr.value(timeout)
|
|
200
|
+
elapsed = Time.now - start_time
|
|
201
|
+
|
|
202
|
+
puts # Nova linha após streaming
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
stdout: stdout_data,
|
|
206
|
+
stderr: stderr_data,
|
|
207
|
+
status: status.exitstatus,
|
|
208
|
+
success: status.success?,
|
|
209
|
+
elapsed: elapsed.round(2),
|
|
210
|
+
command: command
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
rescue Timeout::Error => e
|
|
214
|
+
log_debug("Timeout after #{timeout}s")
|
|
215
|
+
{
|
|
216
|
+
stdout: stdout_data,
|
|
217
|
+
stderr: stderr_data,
|
|
218
|
+
status: -1,
|
|
219
|
+
success: false,
|
|
220
|
+
elapsed: timeout,
|
|
221
|
+
command: command,
|
|
222
|
+
error: 'timeout',
|
|
223
|
+
message: "Command timed out after #{timeout}s"
|
|
224
|
+
}
|
|
225
|
+
rescue => e
|
|
226
|
+
log_debug("Error: #{e.message}")
|
|
227
|
+
{
|
|
228
|
+
stdout: stdout_data,
|
|
229
|
+
stderr: stderr_data,
|
|
230
|
+
status: -1,
|
|
231
|
+
success: false,
|
|
232
|
+
elapsed: 0,
|
|
233
|
+
command: command,
|
|
234
|
+
error: e.class.name,
|
|
235
|
+
message: e.message
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Erro de segurança
|
|
241
|
+
class SecurityError < StandardError; end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Registra a tool
|
|
246
|
+
Gsd::Tools::Registry.register('bash', Gsd::Tools::BashTool, category: :shell)
|