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,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tools/base'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module Tools
|
|
8
|
+
# FileEditTool - Edita arquivos com diff e backup
|
|
9
|
+
#
|
|
10
|
+
# Suporta:
|
|
11
|
+
# - Substituição de texto
|
|
12
|
+
# - Inserção em linha específica
|
|
13
|
+
# - Deleção de linhas
|
|
14
|
+
# - Geração de diff
|
|
15
|
+
#
|
|
16
|
+
# Uso:
|
|
17
|
+
# tool = FileEditTool.new(cwd: '/path')
|
|
18
|
+
# result = tool.execute(path: 'file.txt', operation: 'replace', ...)
|
|
19
|
+
class FileEditTool < Base
|
|
20
|
+
class << self
|
|
21
|
+
tool_name('file_edit')
|
|
22
|
+
tool_description('Edit files with diff generation and automatic backup')
|
|
23
|
+
tool_input_schema({
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
path: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Path to the file to edit (relative to cwd)'
|
|
29
|
+
},
|
|
30
|
+
operation: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Operation type: replace, insert, delete, append',
|
|
33
|
+
enum: ['replace', 'insert', 'delete', 'append']
|
|
34
|
+
},
|
|
35
|
+
search: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Text to search for (for replace/delete operations)'
|
|
38
|
+
},
|
|
39
|
+
replace: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'Replacement text (for replace operation)'
|
|
42
|
+
},
|
|
43
|
+
line_number: {
|
|
44
|
+
type: 'integer',
|
|
45
|
+
description: 'Line number for insert/delete operations (1-based)'
|
|
46
|
+
},
|
|
47
|
+
content: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'Content to insert/append'
|
|
50
|
+
},
|
|
51
|
+
create_backup: {
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
description: 'Create backup before editing (default: true)',
|
|
54
|
+
default: true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
required: ['path', 'operation']
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Executa edição no arquivo
|
|
62
|
+
#
|
|
63
|
+
# @param args [Hash] Argumentos
|
|
64
|
+
# @return [Hash] Resultado com diff e metadados
|
|
65
|
+
def execute(args)
|
|
66
|
+
path = args[:path] || args['path']
|
|
67
|
+
operation = args[:operation] || args['operation']
|
|
68
|
+
create_backup = args[:create_backup] || args['create_backup'] || true
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, 'Path is required' unless path
|
|
71
|
+
raise ArgumentError, 'Operation is required' unless operation
|
|
72
|
+
|
|
73
|
+
log_debug("Editing: #{path}, Operation: #{operation}")
|
|
74
|
+
|
|
75
|
+
# Normaliza path
|
|
76
|
+
full_path = normalize_path(path)
|
|
77
|
+
|
|
78
|
+
# Verifica se arquivo existe
|
|
79
|
+
unless File.exist?(full_path)
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: 'file_not_found',
|
|
83
|
+
message: "File not found: #{path}"
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Lê conteúdo original
|
|
88
|
+
original_content = File.read(full_path)
|
|
89
|
+
original_lines = original_content.lines
|
|
90
|
+
|
|
91
|
+
# Executa operação
|
|
92
|
+
new_content = case operation
|
|
93
|
+
when 'replace'
|
|
94
|
+
do_replace(original_content, args)
|
|
95
|
+
when 'insert'
|
|
96
|
+
do_insert(original_lines, args)
|
|
97
|
+
when 'delete'
|
|
98
|
+
do_delete(original_lines, args)
|
|
99
|
+
when 'append'
|
|
100
|
+
do_append(original_content, args)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "Unknown operation: #{operation}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Gera diff
|
|
106
|
+
diff = generate_diff(original_content, new_content, path)
|
|
107
|
+
|
|
108
|
+
# Backup se necessário
|
|
109
|
+
backup_path = nil
|
|
110
|
+
if create_backup
|
|
111
|
+
backup_path = create_backup_file(full_path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Escreve novo conteúdo
|
|
115
|
+
File.write(full_path, new_content)
|
|
116
|
+
|
|
117
|
+
log_debug("File edited: #{full_path}")
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
success: true,
|
|
121
|
+
path: path,
|
|
122
|
+
full_path: full_path,
|
|
123
|
+
operation: operation,
|
|
124
|
+
backup: backup_path,
|
|
125
|
+
diff: diff,
|
|
126
|
+
original_lines: original_lines.count,
|
|
127
|
+
new_lines: new_content.lines.count,
|
|
128
|
+
changes: count_changes(original_content, new_content)
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Verifica se é safe
|
|
133
|
+
#
|
|
134
|
+
# @param args [Hash] Argumentos
|
|
135
|
+
# @return [Boolean] false
|
|
136
|
+
def self.safe?(args)
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Verifica se é read-only
|
|
141
|
+
#
|
|
142
|
+
# @param args [Hash] Argumentos
|
|
143
|
+
# @return [Boolean] false
|
|
144
|
+
def self.read_only?(args)
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Verifica se é destrutivo
|
|
149
|
+
#
|
|
150
|
+
# @param args [Hash] Argumentos
|
|
151
|
+
# @return [Boolean] true
|
|
152
|
+
def self.destructive?(args)
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Operação de replace
|
|
159
|
+
#
|
|
160
|
+
# @param content [String] Conteúdo original
|
|
161
|
+
# @param args [Hash] Argumentos
|
|
162
|
+
# @return [String] Novo conteúdo
|
|
163
|
+
def do_replace(content, args)
|
|
164
|
+
search = args[:search] || args['search']
|
|
165
|
+
replace = args[:replace] || args['replace'] || ''
|
|
166
|
+
|
|
167
|
+
raise ArgumentError, 'Search text is required for replace operation' unless search
|
|
168
|
+
|
|
169
|
+
content.gsub(search, replace)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Operação de insert
|
|
173
|
+
#
|
|
174
|
+
# @param lines [Array] Linhas originais
|
|
175
|
+
# @param args [Hash] Argumentos
|
|
176
|
+
# @return [String] Novo conteúdo
|
|
177
|
+
def do_insert(lines, args)
|
|
178
|
+
line_number = args[:line_number] || args['line_number']
|
|
179
|
+
content = args[:content] || args['content']
|
|
180
|
+
|
|
181
|
+
raise ArgumentError, 'Line number is required for insert operation' unless line_number
|
|
182
|
+
raise ArgumentError, 'Content is required for insert operation' unless content
|
|
183
|
+
|
|
184
|
+
# Converte para 0-based
|
|
185
|
+
idx = line_number - 1
|
|
186
|
+
idx = [0, idx].max
|
|
187
|
+
idx = [lines.count, idx].min
|
|
188
|
+
|
|
189
|
+
lines.insert(idx, content + "\n")
|
|
190
|
+
lines.join
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Operação de delete
|
|
194
|
+
#
|
|
195
|
+
# @param lines [Array] Linhas originais
|
|
196
|
+
# @param args [Hash] Argumentos
|
|
197
|
+
# @return [String] Novo conteúdo
|
|
198
|
+
def do_delete(lines, args)
|
|
199
|
+
line_number = args[:line_number] || args['line_number']
|
|
200
|
+
search = args[:search] || args['search']
|
|
201
|
+
|
|
202
|
+
if line_number
|
|
203
|
+
# Deleta linha específica
|
|
204
|
+
idx = line_number - 1
|
|
205
|
+
lines.delete_at(idx)
|
|
206
|
+
elsif search
|
|
207
|
+
# Deleta linhas que contêm search
|
|
208
|
+
lines.reject! { |line| line.include?(search) }
|
|
209
|
+
else
|
|
210
|
+
raise ArgumentError, 'Line number or search text is required for delete operation'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
lines.join
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Operação de append
|
|
217
|
+
#
|
|
218
|
+
# @param content [String] Conteúdo original
|
|
219
|
+
# @param args [Hash] Argumentos
|
|
220
|
+
# @return [String] Novo conteúdo
|
|
221
|
+
def do_append(content, args)
|
|
222
|
+
append_content = args[:content] || args['content'] || ''
|
|
223
|
+
|
|
224
|
+
content + "\n" + append_content + "\n"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Gera diff unificado
|
|
228
|
+
#
|
|
229
|
+
# @param original [String] Conteúdo original
|
|
230
|
+
# @param modified [String] Conteúdo modificado
|
|
231
|
+
# @param path [String] Caminho do arquivo
|
|
232
|
+
# @return [String] Diff formatado
|
|
233
|
+
def generate_diff(original, modified, path)
|
|
234
|
+
# Cria arquivos temporários
|
|
235
|
+
original_file = Tempfile.new('original')
|
|
236
|
+
modified_file = Tempfile.new('modified')
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
original_file.write(original)
|
|
240
|
+
original_file.close
|
|
241
|
+
|
|
242
|
+
modified_file.write(modified)
|
|
243
|
+
modified_file.close
|
|
244
|
+
|
|
245
|
+
# Gera diff
|
|
246
|
+
diff_result = `diff -u #{original_file.path} #{modified_file.path} 2>&1`
|
|
247
|
+
|
|
248
|
+
# Formata diff
|
|
249
|
+
diff_result.gsub(original_file.path, 'a/' + path)
|
|
250
|
+
.gsub(modified_file.path, 'b/' + path)
|
|
251
|
+
ensure
|
|
252
|
+
original_file.unlink
|
|
253
|
+
modified_file.unlink
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Conta mudanças
|
|
258
|
+
#
|
|
259
|
+
# @param original [String] Conteúdo original
|
|
260
|
+
# @param modified [String] Conteúdo modificado
|
|
261
|
+
# @return [Hash] Contagem de mudanças
|
|
262
|
+
def count_changes(original, modified)
|
|
263
|
+
original_lines = original.lines.to_a
|
|
264
|
+
modified_lines = modified.lines.to_a
|
|
265
|
+
|
|
266
|
+
added = (modified_lines - original_lines).count
|
|
267
|
+
removed = (original_lines - modified_lines).count
|
|
268
|
+
|
|
269
|
+
{
|
|
270
|
+
added: added,
|
|
271
|
+
removed: removed,
|
|
272
|
+
total: added + removed
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Cria arquivo de backup
|
|
277
|
+
#
|
|
278
|
+
# @param path [String] Caminho do arquivo
|
|
279
|
+
# @return [String] Caminho do backup
|
|
280
|
+
def create_backup_file(path)
|
|
281
|
+
timestamp = Time.now.to_i
|
|
282
|
+
backup_path = "#{path}.bak.#{timestamp}"
|
|
283
|
+
|
|
284
|
+
# Se backup já existe, usa timestamp com nanos
|
|
285
|
+
if File.exist?(backup_path)
|
|
286
|
+
backup_path = "#{path}.bak.#{Time.now.to_f}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
File.rename(path, backup_path)
|
|
290
|
+
backup_path
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Registra a tool
|
|
297
|
+
Gsd::Tools::Registry.register('file_edit', Gsd::Tools::FileEditTool, category: :files)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tools/base'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module Tools
|
|
7
|
+
# FileReadTool - Lê arquivos com limites de segurança
|
|
8
|
+
#
|
|
9
|
+
# Uso:
|
|
10
|
+
# tool = FileReadTool.new(cwd: '/path')
|
|
11
|
+
# result = tool.execute(path: 'file.txt')
|
|
12
|
+
class FileReadTool < Base
|
|
13
|
+
class << self
|
|
14
|
+
tool_name('file_read')
|
|
15
|
+
tool_description('Read file contents with size limits and encoding detection')
|
|
16
|
+
tool_input_schema({
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Path to the file to read (relative to cwd)'
|
|
22
|
+
},
|
|
23
|
+
max_size: {
|
|
24
|
+
type: 'integer',
|
|
25
|
+
description: 'Maximum file size in bytes (default: 1MB)',
|
|
26
|
+
default: 1_000_000
|
|
27
|
+
},
|
|
28
|
+
encoding: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'File encoding (default: utf-8)',
|
|
31
|
+
default: 'utf-8'
|
|
32
|
+
},
|
|
33
|
+
line_start: {
|
|
34
|
+
type: 'integer',
|
|
35
|
+
description: 'Start reading from this line (0-based, default: 0)'
|
|
36
|
+
},
|
|
37
|
+
line_count: {
|
|
38
|
+
type: 'integer',
|
|
39
|
+
description: 'Number of lines to read (default: all)'
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: ['path']
|
|
43
|
+
})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Lê arquivo
|
|
47
|
+
#
|
|
48
|
+
# @param args [Hash] Argumentos
|
|
49
|
+
# @return [Hash] Conteúdo e metadados
|
|
50
|
+
def execute(args)
|
|
51
|
+
path = args[:path] || args['path']
|
|
52
|
+
max_size = args[:max_size] || args['max_size'] || 1_000_000
|
|
53
|
+
encoding = args[:encoding] || args['encoding'] || 'utf-8'
|
|
54
|
+
line_start = args[:line_start] || args['line_start']
|
|
55
|
+
line_count = args[:line_count] || args['line_count']
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, 'Path is required' unless path
|
|
58
|
+
|
|
59
|
+
log_debug("Reading: #{path}")
|
|
60
|
+
log_debug("Max size: #{max_size}, Encoding: #{encoding}")
|
|
61
|
+
|
|
62
|
+
# Normaliza path (segurança)
|
|
63
|
+
full_path = normalize_path(path)
|
|
64
|
+
|
|
65
|
+
# Verifica se arquivo existe
|
|
66
|
+
unless File.exist?(full_path)
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: 'file_not_found',
|
|
70
|
+
message: "File not found: #{path}",
|
|
71
|
+
path: path
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Verifica se é arquivo (não diretório)
|
|
76
|
+
unless File.file?(full_path)
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
error: 'not_a_file',
|
|
80
|
+
message: "Not a file: #{path}",
|
|
81
|
+
path: path
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Lê metadados
|
|
86
|
+
file_size = File.size(full_path)
|
|
87
|
+
file_mtime = File.mtime(full_path)
|
|
88
|
+
|
|
89
|
+
log_debug("File size: #{file_size} bytes")
|
|
90
|
+
|
|
91
|
+
# Verifica tamanho
|
|
92
|
+
if file_size > max_size
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: 'file_too_large',
|
|
96
|
+
message: "File too large: #{file_size} bytes (max: #{max_size})",
|
|
97
|
+
path: path,
|
|
98
|
+
size: file_size,
|
|
99
|
+
max_size: max_size
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Detecta encoding se não especificado
|
|
104
|
+
if encoding == 'auto' || encoding.nil?
|
|
105
|
+
encoding = detect_encoding(full_path)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Lê conteúdo
|
|
109
|
+
content = if line_start || line_count
|
|
110
|
+
read_lines(full_path, encoding, line_start, line_count)
|
|
111
|
+
else
|
|
112
|
+
read_file(full_path, encoding)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
success: true,
|
|
117
|
+
content: content,
|
|
118
|
+
path: path,
|
|
119
|
+
full_path: full_path,
|
|
120
|
+
size: file_size,
|
|
121
|
+
encoding: encoding,
|
|
122
|
+
lines: content.lines.count,
|
|
123
|
+
mtime: file_mtime.iso8601
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Verifica se é safe (sempre safe para leitura)
|
|
128
|
+
#
|
|
129
|
+
# @param args [Hash] Argumentos
|
|
130
|
+
# @return [Boolean] true
|
|
131
|
+
def self.safe?(args)
|
|
132
|
+
true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Verifica se é read-only
|
|
136
|
+
#
|
|
137
|
+
# @param args [Hash] Argumentos
|
|
138
|
+
# @return [Boolean] true
|
|
139
|
+
def self.read_only?(args)
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Detecta encoding do arquivo
|
|
146
|
+
#
|
|
147
|
+
# @param path [String] Caminho do arquivo
|
|
148
|
+
# @return [String] Encoding detectado
|
|
149
|
+
def detect_encoding(path)
|
|
150
|
+
# Tenta UTF-8 primeiro
|
|
151
|
+
content = File.read(path)
|
|
152
|
+
content.valid_encoding? ? 'utf-8' : 'binary'
|
|
153
|
+
rescue
|
|
154
|
+
'utf-8'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Lê arquivo completo
|
|
158
|
+
#
|
|
159
|
+
# @param path [String] Caminho
|
|
160
|
+
# @param encoding [String] Encoding
|
|
161
|
+
# @return [String] Conteúdo
|
|
162
|
+
def read_file(path, encoding)
|
|
163
|
+
File.read(path, encoding: encoding)
|
|
164
|
+
rescue Encoding::UndefinedConversionError => e
|
|
165
|
+
log_debug("Encoding error: #{e.message}")
|
|
166
|
+
# Fallback para binary
|
|
167
|
+
File.read(path, encoding: 'binary')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Lê linhas específicas
|
|
171
|
+
#
|
|
172
|
+
# @param path [String] Caminho
|
|
173
|
+
# @param encoding [String] Encoding
|
|
174
|
+
# @param line_start [Integer] Linha inicial
|
|
175
|
+
# @param line_count [Integer] Número de linhas
|
|
176
|
+
# @return [String] Conteúdo
|
|
177
|
+
def read_lines(path, encoding, line_start, line_count)
|
|
178
|
+
lines = File.readlines(path, encoding: encoding)
|
|
179
|
+
|
|
180
|
+
start_idx = line_start || 0
|
|
181
|
+
count = line_count || lines.length
|
|
182
|
+
|
|
183
|
+
selected = lines[start_idx, count] || []
|
|
184
|
+
selected.join
|
|
185
|
+
rescue Encoding::UndefinedConversionError => e
|
|
186
|
+
log_debug("Encoding error: #{e.message}")
|
|
187
|
+
# Fallback para binary
|
|
188
|
+
lines = File.readlines(path, encoding: 'binary')
|
|
189
|
+
start_idx = line_start || 0
|
|
190
|
+
count = line_count || lines.length
|
|
191
|
+
selected = lines[start_idx, count] || []
|
|
192
|
+
selected.join
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Registra a tool
|
|
199
|
+
Gsd::Tools::Registry.register('file_read', Gsd::Tools::FileReadTool, category: :files)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tools/base'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module Tools
|
|
8
|
+
# FileWriteTool - Escreve arquivos com backup automático
|
|
9
|
+
#
|
|
10
|
+
# Uso:
|
|
11
|
+
# tool = FileWriteTool.new(cwd: '/path')
|
|
12
|
+
# result = tool.execute(path: 'file.txt', content: 'content')
|
|
13
|
+
class FileWriteTool < Base
|
|
14
|
+
class << self
|
|
15
|
+
tool_name('file_write')
|
|
16
|
+
tool_description('Write content to files with automatic backup')
|
|
17
|
+
tool_input_schema({
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
path: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Path to the file to write (relative to cwd)'
|
|
23
|
+
},
|
|
24
|
+
content: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Content to write to the file'
|
|
27
|
+
},
|
|
28
|
+
append: {
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
description: 'Append to file instead of overwrite (default: false)',
|
|
31
|
+
default: false
|
|
32
|
+
},
|
|
33
|
+
create_backup: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
description: 'Create backup of existing file (default: true)',
|
|
36
|
+
default: true
|
|
37
|
+
},
|
|
38
|
+
encoding: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'File encoding (default: utf-8)',
|
|
41
|
+
default: 'utf-8'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
required: ['path', 'content']
|
|
45
|
+
})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Escreve arquivo
|
|
49
|
+
#
|
|
50
|
+
# @param args [Hash] Argumentos
|
|
51
|
+
# @return [Hash] Resultado da escrita
|
|
52
|
+
def execute(args)
|
|
53
|
+
path = args[:path] || args['path']
|
|
54
|
+
content = args[:content] || args['content']
|
|
55
|
+
append = args[:append] || args['append'] || false
|
|
56
|
+
create_backup = args[:create_backup] || args['create_backup'] || true
|
|
57
|
+
encoding = args[:encoding] || args['encoding'] || 'utf-8'
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, 'Path is required' unless path
|
|
60
|
+
raise ArgumentError, 'Content is required' unless content
|
|
61
|
+
|
|
62
|
+
log_debug("Writing: #{path}")
|
|
63
|
+
log_debug("Append: #{append}, Backup: #{create_backup}, Encoding: #{encoding}")
|
|
64
|
+
|
|
65
|
+
# Normaliza path (segurança)
|
|
66
|
+
full_path = normalize_path(path)
|
|
67
|
+
|
|
68
|
+
# Cria diretórios se necessário
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
70
|
+
|
|
71
|
+
# Backup se arquivo existe e create_backup é true
|
|
72
|
+
backup_path = nil
|
|
73
|
+
if File.exist?(full_path) && create_backup
|
|
74
|
+
backup_path = create_backup_file(full_path)
|
|
75
|
+
log_debug("Backup created: #{backup_path}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Escreve conteúdo
|
|
79
|
+
if append
|
|
80
|
+
File.open(full_path, 'a', encoding: encoding) do |f|
|
|
81
|
+
f.write(content)
|
|
82
|
+
end
|
|
83
|
+
log_debug("Content appended to: #{full_path}")
|
|
84
|
+
else
|
|
85
|
+
File.write(full_path, content, encoding: encoding)
|
|
86
|
+
log_debug("File written: #{full_path}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Retorna metadados
|
|
90
|
+
file_size = File.size(full_path)
|
|
91
|
+
file_mtime = File.mtime(full_path)
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
success: true,
|
|
95
|
+
path: path,
|
|
96
|
+
full_path: full_path,
|
|
97
|
+
size: file_size,
|
|
98
|
+
written_bytes: content.bytesize,
|
|
99
|
+
mode: append ? 'append' : 'overwrite',
|
|
100
|
+
backup: backup_path,
|
|
101
|
+
mtime: file_mtime.iso8601
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Verifica se é safe (não é safe para execução automática)
|
|
106
|
+
#
|
|
107
|
+
# @param args [Hash] Argumentos
|
|
108
|
+
# @return [Boolean] false
|
|
109
|
+
def self.safe?(args)
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Verifica se é read-only
|
|
114
|
+
#
|
|
115
|
+
# @param args [Hash] Argumentos
|
|
116
|
+
# @return [Boolean] false (escreve no arquivo)
|
|
117
|
+
def self.read_only?(args)
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Verifica se é destrutivo
|
|
122
|
+
#
|
|
123
|
+
# @param args [Hash] Argumentos
|
|
124
|
+
# @return [Boolean] true (pode sobrescrever arquivos)
|
|
125
|
+
def self.destructive?(args)
|
|
126
|
+
append = args[:append] || args['append'] || false
|
|
127
|
+
!append # É destrutivo se não for append
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Cria arquivo de backup
|
|
133
|
+
#
|
|
134
|
+
# @param path [String] Caminho do arquivo
|
|
135
|
+
# @return [String] Caminho do backup
|
|
136
|
+
def create_backup_file(path)
|
|
137
|
+
timestamp = Time.now.to_i
|
|
138
|
+
backup_path = "#{path}.bak.#{timestamp}"
|
|
139
|
+
|
|
140
|
+
# Se backup já existe, usa timestamp com nanos
|
|
141
|
+
if File.exist?(backup_path)
|
|
142
|
+
backup_path = "#{path}.bak.#{Time.now.to_f}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
File.rename(path, backup_path)
|
|
146
|
+
backup_path
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Registra a tool
|
|
153
|
+
Gsd::Tools::Registry.register('file_write', Gsd::Tools::FileWriteTool, category: :files)
|