ask-tools-shell 0.2.2 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03ed1e1f856f94afa4d5f5dc4b8fb278ad2598ddc6b7d698606c035a9e4034a9
4
- data.tar.gz: a040e94935e8a1a5e03530f84e225ee6f55e662fc1d2d1ad3840bf4bcc6e168d
3
+ metadata.gz: 139a762b4412fcf6dac23e7360f7f49e537f32272491865e90f3ec36ae27da44
4
+ data.tar.gz: d074b1a2173259a65fc9f9eab1efd3424f2ef4bfcea2e3e868a3ee281ca5d5db
5
5
  SHA512:
6
- metadata.gz: d8718eb919c2eec978e61c081a036cfbf34cb9adcc829bc0b4637fe490a566f9e573de48a6e327064558868b6abf351f9373178700e5d662b80f6cb128ad788c
7
- data.tar.gz: 95312eb2e6b6db1dafca32d8bf074a9d789c4a3cca13c30ed16e116a03e726f308250f08d9292fb7f7f05bed829cd7e11bb792370376e86bb46f938a1b42513c
6
+ metadata.gz: 8e16c060e637dc9dfa1c420fff9eaf5c357a8abc2ce7c9489fa082bd2f319b65e4b03b64677d3310df359725877e313c334c0a39a5eaa0b5bd8ff44253c2aac9
7
+ data.tar.gz: ab4bbada08684a89410144614778b70be6a5bc7e7e1d0dbd596aac2727df3eab80d3ec94de9cf172d4a0519d6a1494d102010087c61ea9bee6118fed549bf17f
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 (2026-06-21)
4
+
5
+ - Added `EditOperations` pluggable interface (read_file, write_file, file_exist?, file?, file_size, expand_path)
6
+ - Added `DefaultEditOperations` default implementation
7
+ - Added BOM detection/stripping to Edit tool (`Shell.strip_bom`)
8
+ - Added line ending detection and preservation to Edit tool (`Shell.detect_line_ending`, `Shell.normalize_line_endings`, `Shell.restore_line_endings`)
9
+ - Added `FileMutationQueue` for atomic batch file edits with rollback
10
+
11
+ ## 0.2.2
12
+
13
+ - Various fixes
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ask
6
+ module Tools
7
+ module Shell
8
+ module ApplyPatchOperations
9
+ def read_file(path)
10
+ File.read(path)
11
+ end
12
+
13
+ def write_file(path, content)
14
+ File.write(path, content)
15
+ end
16
+
17
+ def file_exist?(path)
18
+ File.exist?(path)
19
+ end
20
+
21
+ def file?(path)
22
+ File.file?(path)
23
+ end
24
+
25
+ def delete_file(path)
26
+ File.delete(path)
27
+ end
28
+
29
+ def expand_path(path)
30
+ File.expand_path(path)
31
+ end
32
+
33
+ def mkdir_p(path)
34
+ FileUtils.mkdir_p(File.dirname(path))
35
+ end
36
+ end
37
+
38
+ class DefaultApplyPatchOperations
39
+ include ApplyPatchOperations
40
+ end
41
+ end
42
+
43
+ # Apply a patch to files using a unified diff format.
44
+ # Supports creating, updating, and deleting files within a
45
+ # "*** Begin Patch" / "*** End Patch" envelope.
46
+ class ApplyPatch < Ask::Tool
47
+ description "Edit files using a unified diff format. " \
48
+ "Wrap all changes in a \"*** Begin Patch\" / \"*** End Patch\" envelope. " \
49
+ "Each file section starts with a header: " \
50
+ "\"*** Add File: <path>\" for new files, " \
51
+ "\"*** Update File: <path>\" for changes, or " \
52
+ "\"*** Delete File: <path>\" for removals. " \
53
+ "Prefix new lines with +."
54
+
55
+ param :patchText, type: :string, desc: "The full patch text describing all file changes", required: true
56
+
57
+ attr_writer :operations
58
+
59
+ def operations
60
+ @operations ||= Shell::DefaultApplyPatchOperations.new
61
+ end
62
+
63
+ MAX_FILE_SIZE = 10_000_000
64
+
65
+ def execute(patchText:)
66
+ patches = parse_patch(patchText)
67
+ return Ask::Result.error(message: "No valid patch sections found") if patches.empty?
68
+
69
+ results = []
70
+ patches.each do |entry|
71
+ path = operations.expand_path(entry[:path])
72
+
73
+ case entry[:type]
74
+ when :add
75
+ if operations.file_exist?(path)
76
+ return Ask::Result.error(message: "File already exists: #{path}")
77
+ end
78
+ operations.mkdir_p(path)
79
+ content = entry[:lines].join("\n")
80
+ content += "\n" unless content.end_with?("\n")
81
+ operations.write_file(path, content)
82
+ results << { action: "add", path: entry[:path], lines: entry[:lines].size }
83
+
84
+ when :update
85
+ unless operations.file_exist?(path)
86
+ return Ask::Result.error(message: "File does not exist: #{path}")
87
+ end
88
+ unless operations.file?(path)
89
+ return Ask::Result.error(message: "Not a file: #{path}")
90
+ end
91
+
92
+ raw = operations.read_file(path)
93
+ size = raw.bytesize
94
+ if size > MAX_FILE_SIZE
95
+ return Ask::Result.error(message: "File too large (#{size} bytes)")
96
+ end
97
+
98
+ new_content = apply_chunks(raw, entry[:chunks])
99
+ operations.write_file(path, new_content)
100
+ results << { action: "update", path: entry[:path] }
101
+
102
+ when :delete
103
+ unless operations.file_exist?(path)
104
+ return Ask::Result.error(message: "File does not exist: #{path}")
105
+ end
106
+ operations.delete_file(path)
107
+ results << { action: "delete", path: entry[:path] }
108
+ end
109
+ end
110
+
111
+ summary = results.map { |r| "#{r[:action].upcase_first} #{r[:path]}" }.join("\n")
112
+ Ask::Result.ok(data: { summary: summary, results: results }, metadata: { results: results })
113
+ end
114
+
115
+ private
116
+
117
+ PATCH_START = "*** Begin Patch"
118
+ PATCH_END = "*** End Patch"
119
+ ADD_HEADER = /^\*\*\* Add File:\s+(.+)/i
120
+ UPDATE_HEADER = /^\*\*\* Update File:\s+(.+)/i
121
+ DELETE_HEADER = /^\*\*\* Delete File:\s+(.+)/i
122
+ HUNK_HEADER = /^@@/
123
+
124
+ def parse_patch(text)
125
+ text = text.dup.force_encoding("UTF-8")
126
+ start_idx = text.index(PATCH_START)
127
+ return [] unless start_idx
128
+
129
+ after_start = text[(start_idx + PATCH_START.length)..]
130
+ end_idx = after_start.index(PATCH_END)
131
+ body = end_idx ? after_start[0...end_idx] : after_start
132
+
133
+ entries = []
134
+ lines = body.split("\n")
135
+ i = 0
136
+ while i < lines.length
137
+ line = lines[i]
138
+
139
+ if (m = line.match(ADD_HEADER))
140
+ path = m[1].strip
141
+ i += 1
142
+ content_lines = []
143
+ while i < lines.length && !lines[i].start_with?("***")
144
+ content_lines << lines[i].sub(/^\+/, "") if lines[i].start_with?("+")
145
+ i += 1
146
+ end
147
+ entries << { type: :add, path: path, lines: content_lines }
148
+
149
+ elsif (m = line.match(UPDATE_HEADER))
150
+ path = m[1].strip
151
+ i += 1
152
+ chunks = []
153
+ while i < lines.length && !lines[i].start_with?("***")
154
+ if lines[i].match?(HUNK_HEADER)
155
+ i += 1
156
+ old_lines = []
157
+ new_lines = []
158
+ while i < lines.length && !lines[i].start_with?("***") && !lines[i].match?(HUNK_HEADER) && !lines[i].start_with?("***")
159
+ if lines[i].start_with?("-")
160
+ old_lines << lines[i][1..]
161
+ elsif lines[i].start_with?("+")
162
+ new_lines << lines[i][1..]
163
+ elsif lines[i].start_with?(" ")
164
+ old_lines << lines[i][1..]
165
+ new_lines << lines[i][1..]
166
+ end
167
+ i += 1
168
+ end
169
+ chunks << { old: old_lines, new: new_lines } unless old_lines.empty? && new_lines.empty?
170
+ else
171
+ i += 1
172
+ end
173
+ end
174
+ entries << { type: :update, path: path, chunks: chunks }
175
+
176
+ elsif (m = line.match(DELETE_HEADER))
177
+ entries << { type: :delete, path: m[1].strip }
178
+ i += 1
179
+
180
+ else
181
+ i += 1
182
+ end
183
+ end
184
+
185
+ entries
186
+ end
187
+
188
+ def apply_chunks(content, chunks)
189
+ return content if chunks.empty?
190
+
191
+ lines = content.split("\n", -1)
192
+ chunks.each do |chunk|
193
+ old_text = chunk[:old].join("\n")
194
+ new_text = chunk[:new].join("\n")
195
+ file_text = lines.join("\n")
196
+ idx = file_text.index(old_text)
197
+ next unless idx
198
+
199
+ before = file_text[0...idx]
200
+ after = file_text[(idx + old_text.length)..]
201
+ file_text = before + new_text + after
202
+ lines = file_text.split("\n", -1)
203
+ end
204
+ lines.join("\n")
205
+ end
206
+ end
207
+ end
208
+ end
@@ -2,11 +2,66 @@
2
2
 
