ask-tools-shell 0.1.0
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 +173 -0
- data/lib/ask/tools/shell/bash.rb +78 -0
- data/lib/ask/tools/shell/code.rb +59 -0
- data/lib/ask/tools/shell/edit.rb +54 -0
- data/lib/ask/tools/shell/glob.rb +38 -0
- data/lib/ask/tools/shell/grep.rb +63 -0
- data/lib/ask/tools/shell/read.rb +69 -0
- data/lib/ask/tools/shell/version.rb +9 -0
- data/lib/ask/tools/shell/write.rb +36 -0
- data/lib/ask/tools/shell.rb +20 -0
- data/lib/ask-tools-shell.rb +13 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: df1f5ba13ed96fd946f5fae6eaa010ac2ae9c8ef6c87a48fcec7ec0b994f1164
|
|
4
|
+
data.tar.gz: 79a8c50c47d6b43ba2fbe9dddd8a514aac6f2baa5951c240131003f4f88ac22d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d918ba9bd43e27f511c82f9adc4de3d6daa8817531555cfc567729f807f92df8e18f28932aa6e59738c0d2f1e653562255240df5974f3e128249e8ac04258418
|
|
7
|
+
data.tar.gz: 226ea5d605b60d10a8b6839c4469d5cbc97076f3c08596e59c0adb0fe29ed0b03bc95b54261af51a3c6babd074489a9b0ca3b55f47281d0cd8a4d1f41e01fe24
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# ask-tools-shell
|
|
2
|
+
|
|
3
|
+
Shell, filesystem, and code execution tools for AI agents. Part of the ask-rb ecosystem.
|
|
4
|
+
|
|
5
|
+
Provides **Bash**, **Read**, **Write**, **Edit**, **Glob**, **Grep**, and **Code** — the execution tools every agent needs.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "ask-tools-shell"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Dependencies
|
|
12
|
+
|
|
13
|
+
- **ask-tools** ~> 0.1 (provides `Ask::Tool` base class and `Ask::Result`)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "ask-tools-shell"
|
|
21
|
+
|
|
22
|
+
# List all available tools
|
|
23
|
+
Ask::Tools::Shell.all.map(&:name)
|
|
24
|
+
# => ["bash", "read", "write", "edit", "glob", "grep", "code"]
|
|
25
|
+
|
|
26
|
+
# Use a tool standalone
|
|
27
|
+
result = Ask::Tools::Bash.new.call(command: "echo hello")
|
|
28
|
+
result.ok? # => true
|
|
29
|
+
result.output[:stdout] # => "hello\n"
|
|
30
|
+
result.output[:exit_code] # => 0
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
### `Ask::Tools::Bash`
|
|
38
|
+
|
|
39
|
+
Execute shell commands in a sandboxed temp directory.
|
|
40
|
+
|
|
41
|
+
| Param | Type | Required | Default | Description |
|
|
42
|
+
|-------|------|----------|---------|-------------|
|
|
43
|
+
| `command` | `string` | Yes | — | The bash command to execute |
|
|
44
|
+
| `timeout` | `integer` | No | 30 | Timeout in seconds |
|
|
45
|
+
| `workdir` | `string` | No | temp dir | Working directory |
|
|
46
|
+
|
|
47
|
+
Returns `{ stdout, stderr, exit_code, timed_out }`. Output truncated to 100KB. Process killed on timeout.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Ask::Tools::Bash.new.call(command: "ls -la", timeout: 10)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `Ask::Tools::Read`
|
|
54
|
+
|
|
55
|
+
Read file contents with line numbers, or list directory entries.
|
|
56
|
+
|
|
57
|
+
| Param | Type | Required | Default | Description |
|
|
58
|
+
|-------|------|----------|---------|-------------|
|
|
59
|
+
| `path` | `string` | Yes | — | Absolute path to file or directory |
|
|
60
|
+
| `offset` | `integer` | No | 0 | Starting line number (0-indexed) |
|
|
61
|
+
| `limit` | `integer` | No | 2000 | Maximum lines to read |
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Ask::Tools::Read.new.call(path: "/etc/hosts")
|
|
65
|
+
Ask::Tools::Read.new.call(path: "large.log", offset: 100, limit: 50)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `Ask::Tools::Write`
|
|
69
|
+
|
|
70
|
+
Write content to a file. Creates parent directories automatically.
|
|
71
|
+
|
|
72
|
+
| Param | Type | Required | Default | Description |
|
|
73
|
+
|-------|------|----------|---------|-------------|
|
|
74
|
+
| `path` | `string` | Yes | — | Absolute path to write to |
|
|
75
|
+
| `content` | `string` | Yes | — | File content (max 500KB) |
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Ask::Tools::Write.new.call(path: "/tmp/hello.txt", content: "Hello, World!")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `Ask::Tools::Edit`
|
|
82
|
+
|
|
83
|
+
Replace exact text in a file. Uses exact string matching.
|
|
84
|
+
|
|
85
|
+
| Param | Type | Required | Default | Description |
|
|
86
|
+
|-------|------|----------|---------|-------------|
|
|
87
|
+
| `path` | `string` | Yes | — | Absolute path to the file |
|
|
88
|
+
| `old_string` | `string` | Yes | — | Exact text to replace |
|
|
89
|
+
| `new_string` | `string` | Yes | — | Replacement text |
|
|
90
|
+
| `replace_all` | `boolean` | No | false | Replace all occurrences |
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Ask::Tools::Edit.new.call(path: "file.rb", old_string: "foo", new_string: "bar")
|
|
94
|
+
Ask::Tools::Edit.new.call(path: "file.rb", old_string: "x", new_string: "y", replace_all: true)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `Ask::Tools::Glob`
|
|
98
|
+
|
|
99
|
+
Find files matching a glob pattern, sorted by modification time (newest first).
|
|
100
|
+
|
|
101
|
+
| Param | Type | Required | Default | Description |
|
|
102
|
+
|-------|------|----------|---------|-------------|
|
|
103
|
+
| `pattern` | `string` | Yes | — | Glob pattern (e.g. `**/*.rb`) |
|
|
104
|
+
| `path` | `string` | No | current dir | Base directory |
|
|
105
|
+
|
|
106
|
+
Max 1000 results.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
Ask::Tools::Glob.new.call(pattern: "**/*.rb", path: "/path/to/project")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `Ask::Tools::Grep`
|
|
113
|
+
|
|
114
|
+
Search file contents using a regex pattern.
|
|
115
|
+
|
|
116
|
+
| Param | Type | Required | Default | Description |
|
|
117
|
+
|-------|------|----------|---------|-------------|
|
|
118
|
+
| `pattern` | `string` | Yes | — | Regex pattern to search for |
|
|
119
|
+
| `path` | `string` | No | current dir | Directory to search |
|
|
120
|
+
| `include` | `string` | No | `**/*` | File pattern filter (e.g. `*.rb`) |
|
|
121
|
+
|
|
122
|
+
Max 100 matches. Line content capped at 500 chars. Skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log`.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Ask::Tools::Grep.new.call(pattern: "TODO", path: ".", include: "*.rb")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `Ask::Tools::Code`
|
|
129
|
+
|
|
130
|
+
Write and execute Ruby code in a subprocess. Uses gems already available in the environment.
|
|
131
|
+
|
|
132
|
+
| Param | Type | Required | Default | Description |
|
|
133
|
+
|-------|------|----------|---------|-------------|
|
|
134
|
+
| `code` | `string` | Yes | — | Ruby source code to execute |
|
|
135
|
+
|
|
136
|
+
Returns `{ stdout, stderr, exit_code }`. Output truncated to 100KB.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
Ask::Tools::Code.new.call(code: <<~RUBY)
|
|
140
|
+
puts "Hello from Ruby!"
|
|
141
|
+
result = 2 + 2
|
|
142
|
+
puts "2 + 2 = #{result}"
|
|
143
|
+
RUBY
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Using Tools with an Agent
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
require "ask-tools-shell"
|
|
152
|
+
|
|
153
|
+
# All tools
|
|
154
|
+
tools = Ask::Tools::Shell.all
|
|
155
|
+
|
|
156
|
+
# Find by name
|
|
157
|
+
bash = Ask::Tools["bash"]
|
|
158
|
+
bash.call(command: "date")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
bundle install
|
|
167
|
+
bundle exec rake test
|
|
168
|
+
gem build ask-tools-shell.gemspec
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module Ask
|
|
9
|
+
module Tools
|
|
10
|
+
# Execute shell commands in a sandboxed environment.
|
|
11
|
+
# Returns stdout, stderr, exit code, and a timed_out flag.
|
|
12
|
+
# Output is truncated to 100KB.
|
|
13
|
+
class Bash < Ask::Tool
|
|
14
|
+
description "Execute a bash command in a sandboxed environment. " \
|
|
15
|
+
"Returns stdout, stderr, and exit code. " \
|
|
16
|
+
"Output is truncated to 100KB."
|
|
17
|
+
|
|
18
|
+
param :command, type: :string, desc: "The bash command to execute", required: true
|
|
19
|
+
param :timeout, type: :integer, desc: "Timeout in seconds", required: false
|
|
20
|
+
param :workdir, type: :string, desc: "Working directory", required: false
|
|
21
|
+
|
|
22
|
+
MAX_OUTPUT_SIZE = 102_400
|
|
23
|
+
|
|
24
|
+
def execute(command:, timeout: 30, workdir: nil)
|
|
25
|
+
Dir.mktmpdir("ask_bash") do |dir|
|
|
26
|
+
workdir ||= dir
|
|
27
|
+
|
|
28
|
+
stdout = StringIO.new
|
|
29
|
+
stderr = StringIO.new
|
|
30
|
+
timed_out = false
|
|
31
|
+
exit_code = -1
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
Open3.popen3("bash", "-c", command, chdir: workdir) do |stdin, out, err, wait_thr|
|
|
35
|
+
stdin.close
|
|
36
|
+
|
|
37
|
+
threads = [
|
|
38
|
+
Thread.new { IO.copy_stream(out, stdout) rescue nil },
|
|
39
|
+
Thread.new { IO.copy_stream(err, stderr) rescue nil }
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
unless wait_thr.join(timeout)
|
|
43
|
+
Process.kill("-KILL", wait_thr.pid) rescue nil
|
|
44
|
+
timed_out = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
threads.each(&:join)
|
|
48
|
+
exit_code = timed_out ? -1 : wait_thr.value.exitstatus
|
|
49
|
+
end
|
|
50
|
+
rescue => e
|
|
51
|
+
return Ask::Result.error(message: "Bash execution failed: #{e.message}",
|
|
52
|
+
metadata: { stdout: stdout.string, stderr: stderr.string })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
out_text = stdout.string
|
|
56
|
+
err_text = stderr.string
|
|
57
|
+
|
|
58
|
+
if out_text.length > MAX_OUTPUT_SIZE
|
|
59
|
+
header = "[Output truncated to #{MAX_OUTPUT_SIZE / 1024}KB]\n"
|
|
60
|
+
out_text = "#{header}#{out_text[-(MAX_OUTPUT_SIZE - header.length)..]}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if err_text.length > MAX_OUTPUT_SIZE
|
|
64
|
+
header = "[Error output truncated to #{MAX_OUTPUT_SIZE / 1024}KB]\n"
|
|
65
|
+
err_text = "#{header}#{err_text[-(MAX_OUTPUT_SIZE - header.length)..]}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Ask::Result.ok(data: {
|
|
69
|
+
stdout: out_text,
|
|
70
|
+
stderr: err_text,
|
|
71
|
+
exit_code: exit_code,
|
|
72
|
+
timed_out: timed_out
|
|
73
|
+
})
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Tools
|
|
8
|
+
# Write and execute Ruby code in a subprocess.
|
|
9
|
+
# Runs in a temp directory via ruby -e.
|
|
10
|
+
class Code < Ask::Tool
|
|
11
|
+
description "Write and execute Ruby code in a subprocess. " \
|
|
12
|
+
"Returns stdout, stderr, and exit code. " \
|
|
13
|
+
"Uses gems already available in the environment."
|
|
14
|
+
|
|
15
|
+
param :code, type: :string, desc: "Ruby source code to execute", required: true
|
|
16
|
+
|
|
17
|
+
MAX_OUTPUT_SIZE = 102_400
|
|
18
|
+
|
|
19
|
+
def execute(code:)
|
|
20
|
+
Dir.mktmpdir("ask_code") do |_dir|
|
|
21
|
+
stdout = StringIO.new
|
|
22
|
+
stderr = StringIO.new
|
|
23
|
+
exit_code = -1
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
Open3.popen3("ruby", "-e", code, chdir: Dir.pwd) do |stdin, out, err, wait_thr|
|
|
27
|
+
stdin.close
|
|
28
|
+
|
|
29
|
+
threads = [
|
|
30
|
+
Thread.new { IO.copy_stream(out, stdout) rescue nil },
|
|
31
|
+
Thread.new { IO.copy_stream(err, stderr) rescue nil }
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
threads.each(&:join)
|
|
35
|
+
exit_code = wait_thr.value.exitstatus
|
|
36
|
+
end
|
|
37
|
+
rescue => e
|
|
38
|
+
return Ask::Result.error(message: "Code execution failed: #{e.message}",
|
|
39
|
+
metadata: { stdout: stdout.string, stderr: stderr.string })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
out_text = stdout.string
|
|
43
|
+
err_text = stderr.string
|
|
44
|
+
|
|
45
|
+
if out_text.length > MAX_OUTPUT_SIZE
|
|
46
|
+
header = "[Output truncated to #{MAX_OUTPUT_SIZE / 1024}KB]\n"
|
|
47
|
+
out_text = "#{header}#{out_text[-(MAX_OUTPUT_SIZE - header.length)..]}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Ask::Result.ok(data: {
|
|
51
|
+
stdout: out_text,
|
|
52
|
+
stderr: err_text,
|
|
53
|
+
exit_code: exit_code
|
|
54
|
+
})
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Tools
|
|
5
|
+
# Edit a file by replacing exact text.
|
|
6
|
+
# Uses exact string matching — provide enough surrounding context for uniqueness.
|
|
7
|
+
class Edit < Ask::Tool
|
|
8
|
+
description "Edit a file by replacing exact text. " \
|
|
9
|
+
"Uses exact string matching — provide enough surrounding context for uniqueness."
|
|
10
|
+
|
|
11
|
+
param :path, type: :string, desc: "Absolute path to the file", required: true
|
|
12
|
+
param :old_string, type: :string, desc: "Exact text to replace", required: true
|
|
13
|
+
param :new_string, type: :string, desc: "Replacement text", required: true
|
|
14
|
+
param :replace_all, type: :boolean, desc: "Replace all occurrences if true", required: false
|
|
15
|
+
|
|
16
|
+
MAX_FILE_SIZE = 1_000_000
|
|
17
|
+
|
|
18
|
+
def execute(path:, old_string:, new_string:, replace_all: false)
|
|
19
|
+
path = File.expand_path(path)
|
|
20
|
+
|
|
21
|
+
unless File.exist?(path)
|
|
22
|
+
return Ask::Result.error(message: "File does not exist: #{path}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unless File.file?(path)
|
|
26
|
+
return Ask::Result.error(message: "Not a file: #{path}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if File.size(path) > MAX_FILE_SIZE
|
|
30
|
+
return Ask::Result.error(
|
|
31
|
+
message: "File too large (#{File.size(path)} bytes). Maximum is #{MAX_FILE_SIZE} bytes."
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
content = File.read(path)
|
|
36
|
+
|
|
37
|
+
if replace_all
|
|
38
|
+
count = content.scan(old_string).size
|
|
39
|
+
return Ask::Result.error(message: "String not found") if count == 0
|
|
40
|
+
content = content.gsub(old_string, new_string)
|
|
41
|
+
else
|
|
42
|
+
index = content.index(old_string)
|
|
43
|
+
return Ask::Result.error(message: "String not found") unless index
|
|
44
|
+
count = 1
|
|
45
|
+
content = content.sub(old_string, new_string)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
File.write(path, content)
|
|
49
|
+
Ask::Result.ok(data: { path: path, replacements: count },
|
|
50
|
+
metadata: { path: path, replacements: count })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Tools
|
|
5
|
+
# Find files matching a glob pattern, newest first.
|
|
6
|
+
class Glob < Ask::Tool
|
|
7
|
+
description "Find files matching a glob pattern. " \
|
|
8
|
+
"Returns up to 1000 matching file paths sorted by modification time (newest first)."
|
|
9
|
+
|
|
10
|
+
param :pattern, type: :string, desc: "Glob pattern (e.g. **/*.rb)", required: true
|
|
11
|
+
param :path, type: :string, desc: "Base directory (default: current)", required: false
|
|
12
|
+
|
|
13
|
+
MAX_RESULTS = 1000
|
|
14
|
+
|
|
15
|
+
def execute(pattern:, path: nil)
|
|
16
|
+
base = path ? File.expand_path(path) : Dir.pwd
|
|
17
|
+
|
|
18
|
+
unless File.directory?(base)
|
|
19
|
+
return Ask::Result.error(message: "Directory does not exist: #{base}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
files = Dir.glob(pattern, base: base).map { |f| File.join(base, f) }
|
|
23
|
+
files = files.select { |f| File.file?(f) }
|
|
24
|
+
files = files.sort_by { |f| -File.mtime(f).to_i }
|
|
25
|
+
files = files.first(MAX_RESULTS)
|
|
26
|
+
|
|
27
|
+
if files.empty?
|
|
28
|
+
return Ask::Result.error(message: "No files found matching: #{pattern}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = files.join("\n")
|
|
32
|
+
result << "\n... (#{files.size} files shown)" if files.size == MAX_RESULTS
|
|
33
|
+
|
|
34
|
+
Ask::Result.ok(data: result, metadata: { count: files.size, truncated: files.size == MAX_RESULTS })
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Tools
|
|
5
|
+
# Search file contents using a regex pattern.
|
|
6
|
+
class Grep < Ask::Tool
|
|
7
|
+
description "Search file contents using a regex pattern. " \
|
|
8
|
+
"Returns matching file paths with line numbers and content. " \
|
|
9
|
+
"Truncated to 100 matches with line content capped at 500 chars."
|
|
10
|
+
|
|
11
|
+
param :pattern, type: :string, desc: "Regex pattern to search for", required: true
|
|
12
|
+
param :path, type: :string, desc: "Directory to search in (default: current)", required: false
|
|
13
|
+
param :include, type: :string, desc: "File pattern filter (e.g. *.rb)", required: false
|
|
14
|
+
|
|
15
|
+
MAX_MATCHES = 100
|
|
16
|
+
MAX_LINE_LENGTH = 500
|
|
17
|
+
EXCLUDE_DIRS = %w[.git node_modules vendor .bundle tmp log].freeze
|
|
18
|
+
|
|
19
|
+
def execute(pattern:, path: nil, include: nil)
|
|
20
|
+
base = path ? File.expand_path(path) : Dir.pwd
|
|
21
|
+
|
|
22
|
+
unless File.directory?(base)
|
|
23
|
+
return Ask::Result.error(message: "Directory does not exist: #{base}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
regex = Regexp.new(pattern, Regexp::IGNORECASE)
|
|
28
|
+
rescue RegexpError => e
|
|
29
|
+
return Ask::Result.error(message: "Invalid regex: #{e.message}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
matches = []
|
|
33
|
+
glob = include || "**/*"
|
|
34
|
+
|
|
35
|
+
Dir.glob(File.join(base, glob)).each do |file|
|
|
36
|
+
next unless File.file?(file)
|
|
37
|
+
next if EXCLUDE_DIRS.any? { |d| file.include?("/#{d}/") }
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
File.readlines(file).each_with_index do |line, i|
|
|
41
|
+
next unless regex.match?(line)
|
|
42
|
+
line_text = line.chomp
|
|
43
|
+
line_text = line_text[0, MAX_LINE_LENGTH] + "..." if line_text.length > MAX_LINE_LENGTH
|
|
44
|
+
matches << "#{file}:#{i + 1}: #{line_text}"
|
|
45
|
+
if matches.size >= MAX_MATCHES
|
|
46
|
+
return Ask::Result.ok(data: matches.join("\n") + "\n... (too many matches)",
|
|
47
|
+
metadata: { count: matches.size, truncated: true })
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rescue => e
|
|
51
|
+
matches << "#{file}: error reading: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if matches.empty?
|
|
56
|
+
return Ask::Result.error(message: "No matches found for: #{pattern}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Ask::Result.ok(data: matches.join("\n"), metadata: { count: matches.size, truncated: false })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Tools
|
|
7
|
+
# Read file contents with line numbers, or list directory contents.
|
|
8
|
+
# Output is truncated to 2000 lines.
|
|
9
|
+
class Read < Ask::Tool
|
|
10
|
+
description "Read the contents of a file or list a directory. " \
|
|
11
|
+
"Files are displayed with line numbers. " \
|
|
12
|
+
"Output is truncated to 2000 lines."
|
|
13
|
+
|
|
14
|
+
param :path, type: :string, desc: "Absolute path to the file or directory", required: true
|
|
15
|
+
param :offset, type: :integer, desc: "Starting line number (0-indexed)", required: false
|
|
16
|
+
param :limit, type: :integer, desc: "Maximum number of lines to read", required: false
|
|
17
|
+
|
|
18
|
+
MAX_LINES = 2000
|
|
19
|
+
|
|
20
|
+
def execute(path:, offset: nil, limit: nil)
|
|
21
|
+
path = File.expand_path(path)
|
|
22
|
+
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
return Ask::Result.error(message: "Path does not exist: #{path}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if File.directory?(path)
|
|
28
|
+
entries = Dir.children(path).sort
|
|
29
|
+
entries.map! do |e|
|
|
30
|
+
full = File.join(path, e)
|
|
31
|
+
"#{e}#{File.directory?(full) ? '/' : ''}"
|
|
32
|
+
end
|
|
33
|
+
return Ask::Result.ok(data: entries.join("\n"), metadata: { type: "directory", count: entries.size })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless File.file?(path)
|
|
37
|
+
return Ask::Result.error(message: "Not a file: #{path}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if File.size(path) > 1_000_000
|
|
41
|
+
return Ask::Result.error(
|
|
42
|
+
message: "File too large (#{File.size(path)} bytes). Use offset/limit to read portions."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
lines = File.readlines(path, chomp: true)
|
|
47
|
+
total = lines.size
|
|
48
|
+
offset_val = offset.to_i.clamp(0, total)
|
|
49
|
+
limit_val = limit || MAX_LINES
|
|
50
|
+
|
|
51
|
+
selected = lines[offset_val, limit_val]
|
|
52
|
+
truncated = selected.size < (total - offset_val)
|
|
53
|
+
|
|
54
|
+
result = selected.each_with_index.map do |line, i|
|
|
55
|
+
"#{offset_val + i + 1}: #{line}"
|
|
56
|
+
end.join("\n")
|
|
57
|
+
|
|
58
|
+
result << "\n... (#{total - offset_val - selected.size} more lines)" if truncated
|
|
59
|
+
|
|
60
|
+
Ask::Result.ok(data: result, metadata: {
|
|
61
|
+
total_lines: total,
|
|
62
|
+
start_line: offset_val + 1,
|
|
63
|
+
end_line: offset_val + selected.size,
|
|
64
|
+
truncated: truncated
|
|
65
|
+
})
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Tools
|
|
7
|
+
# Write content to a file, creating parent directories automatically.
|
|
8
|
+
class Write < Ask::Tool
|
|
9
|
+
description "Write content to a file at the specified path. " \
|
|
10
|
+
"Creates parent directories automatically. " \
|
|
11
|
+
"Returns the path and number of bytes written."
|
|
12
|
+
|
|
13
|
+
param :path, type: :string, desc: "Absolute path to write to", required: true
|
|
14
|
+
param :content, type: :string, desc: "File content to write", required: true
|
|
15
|
+
|
|
16
|
+
MAX_CONTENT_SIZE = 500_000
|
|
17
|
+
|
|
18
|
+
def execute(path:, content:)
|
|
19
|
+
path = File.expand_path(path)
|
|
20
|
+
|
|
21
|
+
if content.length > MAX_CONTENT_SIZE
|
|
22
|
+
return Ask::Result.error(
|
|
23
|
+
message: "Content too large (#{content.length} bytes). Maximum is #{MAX_CONTENT_SIZE} bytes."
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dir = File.dirname(path)
|
|
28
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
29
|
+
|
|
30
|
+
bytes = File.write(path, content)
|
|
31
|
+
Ask::Result.ok(data: { path: path, bytes: bytes },
|
|
32
|
+
metadata: { path: path, bytes: bytes })
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Tools
|
|
5
|
+
# Collection point for shell tools.
|
|
6
|
+
#
|
|
7
|
+
# Ask::Tools::Shell.all # => [Bash, Read, Write, ...] instances
|
|
8
|
+
#
|
|
9
|
+
module Shell
|
|
10
|
+
TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code].freeze
|
|
11
|
+
|
|
12
|
+
# Return an instance of every registered shell tool.
|
|
13
|
+
#
|
|
14
|
+
# @return [Array<Ask::Tool>]
|
|
15
|
+
def self.all
|
|
16
|
+
TOOLS.map(&:new)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask-tools"
|
|
4
|
+
|
|
5
|
+
require_relative "ask/tools/shell/version"
|
|
6
|
+
require_relative "ask/tools/shell/bash"
|
|
7
|
+
require_relative "ask/tools/shell/read"
|
|
8
|
+
require_relative "ask/tools/shell/write"
|
|
9
|
+
require_relative "ask/tools/shell/edit"
|
|
10
|
+
require_relative "ask/tools/shell/glob"
|
|
11
|
+
require_relative "ask/tools/shell/grep"
|
|
12
|
+
require_relative "ask/tools/shell/code"
|
|
13
|
+
require_relative "ask/tools/shell"
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-tools-shell
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-tools
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
description: Bash, Read, Write, Edit, Glob, Grep, and Code tools for the ask-rb ecosystem.
|
|
69
|
+
email:
|
|
70
|
+
- kaka@myrrlabs.com
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- LICENSE
|
|
76
|
+
- README.md
|
|
77
|
+
- lib/ask-tools-shell.rb
|
|
78
|
+
- lib/ask/tools/shell.rb
|
|
79
|
+
- lib/ask/tools/shell/bash.rb
|
|
80
|
+
- lib/ask/tools/shell/code.rb
|
|
81
|
+
- lib/ask/tools/shell/edit.rb
|
|
82
|
+
- lib/ask/tools/shell/glob.rb
|
|
83
|
+
- lib/ask/tools/shell/grep.rb
|
|
84
|
+
- lib/ask/tools/shell/read.rb
|
|
85
|
+
- lib/ask/tools/shell/version.rb
|
|
86
|
+
- lib/ask/tools/shell/write.rb
|
|
87
|
+
homepage: https://github.com/ask-rb/ask-tools-shell
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata: {}
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '3.2'
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 4.0.3
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: Shell, filesystem, and code execution tools
|
|
108
|
+
test_files: []
|