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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. 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)