3
3
  module Ask
4
4
  module Tools
5
- # Edit a file by replacing exact text.
6
- # Uses exact string matching provide enough surrounding context for uniqueness.
5
+ module Shell
6
+ # Pluggable operations for the Edit tool.
7
+ module EditOperations
8
+ def read_file(path)
9
+ File.read(path)
10
+ end
11
+
12
+ def write_file(path, content)
13
+ File.write(path, content)
14
+ end
15
+
16
+ def file_exist?(path)
17
+ File.exist?(path)
18
+ end
19
+
20
+ def file?(path)
21
+ File.file?(path)
22
+ end
23
+
24
+ def file_size(path)
25
+ File.size(path)
26
+ end
27
+
28
+ def expand_path(path)
29
+ File.expand_path(path)
30
+ end
31
+ end
32
+
33
+ class DefaultEditOperations
34
+ include EditOperations
35
+ end
36
+
37
+ # Strip UTF-8 BOM from content. Returns { bom: String, text: String }.
38
+ def self.strip_bom(content)
39
+ bom = content.byteslice(0, 3) == "\xEF\xBB\xBF" ? content.byteslice(0, 3) : ""
40
+ [bom, bom.empty? ? content : content.byteslice(3..)]
41
+ end
42
+
43
+ # Detect the dominant line ending in content.
44
+ def self.detect_line_ending(content)
45
+ crlf = content.scan("\r\n").size
46
+ lf = content.count("\n") - crlf
47
+ crlf > lf ? "\r\n" : "\n"
48
+ end
49
+
50
+ # Normalize line endings to LF.
51
+ def self.normalize_line_endings(content)
52
+ content.gsub("\r\n", "\n").gsub("\r", "\n")
53
+ end
54
+
55
+ # Restore original line endings after normalization.
56
+ def self.restore_line_endings(content, original_ending)
57
+ return content if original_ending == "\n"
58
+ content.gsub("\n", original_ending)
59
+ end
60
+ end
61
+
62
+ # Edit a file by replacing exact text. Supports BOM, line-ending preservation.
7
63
  class Edit < Ask::Tool
