ask-tools-shell 0.3.0 → 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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/ask/tools/shell/apply_patch.rb +208 -0
- data/lib/ask/tools/shell/edit.rb +1 -1
- data/lib/ask/tools/shell/version.rb +1 -1
- data/lib/ask/tools/shell.rb +6 -1
- metadata +11 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 139a762b4412fcf6dac23e7360f7f49e537f32272491865e90f3ec36ae27da44
|
|
4
|
+
data.tar.gz: d074b1a2173259a65fc9f9eab1efd3424f2ef4bfcea2e3e868a3ee281ca5d5db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/tools/shell/edit.rb
CHANGED
data/lib/ask/tools/shell.rb
CHANGED
|
@@ -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.3.
|
|
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,11 +86,13 @@ 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
|
|
@@ -103,7 +105,10 @@ files:
|
|
|
103
105
|
homepage: https://github.com/ask-rb/ask-tools-shell
|
|
104
106
|
licenses:
|
|
105
107
|
- MIT
|
|
106
|
-
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
|
|
107
112
|
rdoc_options: []
|
|
108
113
|
require_paths:
|
|
109
114
|
- lib
|