8
- description "Edit a file by replacing exact text. " \
9
- "Uses exact string matching — provide enough surrounding context for uniqueness."
64
+ description "Edit a file by replacing exact text."
10
65
 
11
66
  param :path, type: :string, desc: "Absolute path to the file", required: true
12
67
  param :old_string, type: :string, desc: "Exact text to replace", required: true
@@ -15,24 +70,34 @@ module Ask
15
70
 
16
71
  MAX_FILE_SIZE = 1_000_000
17
72
 
73
+ attr_writer :operations
74
+
75
+ def operations
76
+ @operations ||= Shell::DefaultEditOperations.new
77
+ end
78
+
18
79
  def execute(path:, old_string:, new_string:, replace_all: false)
19
- path = File.expand_path(path)
80
+ path = operations.expand_path(path)
20
81
 
21
- unless File.exist?(path)
22
- return Ask::Result.error(message: "File does not exist: #{path}")
82
+ unless operations.file_exist?(path)
83
+ return Ask::Result.error(message: "File does not exist: " + path)
23
84
  end
24
85
 
25
- unless File.file?(path)
26
- return Ask::Result.error(message: "Not a file: #{path}")
86
+ unless operations.file?(path)
87
+ return Ask::Result.error(message: "Not a file: " + path)
27
88
  end
28
89
 
29
- if File.size(path) > MAX_FILE_SIZE
90
+ size = operations.file_size(path)
91
+ if size > MAX_FILE_SIZE
30
92
  return Ask::Result.error(
31
- message: "File too large (#{File.size(path)} bytes). Maximum is #{MAX_FILE_SIZE} bytes."
93
+ message: "File too large (#{size} bytes). Maximum is #{MAX_FILE_SIZE} bytes."
32
94
  )
33
95
  end
34
96
 
35
- content = File.read(path)
97
+ raw = operations.read_file(path)
98
+ bom, content = Shell.strip_bom(raw)
99
+ original_ending = Shell.detect_line_ending(content)
100
+ content = Shell.normalize_line_endings(content)
36
101
 
37
102
  if replace_all
38
103
  count = content.scan(old_string).size
@@ -45,7 +110,11 @@ module Ask
45
110
  content = content.sub(old_string, new_string)
46
111
  end
47
112
 
48
- File.write(path, content)
113
+ # Restore line endings and re-add BOM
114
+ content = Shell.restore_line_endings(content, original_ending)
115
+ content = bom + content
116
+
117
+ operations.write_file(path, content)
49
118
  Ask::Result.ok(data: { path: path, replacements: count },
50
119
  metadata: { path: path, replacements: count })
51
120
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Tools
5
+ module Shell
6
+ # Queues file mutations and applies them atomically.
7
+ # Rollback on any failure — no partial writes.
8
+ #
9
+ # queue = FileMutationQueue.new
10
+ # queue.stage("path/to/file.rb", ->(content) {
11
+ # content.gsub("old", "new")
12
+ # })
13
+ # queue.apply! # reads all staged files, applies transforms, writes back
14
+ #
15
+ class FileMutationQueue
16
+ class ApplyError < StandardError; end
17
+
18
+ def initialize(operations: nil)
19
+ @staged = []
20
+ @operations = operations || DefaultEditOperations.new
21
+ end
22
+
23
+ # Stage a mutation for a file.
24
+ # @param path [String] absolute path to the file
25
+ # @yield [content] raw file content, returns modified content
26
+ # @yieldparam content [String] the original file content
27
+ # @yieldreturn [String] the modified content
28
+ def stage(path, &block)
29
+ @staged << { path: File.expand_path(path), block: block }
30
+ end
31
+
32
+ # Apply all staged mutations atomically.
33
+ # Reads all files, applies transforms, then writes all back.
34
+ # If any write fails, all written files are rolled back to original.
35
+ # @return [Array<Hash>] results with :path, :original_size, :new_size, :success
36
+ # @raise [ApplyError] if any mutation fails (after rollback)
37
+ def apply!
38
+ snapshots = []
39
+
40
+ # Phase 1: read all files and apply transforms
41
+ @staged.each do |entry|
42
+ path = entry[:path]
43
+ original = @operations.read_file(path)
44
+ modified = entry[:block].call(original)
45
+ snapshots << { path: path, original: original, modified: modified }
46
+ end
47
+
48
+ # Phase 2: write all files, track rollback info
49
+ written = []
50
+ begin
51
+ snapshots.each do |s|
52
+ @operations.write_file(s[:path], s[:modified])
53
+ written << s[:path]
54
+ end
55
+ rescue => e
56
+ # Rollback: restore originals for files we already wrote
57
+ written.each do |path|
58
+ snapshot = snapshots.find { |s| s[:path] == path }
59
+ @operations.write_file(path, snapshot[:original]) if snapshot
60
+ rescue => rb_e
61
+ $stderr.puts "[FileMutationQueue] Rollback failed for #{path}: #{rb_e.message}"
62
+ end
63
+ raise ApplyError, "Mutation failed at #{e.class}: #{e.message}. Rolled back #{written.size} files."
64
+ end
65
+
66
+ snapshots.map do |s|
67
+ { path: s[:path], original_size: s[:original].length,
68
+ new_size: s[:modified].length, success: true }
69
+ end
70
+ end
71
+
72
+ # Clear all staged mutations without applying.
73
+ def clear!
74
+ @staged.clear
75
+ end
76
+
77
+ # Number of staged mutations.
78
+ def size
79
+ @staged.size
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Tools
5
5
  module Shell
6
- VERSION = "0.2.2"
6
+ VERSION = "0.3.1"
7
7
  end
8
8
  end
9
9
  end
@@ -9,11 +9,16 @@ require_relative "shell/edit"
9
9
  require_relative "shell/glob"
10
10
  require_relative "shell/grep"
11
11
  require_relative "shell/code"
12
+ require_relative "shell/apply_patch"
12
13
 
13
14
  module Ask
14
15
  module Tools
15
16
  module Shell
16
- TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code].freeze
17
+ TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch].freeze
18
+
19
+ def self.all
20
+ TOOLS.map(&:new)
21
+ end
17
22
  end
18
23
  end
19
24
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-tools-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -13,28 +13,28 @@ dependencies:
13
13
  name: ask-tools
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '0.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ask-sandbox-providers
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '0.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.1'
40
40
  - !ruby/object:Gem::Dependency
@@ -86,14 +86,17 @@ executables: []
86
86
  extensions: []
87
87
  extra_rdoc_files: []
88
88
  files:
89
+ - CHANGELOG.md
89
90
  - LICENSE
90
91
  - README.md
91
92
  - lib/ask-tools-shell.rb
92
93
  - lib/ask/skills/shell.patterns/SKILL.md
93
94
  - lib/ask/tools/shell.rb
95
+ - lib/ask/tools/shell/apply_patch.rb
94
96
  - lib/ask/tools/shell/bash.rb
95
97
  - lib/ask/tools/shell/code.rb
96
98
  - lib/ask/tools/shell/edit.rb
99
+ - lib/ask/tools/shell/file_mutation_queue.rb
97
100
  - lib/ask/tools/shell/glob.rb
98
101
  - lib/ask/tools/shell/grep.rb
99
102
  - lib/ask/tools/shell/read.rb
@@ -102,7 +105,10 @@ files:
102
105
  homepage: https://github.com/ask-rb/ask-tools-shell
103
106
  licenses:
104
107
  - MIT
105
- metadata: {}
108
+ metadata:
109
+ homepage_uri: https://github.com/ask-rb/ask-tools-shell
110
+ source_code_uri: https://github.com/ask-rb/ask-tools-shell
111
+ changelog_uri: https://github.com/ask-rb/ask-tools-shell/blob/master/CHANGELOG.md
106
112
  rdoc_options: []
107
113
  require_paths:
108
114
  - lib