lex-exec 0.1.8 → 0.1.10
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 +20 -0
- data/README.md +38 -3
- data/lib/legion/extensions/exec/client.rb +61 -0
- data/lib/legion/extensions/exec/helpers/constants.rb +1 -1
- data/lib/legion/extensions/exec/helpers/repo_materializer.rb +86 -0
- data/lib/legion/extensions/exec/helpers/worktree.rb +28 -9
- data/lib/legion/extensions/exec/runners/bundler.rb +34 -0
- data/lib/legion/extensions/exec/runners/filesystem.rb +194 -0
- data/lib/legion/extensions/exec/runners/git.rb +211 -7
- data/lib/legion/extensions/exec/runners/shell.rb +26 -0
- data/lib/legion/extensions/exec/version.rb +1 -1
- data/lib/legion/extensions/exec.rb +40 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99f24f743720335d307955a43fafde85fb5eb89e40f169d7b800c48fe47eb24f
|
|
4
|
+
data.tar.gz: 816a4d2cbb3a04fa7dd1c62f689896a4671ad1042275bad1abd68d8cc0911a6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24bf7e41c0eca3212b1c17aed5995fe8b922a337832899a152a5fdf9662e35b4b5096a6d7ca06df022f9822e0cb787f96052e69e742a609be2735ba962e4f802
|
|
7
|
+
data.tar.gz: 1cdc824440473fa2ee65fe0c23336cf81ed25974007051174eaf836ca57a43ac93ef81179663c79df9c4a50f92365fe96d38bfa21c7b9de6eb1edd57f5180dd9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.10] - 2026-05-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Runner definition metadata for Shell, Git, Bundler, and Filesystem operations.
|
|
7
|
+
- Filesystem runner wrappers for common local commands including `pwd`, `ls`, `mkdir`, `touch`, `cat`, `head`, `tail`, `wc`, `cp`, `mv`, and `rm`.
|
|
8
|
+
- Git runner wrappers for `diff`, `branch`, `log`, and `show`.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Shell-escape Git command arguments for staging, commits, repository creation, and filtered diffs.
|
|
12
|
+
|
|
13
|
+
## [0.1.9] - 2026-04-13
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `Git.clone` — shallow/branched clone with depth read from `Legion::Settings.dig(:fleet, :git, :depth)`
|
|
17
|
+
- `Git.fetch` — `git fetch --all --prune` or named remote
|
|
18
|
+
- `Git.checkout` — checkout ref or create new branch with `-b`
|
|
19
|
+
- `Worktree.create/remove/list` — `repo_path:` parameter passes `chdir:` to `Open3.capture3` for shared-worker correctness
|
|
20
|
+
- `Helpers::RepoMaterializer` — strategy-based repo materialization (`materialize/release`); Phase 1 implements `:clone` strategy with `credential_provider` injection
|
|
21
|
+
- `worktree_path` now checks `Legion::Settings.dig(:fleet, :workspace, :worktree_base)` before falling back to `:worktree, :base_dir`
|
|
22
|
+
|
|
3
23
|
## [0.1.8] - 2026-04-09
|
|
4
24
|
|
|
5
25
|
### Changed
|
data/README.md
CHANGED
|
@@ -18,13 +18,14 @@ gem install lex-exec
|
|
|
18
18
|
|
|
19
19
|
## Overview
|
|
20
20
|
|
|
21
|
-
`lex-exec` provides
|
|
21
|
+
`lex-exec` provides four runners:
|
|
22
22
|
|
|
23
23
|
- **Shell** - Execute arbitrary shell commands against an allowlist
|
|
24
|
-
- **Git** - Common git operations (init, add, commit, push, status, create_repo)
|
|
24
|
+
- **Git** - Common git operations (init, add, commit, push, status, diff, branch, log, show, create_repo)
|
|
25
|
+
- **Filesystem** - Native wrappers for common file commands (`pwd`, `ls`, `mkdir`, `touch`, `cat`, `head`, `tail`, `wc`, `cp`, `mv`, `rm`)
|
|
25
26
|
- **Bundler** - Run `bundle install`, `rspec`, and `rubocop` with structured output parsing
|
|
26
27
|
|
|
27
|
-
All shell execution goes through a `Sandbox` that checks the base command against an allowlist and rejects commands matching blocked patterns. Every execution is recorded in a thread-safe in-memory `AuditLog`.
|
|
28
|
+
All runner methods expose Legion definition metadata (`mcp_prefix`, category, inputs, idempotency, and trigger words) so the extension can be discovered as native tools. All shell execution goes through a `Sandbox` that checks the base command against an allowlist and rejects commands matching blocked patterns. Every execution is recorded in a thread-safe in-memory `AuditLog`.
|
|
28
29
|
|
|
29
30
|
## Allowlisted Commands
|
|
30
31
|
|
|
@@ -33,6 +34,7 @@ Only the following base commands are permitted:
|
|
|
33
34
|
```
|
|
34
35
|
bundle git gh ruby rspec rubocop
|
|
35
36
|
ls cat mkdir cp mv rm touch echo wc head tail
|
|
37
|
+
pwd python3 pip3
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
Commands not in this list are rejected before execution with `success: false, error: :blocked`.
|
|
@@ -94,8 +96,19 @@ client.add(files: ['lib/foo.rb', 'spec/foo_spec.rb'])
|
|
|
94
96
|
client.commit(message: 'add foo runner')
|
|
95
97
|
client.push(remote: 'origin', branch: 'main', set_upstream: true)
|
|
96
98
|
client.status
|
|
99
|
+
client.diff(staged: true)
|
|
100
|
+
client.branch(all: true)
|
|
101
|
+
client.log(max_count: 10)
|
|
102
|
+
client.show(ref: 'HEAD')
|
|
97
103
|
client.create_repo(name: 'lex-foo', org: 'LegionIO', description: 'foo extension', public: true)
|
|
98
104
|
|
|
105
|
+
# Filesystem
|
|
106
|
+
client.pwd
|
|
107
|
+
client.ls(path: '/path/to/project', all: true, long: true)
|
|
108
|
+
client.mkdir(path: '/path/to/project/tmp/reports')
|
|
109
|
+
client.cat(path: '/path/to/project/README.md')
|
|
110
|
+
client.head(path: '/path/to/project/CHANGELOG.md', lines: 20)
|
|
111
|
+
|
|
99
112
|
# Bundler
|
|
100
113
|
client.install
|
|
101
114
|
client.exec_rspec(format: 'progress')
|
|
@@ -121,6 +134,12 @@ Legion::Extensions::Exec::Runners::Git.push(path: '/path/to/dir', remote: 'origi
|
|
|
121
134
|
# Status (parses --porcelain output into structured form)
|
|
122
135
|
Legion::Extensions::Exec::Runners::Git.status(path: '/path/to/dir')
|
|
123
136
|
|
|
137
|
+
# Diff, branch, log, and show
|
|
138
|
+
Legion::Extensions::Exec::Runners::Git.diff(path: '/path/to/dir', staged: true)
|
|
139
|
+
Legion::Extensions::Exec::Runners::Git.branch(path: '/path/to/dir', all: true)
|
|
140
|
+
Legion::Extensions::Exec::Runners::Git.log(path: '/path/to/dir', max_count: 20)
|
|
141
|
+
Legion::Extensions::Exec::Runners::Git.show(path: '/path/to/dir', ref: 'HEAD')
|
|
142
|
+
|
|
124
143
|
# Create GitHub repo via gh CLI
|
|
125
144
|
Legion::Extensions::Exec::Runners::Git.create_repo(
|
|
126
145
|
name: 'lex-myext',
|
|
@@ -130,6 +149,22 @@ Legion::Extensions::Exec::Runners::Git.create_repo(
|
|
|
130
149
|
)
|
|
131
150
|
```
|
|
132
151
|
|
|
152
|
+
### Filesystem runner
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
Legion::Extensions::Exec::Runners::Filesystem.pwd(path: '/path/to/project')
|
|
156
|
+
Legion::Extensions::Exec::Runners::Filesystem.ls(path: '/path/to/project', all: true, long: true)
|
|
157
|
+
Legion::Extensions::Exec::Runners::Filesystem.mkdir(path: '/path/to/project/tmp/reports')
|
|
158
|
+
Legion::Extensions::Exec::Runners::Filesystem.touch(path: '/path/to/project/tmp/report.txt')
|
|
159
|
+
Legion::Extensions::Exec::Runners::Filesystem.cat(path: '/path/to/project/README.md')
|
|
160
|
+
Legion::Extensions::Exec::Runners::Filesystem.head(path: '/path/to/project/CHANGELOG.md', lines: 20)
|
|
161
|
+
Legion::Extensions::Exec::Runners::Filesystem.tail(path: '/path/to/project/log/development.log', lines: 50)
|
|
162
|
+
Legion::Extensions::Exec::Runners::Filesystem.wc(path: '/path/to/project/README.md')
|
|
163
|
+
Legion::Extensions::Exec::Runners::Filesystem.cp(source: '/tmp/a.txt', destination: '/tmp/b.txt')
|
|
164
|
+
Legion::Extensions::Exec::Runners::Filesystem.mv(source: '/tmp/b.txt', destination: '/tmp/c.txt')
|
|
165
|
+
Legion::Extensions::Exec::Runners::Filesystem.rm(path: '/tmp/c.txt')
|
|
166
|
+
```
|
|
167
|
+
|
|
133
168
|
### Bundler runner
|
|
134
169
|
|
|
135
170
|
```ruby
|
|
@@ -38,10 +38,71 @@ module Legion
|
|
|
38
38
|
Runners::Git.status(path: path)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def diff(path: @base_path, **)
|
|
42
|
+
Runners::Git.diff(path: path, **)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def branch(path: @base_path, **)
|
|
46
|
+
Runners::Git.branch(path: path, **)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def log(path: @base_path, **)
|
|
50
|
+
Runners::Git.log(path: path, **)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def show(path: @base_path, **)
|
|
54
|
+
Runners::Git.show(path: path, **)
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
def create_repo(**)
|
|
42
58
|
Runners::Git.create_repo(**)
|
|
43
59
|
end
|
|
44
60
|
|
|
61
|
+
# Filesystem delegation
|
|
62
|
+
def pwd(path: @base_path, **)
|
|
63
|
+
Runners::Filesystem.pwd(path: path, **)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ls(path: @base_path, **)
|
|
67
|
+
Runners::Filesystem.ls(path: path, **)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def mkdir(path:, **)
|
|
71
|
+
Runners::Filesystem.mkdir(path: path, **)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def touch(path:, **)
|
|
75
|
+
Runners::Filesystem.touch(path: path, **)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cat(path:, **)
|
|
79
|
+
Runners::Filesystem.cat(path: path, **)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def head(path:, **)
|
|
83
|
+
Runners::Filesystem.head(path: path, **)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tail(path:, **)
|
|
87
|
+
Runners::Filesystem.tail(path: path, **)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def wc(path:, **)
|
|
91
|
+
Runners::Filesystem.wc(path: path, **)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cp(source:, destination:, **)
|
|
95
|
+
Runners::Filesystem.cp(source: source, destination: destination, **)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mv(source:, destination:, **)
|
|
99
|
+
Runners::Filesystem.mv(source: source, destination: destination, **)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def rm(path:, **)
|
|
103
|
+
Runners::Filesystem.rm(path: path, **)
|
|
104
|
+
end
|
|
105
|
+
|
|
45
106
|
# Bundler delegation
|
|
46
107
|
def install(path: @base_path, **)
|
|
47
108
|
Runners::Bundler.install(path: path)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Exec
|
|
8
|
+
module Helpers
|
|
9
|
+
module RepoMaterializer
|
|
10
|
+
class << self
|
|
11
|
+
def materialize(work_item:, credential_provider: nil)
|
|
12
|
+
strategy = resolve_strategy
|
|
13
|
+
case strategy
|
|
14
|
+
when :clone
|
|
15
|
+
materialize_via_clone(work_item: work_item, credential_provider: credential_provider)
|
|
16
|
+
else
|
|
17
|
+
{ success: false, error: "Unknown strategy: #{strategy}" }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def release(work_item:)
|
|
22
|
+
repo_path = build_repo_path(work_item)
|
|
23
|
+
Helpers::Worktree.remove(task_id: work_item[:task_id], repo_path: repo_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def resolve_strategy
|
|
29
|
+
raw = Legion::Settings.dig(:fleet, :materialization, :strategy) if defined?(Legion::Settings)
|
|
30
|
+
(raw || :clone).to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def materialize_via_clone(work_item:, credential_provider:)
|
|
34
|
+
url = apply_credentials(work_item[:repo_url], credential_provider)
|
|
35
|
+
depth = Legion::Settings.dig(:fleet, :git, :depth) if defined?(Legion::Settings)
|
|
36
|
+
repo_path = build_repo_path(work_item)
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
::FileUtils.mkdir_p(::File.dirname(repo_path))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
return { success: false, reason: :mkdir_failed, error: e.message }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
clone_result = Runners::Git.clone(url: url, path: repo_path, depth: depth)
|
|
45
|
+
unless clone_result[:success]
|
|
46
|
+
return {
|
|
47
|
+
success: false,
|
|
48
|
+
error: clone_result[:stderr] || clone_result[:reason] || clone_result[:error],
|
|
49
|
+
reason: clone_result[:reason],
|
|
50
|
+
stderr: clone_result[:stderr],
|
|
51
|
+
clone_result: clone_result
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
worktree_result = Helpers::Worktree.create(
|
|
56
|
+
task_id: work_item[:task_id],
|
|
57
|
+
branch: work_item[:branch],
|
|
58
|
+
base_ref: work_item[:base_ref] || 'HEAD',
|
|
59
|
+
repo_path: repo_path
|
|
60
|
+
)
|
|
61
|
+
return { success: false, error: worktree_result[:message] } unless worktree_result[:success]
|
|
62
|
+
|
|
63
|
+
{ success: true, workspace_path: worktree_result[:path], branch: worktree_result[:branch], repo_path: repo_path }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def apply_credentials(url, credential_provider)
|
|
67
|
+
return url unless credential_provider
|
|
68
|
+
|
|
69
|
+
result = credential_provider.call(url)
|
|
70
|
+
raise ArgumentError, 'credential_provider must return a non-empty String URL' unless result.is_a?(String) && !result.empty?
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_repo_path(work_item)
|
|
76
|
+
base = (Legion::Settings.dig(:fleet, :workspace, :repo_base) if defined?(Legion::Settings))
|
|
77
|
+
base ||= '/tmp/legion-repos'
|
|
78
|
+
repo_name = ::File.basename(work_item[:repo_url], '.git')
|
|
79
|
+
::File.join(base, work_item[:task_id].to_s, repo_name)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -9,40 +9,59 @@ module Legion
|
|
|
9
9
|
module Helpers
|
|
10
10
|
module Worktree
|
|
11
11
|
class << self
|
|
12
|
-
def create(task_id:, branch: nil, base_ref: 'HEAD')
|
|
12
|
+
def create(task_id:, branch: nil, base_ref: 'HEAD', repo_path: nil)
|
|
13
13
|
branch ||= "legion/#{task_id}"
|
|
14
14
|
path = worktree_path(task_id)
|
|
15
15
|
return { success: false, reason: :already_exists } if Dir.exist?(path)
|
|
16
16
|
|
|
17
17
|
FileUtils.mkdir_p(File.dirname(path))
|
|
18
|
-
|
|
18
|
+
args = ['git', 'worktree', 'add', path, '-b', branch, base_ref]
|
|
19
|
+
opts = repo_path ? { chdir: repo_path } : {}
|
|
20
|
+
_stdout, stderr, status = Open3.capture3(*args, **opts)
|
|
19
21
|
if status.success?
|
|
20
22
|
{ success: true, path: path, branch: branch }
|
|
21
23
|
else
|
|
22
24
|
{ success: false, reason: :git_error, message: stderr.strip }
|
|
23
25
|
end
|
|
26
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES => e
|
|
27
|
+
{ success: false, reason: :invalid_repo_path, message: e.message }
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
def remove(task_id:)
|
|
30
|
+
def remove(task_id:, repo_path: nil)
|
|
27
31
|
path = worktree_path(task_id)
|
|
28
32
|
return { success: false, reason: :not_found } unless Dir.exist?(path)
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
args = ['git', 'worktree', 'remove', path, '--force']
|
|
35
|
+
opts = repo_path ? { chdir: repo_path } : {}
|
|
36
|
+
_stdout, stderr, status = Open3.capture3(*args, **opts)
|
|
31
37
|
if status.success?
|
|
32
38
|
{ success: true }
|
|
33
39
|
else
|
|
34
40
|
{ success: false, reason: :git_error, message: stderr.strip }
|
|
35
41
|
end
|
|
42
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES => e
|
|
43
|
+
{ success: false, reason: :invalid_repo_path, message: e.message }
|
|
36
44
|
end
|
|
37
45
|
|
|
38
|
-
def list
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
def list(repo_path: nil)
|
|
47
|
+
args = ['git', 'worktree', 'list', '--porcelain']
|
|
48
|
+
opts = repo_path ? { chdir: repo_path } : {}
|
|
49
|
+
stdout, stderr, status = Open3.capture3(*args, **opts)
|
|
50
|
+
if status.success?
|
|
51
|
+
worktrees = parse_worktree_list(stdout)
|
|
52
|
+
{ success: true, worktrees: worktrees }
|
|
53
|
+
else
|
|
54
|
+
{ success: false, reason: :git_error, message: stderr.strip }
|
|
55
|
+
end
|
|
56
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES => e
|
|
57
|
+
{ success: false, reason: :invalid_repo_path, message: e.message }
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
def worktree_path(task_id)
|
|
45
|
-
base =
|
|
61
|
+
base = if defined?(Legion::Settings)
|
|
62
|
+
Legion::Settings.dig(:fleet, :workspace, :worktree_base) ||
|
|
63
|
+
Legion::Settings.dig(:worktree, :base_dir)
|
|
64
|
+
end
|
|
46
65
|
File.join(base || File.join(Dir.pwd, '.legion-worktrees'), task_id.to_s)
|
|
47
66
|
end
|
|
48
67
|
|
|
@@ -5,12 +5,36 @@ module Legion
|
|
|
5
5
|
module Exec
|
|
6
6
|
module Runners
|
|
7
7
|
module Bundler # rubocop:disable Legion/Extension/RunnerIncludeHelpers
|
|
8
|
+
extend Legion::Extensions::Definitions
|
|
9
|
+
|
|
8
10
|
module_function
|
|
9
11
|
|
|
12
|
+
def self.trigger_words
|
|
13
|
+
%w[bundle bundler gem gems install rspec rubocop test lint]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
definition :install,
|
|
17
|
+
desc: 'Run bundle install in a Ruby project',
|
|
18
|
+
mcp_prefix: 'exec.bundler.install',
|
|
19
|
+
mcp_category: 'exec_bundler',
|
|
20
|
+
mcp_tier: :standard,
|
|
21
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
22
|
+
trigger_words: %w[bundle install gems]
|
|
23
|
+
|
|
10
24
|
def install(path:, **)
|
|
11
25
|
Runners::Shell.execute(command: 'bundle install', cwd: path, timeout: 300_000)
|
|
12
26
|
end
|
|
13
27
|
|
|
28
|
+
definition :exec_rspec,
|
|
29
|
+
desc: 'Run bundle exec rspec and parse the suite summary',
|
|
30
|
+
mcp_prefix: 'exec.bundler.exec_rspec',
|
|
31
|
+
mcp_category: 'exec_bundler',
|
|
32
|
+
mcp_tier: :standard,
|
|
33
|
+
inputs: { properties: { path: { type: 'string' },
|
|
34
|
+
format: { type: 'string' } },
|
|
35
|
+
required: ['path'] },
|
|
36
|
+
trigger_words: %w[rspec spec test suite]
|
|
37
|
+
|
|
14
38
|
def exec_rspec(path:, format: 'progress', **)
|
|
15
39
|
result = Runners::Shell.execute(
|
|
16
40
|
command: "bundle exec rspec --format #{format}",
|
|
@@ -24,6 +48,16 @@ module Legion
|
|
|
24
48
|
result.merge(parsed: parsed)
|
|
25
49
|
end
|
|
26
50
|
|
|
51
|
+
definition :exec_rubocop,
|
|
52
|
+
desc: 'Run bundle exec rubocop with optional autocorrect and parse offenses',
|
|
53
|
+
mcp_prefix: 'exec.bundler.exec_rubocop',
|
|
54
|
+
mcp_category: 'exec_bundler',
|
|
55
|
+
mcp_tier: :standard,
|
|
56
|
+
inputs: { properties: { path: { type: 'string' },
|
|
57
|
+
autocorrect: { type: 'boolean' } },
|
|
58
|
+
required: ['path'] },
|
|
59
|
+
trigger_words: %w[rubocop lint autocorrect offenses]
|
|
60
|
+
|
|
27
61
|
def exec_rubocop(path:, autocorrect: false, **)
|
|
28
62
|
cmd = autocorrect ? 'bundle exec rubocop -A' : 'bundle exec rubocop'
|
|
29
63
|
result = Runners::Shell.execute(command: cmd, cwd: path, timeout: 120_000)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Exec
|
|
8
|
+
module Runners
|
|
9
|
+
module Filesystem # rubocop:disable Legion/Extension/RunnerIncludeHelpers
|
|
10
|
+
extend Legion::Extensions::Definitions
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def self.trigger_words
|
|
15
|
+
%w[file files filesystem directory directories ls list mkdir touch cat head tail wc cp mv rm]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
definition :pwd,
|
|
19
|
+
desc: 'Print the current working directory',
|
|
20
|
+
mcp_prefix: 'exec.filesystem.pwd',
|
|
21
|
+
mcp_category: 'exec_filesystem',
|
|
22
|
+
mcp_tier: :standard,
|
|
23
|
+
idempotent: true,
|
|
24
|
+
inputs: { properties: { path: { type: 'string' } } },
|
|
25
|
+
trigger_words: %w[pwd directory cwd]
|
|
26
|
+
|
|
27
|
+
def pwd(path: Dir.pwd, **)
|
|
28
|
+
Runners::Shell.execute(command: 'pwd', cwd: path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
definition :ls,
|
|
32
|
+
desc: 'List files in a directory',
|
|
33
|
+
mcp_prefix: 'exec.filesystem.ls',
|
|
34
|
+
mcp_category: 'exec_filesystem',
|
|
35
|
+
mcp_tier: :standard,
|
|
36
|
+
idempotent: true,
|
|
37
|
+
inputs: { properties: { path: { type: 'string' },
|
|
38
|
+
all: { type: 'boolean' },
|
|
39
|
+
long: { type: 'boolean' } } },
|
|
40
|
+
trigger_words: %w[ls list files directory]
|
|
41
|
+
|
|
42
|
+
def ls(path: Dir.pwd, all: false, long: false, **)
|
|
43
|
+
flags = []
|
|
44
|
+
flags << 'a' if all
|
|
45
|
+
flags << 'l' if long
|
|
46
|
+
|
|
47
|
+
command = ['ls']
|
|
48
|
+
command << "-#{flags.join}" unless flags.empty?
|
|
49
|
+
command << Shellwords.shellescape(path)
|
|
50
|
+
Runners::Shell.execute(command: command.join(' '), cwd: Dir.pwd)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
definition :mkdir,
|
|
54
|
+
desc: 'Create a directory',
|
|
55
|
+
mcp_prefix: 'exec.filesystem.mkdir',
|
|
56
|
+
mcp_category: 'exec_filesystem',
|
|
57
|
+
mcp_tier: :standard,
|
|
58
|
+
inputs: { properties: { path: { type: 'string' },
|
|
59
|
+
parents: { type: 'boolean' } },
|
|
60
|
+
required: ['path'] },
|
|
61
|
+
trigger_words: %w[mkdir create directory folder]
|
|
62
|
+
|
|
63
|
+
def mkdir(path:, parents: true, **)
|
|
64
|
+
flag = parents ? ' -p' : ''
|
|
65
|
+
Runners::Shell.execute(command: "mkdir#{flag} #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
definition :touch,
|
|
69
|
+
desc: 'Create or update a file timestamp',
|
|
70
|
+
mcp_prefix: 'exec.filesystem.touch',
|
|
71
|
+
mcp_category: 'exec_filesystem',
|
|
72
|
+
mcp_tier: :standard,
|
|
73
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
74
|
+
trigger_words: %w[touch create file]
|
|
75
|
+
|
|
76
|
+
def touch(path:, **)
|
|
77
|
+
Runners::Shell.execute(command: "touch #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
definition :cat,
|
|
81
|
+
desc: 'Read a file with cat',
|
|
82
|
+
mcp_prefix: 'exec.filesystem.cat',
|
|
83
|
+
mcp_category: 'exec_filesystem',
|
|
84
|
+
mcp_tier: :standard,
|
|
85
|
+
idempotent: true,
|
|
86
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
87
|
+
trigger_words: %w[cat read file]
|
|
88
|
+
|
|
89
|
+
def cat(path:, **)
|
|
90
|
+
Runners::Shell.execute(command: "cat #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
definition :head,
|
|
94
|
+
desc: 'Read the first lines of a file',
|
|
95
|
+
mcp_prefix: 'exec.filesystem.head',
|
|
96
|
+
mcp_category: 'exec_filesystem',
|
|
97
|
+
mcp_tier: :standard,
|
|
98
|
+
idempotent: true,
|
|
99
|
+
inputs: { properties: { path: { type: 'string' },
|
|
100
|
+
lines: { type: 'integer' } },
|
|
101
|
+
required: ['path'] },
|
|
102
|
+
trigger_words: %w[head first lines file]
|
|
103
|
+
|
|
104
|
+
def head(path:, lines: 20, **)
|
|
105
|
+
Runners::Shell.execute(command: "head -n #{Integer(lines)} #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
definition :tail,
|
|
109
|
+
desc: 'Read the last lines of a file',
|
|
110
|
+
mcp_prefix: 'exec.filesystem.tail',
|
|
111
|
+
mcp_category: 'exec_filesystem',
|
|
112
|
+
mcp_tier: :standard,
|
|
113
|
+
idempotent: true,
|
|
114
|
+
inputs: { properties: { path: { type: 'string' },
|
|
115
|
+
lines: { type: 'integer' } },
|
|
116
|
+
required: ['path'] },
|
|
117
|
+
trigger_words: %w[tail last lines file]
|
|
118
|
+
|
|
119
|
+
def tail(path:, lines: 20, **)
|
|
120
|
+
Runners::Shell.execute(command: "tail -n #{Integer(lines)} #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
definition :wc,
|
|
124
|
+
desc: 'Count lines, words, and bytes in a file',
|
|
125
|
+
mcp_prefix: 'exec.filesystem.wc',
|
|
126
|
+
mcp_category: 'exec_filesystem',
|
|
127
|
+
mcp_tier: :standard,
|
|
128
|
+
idempotent: true,
|
|
129
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
130
|
+
trigger_words: %w[wc count lines words bytes]
|
|
131
|
+
|
|
132
|
+
def wc(path:, **)
|
|
133
|
+
Runners::Shell.execute(command: "wc #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
definition :cp,
|
|
137
|
+
desc: 'Copy files or directories',
|
|
138
|
+
mcp_prefix: 'exec.filesystem.cp',
|
|
139
|
+
mcp_category: 'exec_filesystem',
|
|
140
|
+
mcp_tier: :standard,
|
|
141
|
+
inputs: { properties: { source: { type: 'string' },
|
|
142
|
+
destination: { type: 'string' },
|
|
143
|
+
recursive: { type: 'boolean' } },
|
|
144
|
+
required: %w[source destination] },
|
|
145
|
+
trigger_words: %w[copy cp file directory]
|
|
146
|
+
|
|
147
|
+
def cp(source:, destination:, recursive: false, **)
|
|
148
|
+
flag = recursive ? ' -R' : ''
|
|
149
|
+
Runners::Shell.execute(
|
|
150
|
+
command: "cp#{flag} #{Shellwords.shellescape(source)} #{Shellwords.shellescape(destination)}",
|
|
151
|
+
cwd: Dir.pwd
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
definition :mv,
|
|
156
|
+
desc: 'Move or rename files or directories',
|
|
157
|
+
mcp_prefix: 'exec.filesystem.mv',
|
|
158
|
+
mcp_category: 'exec_filesystem',
|
|
159
|
+
mcp_tier: :standard,
|
|
160
|
+
inputs: { properties: { source: { type: 'string' },
|
|
161
|
+
destination: { type: 'string' } },
|
|
162
|
+
required: %w[source destination] },
|
|
163
|
+
trigger_words: %w[move mv rename file directory]
|
|
164
|
+
|
|
165
|
+
def mv(source:, destination:, **)
|
|
166
|
+
Runners::Shell.execute(
|
|
167
|
+
command: "mv #{Shellwords.shellescape(source)} #{Shellwords.shellescape(destination)}",
|
|
168
|
+
cwd: Dir.pwd
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
definition :rm,
|
|
173
|
+
desc: 'Remove a file or directory through the sandbox blocklist',
|
|
174
|
+
mcp_prefix: 'exec.filesystem.rm',
|
|
175
|
+
mcp_category: 'exec_filesystem',
|
|
176
|
+
mcp_tier: :elevated,
|
|
177
|
+
inputs: { properties: { path: { type: 'string' },
|
|
178
|
+
recursive: { type: 'boolean' },
|
|
179
|
+
force: { type: 'boolean' } },
|
|
180
|
+
required: ['path'] },
|
|
181
|
+
trigger_words: %w[remove delete rm file directory]
|
|
182
|
+
|
|
183
|
+
def rm(path:, recursive: false, force: false, **)
|
|
184
|
+
flags = []
|
|
185
|
+
flags << 'r' if recursive
|
|
186
|
+
flags << 'f' if force
|
|
187
|
+
flag = flags.empty? ? '' : " -#{flags.join}"
|
|
188
|
+
Runners::Shell.execute(command: "rm#{flag} #{Shellwords.shellescape(path)}", cwd: Dir.pwd)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -1,31 +1,86 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Exec
|
|
6
8
|
module Runners
|
|
7
9
|
module Git # rubocop:disable Legion/Extension/RunnerIncludeHelpers
|
|
10
|
+
extend Legion::Extensions::Definitions
|
|
11
|
+
|
|
8
12
|
module_function
|
|
9
13
|
|
|
14
|
+
def self.trigger_words
|
|
15
|
+
%w[git repo repository status diff add commit branch checkout clone fetch push]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
definition :init,
|
|
19
|
+
desc: 'Initialize a git repository at the target path',
|
|
20
|
+
mcp_prefix: 'exec.git.init',
|
|
21
|
+
mcp_category: 'exec_git',
|
|
22
|
+
mcp_tier: :standard,
|
|
23
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
24
|
+
trigger_words: %w[git init repository]
|
|
25
|
+
|
|
10
26
|
def init(path:, **)
|
|
11
27
|
Runners::Shell.execute(command: 'git init', cwd: path)
|
|
12
28
|
end
|
|
13
29
|
|
|
30
|
+
definition :add,
|
|
31
|
+
desc: 'Stage files in a git repository',
|
|
32
|
+
mcp_prefix: 'exec.git.add',
|
|
33
|
+
mcp_category: 'exec_git',
|
|
34
|
+
mcp_tier: :standard,
|
|
35
|
+
inputs: { properties: { path: { type: 'string' },
|
|
36
|
+
files: { type: %w[string array] } },
|
|
37
|
+
required: ['path'] },
|
|
38
|
+
trigger_words: %w[git add stage files]
|
|
39
|
+
|
|
14
40
|
def add(path:, files: '.', **)
|
|
15
|
-
cmd = files == '.' ? 'git add -A' : "git add #{Array(files)
|
|
41
|
+
cmd = files == '.' ? 'git add -A' : "git add #{shellwords_join(Array(files))}"
|
|
16
42
|
Runners::Shell.execute(command: cmd, cwd: path)
|
|
17
43
|
end
|
|
18
44
|
|
|
45
|
+
definition :commit,
|
|
46
|
+
desc: 'Create a git commit with a message',
|
|
47
|
+
mcp_prefix: 'exec.git.commit',
|
|
48
|
+
mcp_category: 'exec_git',
|
|
49
|
+
mcp_tier: :standard,
|
|
50
|
+
inputs: { properties: { path: { type: 'string' },
|
|
51
|
+
message: { type: 'string' } },
|
|
52
|
+
required: %w[path message] },
|
|
53
|
+
trigger_words: %w[git commit message]
|
|
54
|
+
|
|
19
55
|
def commit(path:, message:, **)
|
|
20
|
-
|
|
21
|
-
Runners::Shell.execute(command: "git commit -m '#{safe_msg}'", cwd: path)
|
|
56
|
+
Runners::Shell.execute(command: "git commit -m #{Shellwords.shellescape(message)}", cwd: path)
|
|
22
57
|
end
|
|
23
58
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
59
|
+
definition :push,
|
|
60
|
+
desc: 'Push a git branch to a remote',
|
|
61
|
+
mcp_prefix: 'exec.git.push',
|
|
62
|
+
mcp_category: 'exec_git',
|
|
63
|
+
mcp_tier: :standard,
|
|
64
|
+
inputs: { properties: { path: { type: 'string' },
|
|
65
|
+
remote: { type: 'string' },
|
|
66
|
+
branch: { type: 'string' },
|
|
67
|
+
set_upstream: { type: 'boolean' } },
|
|
68
|
+
required: ['path'] },
|
|
69
|
+
trigger_words: %w[git push remote upstream branch]
|
|
70
|
+
|
|
71
|
+
def push(path:, remote: nil, branch: nil, set_upstream: false, **)
|
|
72
|
+
Runners::Shell.execute(command: push_command(remote: remote, branch: branch, set_upstream: set_upstream), cwd: path)
|
|
27
73
|
end
|
|
28
74
|
|
|
75
|
+
definition :status,
|
|
76
|
+
desc: 'Return git porcelain status and parsed working tree state',
|
|
77
|
+
mcp_prefix: 'exec.git.status',
|
|
78
|
+
mcp_category: 'exec_git',
|
|
79
|
+
mcp_tier: :standard,
|
|
80
|
+
idempotent: true,
|
|
81
|
+
inputs: { properties: { path: { type: 'string' } }, required: ['path'] },
|
|
82
|
+
trigger_words: %w[git status changes working-tree]
|
|
83
|
+
|
|
29
84
|
def status(path:, **)
|
|
30
85
|
result = Runners::Shell.execute(command: 'git status --porcelain', cwd: path)
|
|
31
86
|
return result unless result[:success] # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
@@ -34,13 +89,162 @@ module Legion
|
|
|
34
89
|
result.merge(parsed: parsed)
|
|
35
90
|
end
|
|
36
91
|
|
|
92
|
+
definition :diff,
|
|
93
|
+
desc: 'Show unstaged, staged, or ref-specific git diff output',
|
|
94
|
+
mcp_prefix: 'exec.git.diff',
|
|
95
|
+
mcp_category: 'exec_git',
|
|
96
|
+
mcp_tier: :standard,
|
|
97
|
+
idempotent: true,
|
|
98
|
+
inputs: { properties: { path: { type: 'string' },
|
|
99
|
+
staged: { type: 'boolean' },
|
|
100
|
+
ref: { type: 'string' },
|
|
101
|
+
files: { type: %w[string array] } },
|
|
102
|
+
required: ['path'] },
|
|
103
|
+
trigger_words: %w[git diff patch changes staged]
|
|
104
|
+
|
|
105
|
+
def diff(path:, staged: false, ref: nil, files: nil, **)
|
|
106
|
+
args = ['git diff']
|
|
107
|
+
args << '--cached' if staged
|
|
108
|
+
args << Shellwords.shellescape(ref) if ref
|
|
109
|
+
args << '--' if files
|
|
110
|
+
args << shellwords_join(Array(files)) if files
|
|
111
|
+
Runners::Shell.execute(command: args.join(' '), cwd: path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
definition :branch,
|
|
115
|
+
desc: 'List local or all git branches',
|
|
116
|
+
mcp_prefix: 'exec.git.branch',
|
|
117
|
+
mcp_category: 'exec_git',
|
|
118
|
+
mcp_tier: :standard,
|
|
119
|
+
idempotent: true,
|
|
120
|
+
inputs: { properties: { path: { type: 'string' },
|
|
121
|
+
all: { type: 'boolean' } },
|
|
122
|
+
required: ['path'] },
|
|
123
|
+
trigger_words: %w[git branch branches]
|
|
124
|
+
|
|
125
|
+
def branch(path:, all: false, **)
|
|
126
|
+
command = all ? 'git branch --all' : 'git branch'
|
|
127
|
+
Runners::Shell.execute(command: command, cwd: path)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
definition :log,
|
|
131
|
+
desc: 'Show recent git commit history',
|
|
132
|
+
mcp_prefix: 'exec.git.log',
|
|
133
|
+
mcp_category: 'exec_git',
|
|
134
|
+
mcp_tier: :standard,
|
|
135
|
+
idempotent: true,
|
|
136
|
+
inputs: { properties: { path: { type: 'string' },
|
|
137
|
+
max_count: { type: 'integer' },
|
|
138
|
+
oneline: { type: 'boolean' } },
|
|
139
|
+
required: ['path'] },
|
|
140
|
+
trigger_words: %w[git log history commits]
|
|
141
|
+
|
|
142
|
+
def log(path:, max_count: 20, oneline: true, **)
|
|
143
|
+
args = ['git log', "--max-count=#{Integer(max_count)}"]
|
|
144
|
+
args << '--oneline' if oneline
|
|
145
|
+
Runners::Shell.execute(command: args.join(' '), cwd: path)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
definition :show,
|
|
149
|
+
desc: 'Show a git object, commit, or ref',
|
|
150
|
+
mcp_prefix: 'exec.git.show',
|
|
151
|
+
mcp_category: 'exec_git',
|
|
152
|
+
mcp_tier: :standard,
|
|
153
|
+
idempotent: true,
|
|
154
|
+
inputs: { properties: { path: { type: 'string' },
|
|
155
|
+
ref: { type: 'string' } },
|
|
156
|
+
required: ['path'] },
|
|
157
|
+
trigger_words: %w[git show commit ref]
|
|
158
|
+
|
|
159
|
+
def show(path:, ref: 'HEAD', **)
|
|
160
|
+
Runners::Shell.execute(command: "git show #{Shellwords.shellescape(ref)}", cwd: path)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
definition :create_repo,
|
|
164
|
+
desc: 'Create and clone a GitHub repository using gh',
|
|
165
|
+
mcp_prefix: 'exec.git.create_repo',
|
|
166
|
+
mcp_category: 'exec_git',
|
|
167
|
+
mcp_tier: :standard,
|
|
168
|
+
inputs: { properties: { name: { type: 'string' },
|
|
169
|
+
org: { type: 'string' },
|
|
170
|
+
description: { type: 'string' },
|
|
171
|
+
public: { type: 'boolean' } },
|
|
172
|
+
required: ['name'] },
|
|
173
|
+
trigger_words: %w[github repo create clone]
|
|
174
|
+
|
|
37
175
|
def create_repo(name:, org: 'LegionIO', description: '', public: true, **)
|
|
38
176
|
visibility = public ? '--public' : '--private'
|
|
39
177
|
Runners::Shell.execute(
|
|
40
|
-
command: "gh repo create #{org}/#{name} #{visibility} --description
|
|
178
|
+
command: "gh repo create #{Shellwords.shellescape("#{org}/#{name}")} #{visibility} --description #{Shellwords.shellescape(description)} --clone",
|
|
41
179
|
cwd: Dir.pwd
|
|
42
180
|
)
|
|
43
181
|
end
|
|
182
|
+
|
|
183
|
+
definition :clone,
|
|
184
|
+
desc: 'Clone a git repository with optional depth and branch',
|
|
185
|
+
mcp_prefix: 'exec.git.clone',
|
|
186
|
+
mcp_category: 'exec_git',
|
|
187
|
+
mcp_tier: :standard,
|
|
188
|
+
inputs: { properties: { url: { type: 'string' },
|
|
189
|
+
path: { type: 'string' },
|
|
190
|
+
depth: { type: 'integer' },
|
|
191
|
+
branch: { type: 'string' } },
|
|
192
|
+
required: %w[url path] },
|
|
193
|
+
trigger_words: %w[git clone repository checkout]
|
|
194
|
+
|
|
195
|
+
def clone(url:, path:, depth: nil, branch: nil, **)
|
|
196
|
+
resolved_depth = depth
|
|
197
|
+
resolved_depth ||= Legion::Settings.dig(:fleet, :git, :depth) if defined?(Legion::Settings)
|
|
198
|
+
args = ['git clone']
|
|
199
|
+
args << "--depth #{resolved_depth}" if resolved_depth
|
|
200
|
+
args << "--branch #{Shellwords.shellescape(branch)}" if branch
|
|
201
|
+
args << Shellwords.shellescape(url) << Shellwords.shellescape(path)
|
|
202
|
+
Runners::Shell.execute(command: args.join(' '), cwd: Dir.pwd)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
definition :fetch,
|
|
206
|
+
desc: 'Fetch git remotes and prune deleted branches',
|
|
207
|
+
mcp_prefix: 'exec.git.fetch',
|
|
208
|
+
mcp_category: 'exec_git',
|
|
209
|
+
mcp_tier: :standard,
|
|
210
|
+
idempotent: true,
|
|
211
|
+
inputs: { properties: { path: { type: 'string' },
|
|
212
|
+
remote: { type: 'string' } },
|
|
213
|
+
required: ['path'] },
|
|
214
|
+
trigger_words: %w[git fetch remote prune]
|
|
215
|
+
|
|
216
|
+
def fetch(path:, remote: nil, **)
|
|
217
|
+
cmd = remote ? "git fetch #{Shellwords.shellescape(remote)} --prune" : 'git fetch --all --prune'
|
|
218
|
+
Runners::Shell.execute(command: cmd, cwd: path)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
definition :checkout,
|
|
222
|
+
desc: 'Checkout a git ref or create a branch',
|
|
223
|
+
mcp_prefix: 'exec.git.checkout',
|
|
224
|
+
mcp_category: 'exec_git',
|
|
225
|
+
mcp_tier: :standard,
|
|
226
|
+
inputs: { properties: { path: { type: 'string' },
|
|
227
|
+
ref: { type: 'string' },
|
|
228
|
+
create: { type: 'boolean' } },
|
|
229
|
+
required: %w[path ref] },
|
|
230
|
+
trigger_words: %w[git checkout switch branch]
|
|
231
|
+
|
|
232
|
+
def checkout(path:, ref:, create: false, **)
|
|
233
|
+
flag = create ? ' -b' : ''
|
|
234
|
+
Runners::Shell.execute(command: "git checkout#{flag} #{Shellwords.shellescape(ref)}", cwd: path)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def shellwords_join(values)
|
|
238
|
+
values.map { |value| Shellwords.shellescape(value.to_s) }.join(' ')
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def push_command(remote:, branch:, set_upstream:)
|
|
242
|
+
args = ['git push']
|
|
243
|
+
args << '-u' if set_upstream
|
|
244
|
+
args << Shellwords.shellescape(remote) if remote
|
|
245
|
+
args << Shellwords.shellescape(branch) if branch
|
|
246
|
+
args.join(' ')
|
|
247
|
+
end
|
|
44
248
|
end
|
|
45
249
|
end
|
|
46
250
|
end
|
|
@@ -8,8 +8,25 @@ module Legion
|
|
|
8
8
|
module Exec
|
|
9
9
|
module Runners
|
|
10
10
|
module Shell
|
|
11
|
+
extend Legion::Extensions::Definitions
|
|
11
12
|
extend self
|
|
12
13
|
|
|
14
|
+
def self.trigger_words
|
|
15
|
+
%w[shell command exec execute terminal bash run]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
definition :execute,
|
|
19
|
+
desc: 'Execute an allowlisted shell command in a working directory',
|
|
20
|
+
mcp_prefix: 'exec.shell.execute',
|
|
21
|
+
mcp_category: 'exec_shell',
|
|
22
|
+
mcp_tier: :standard,
|
|
23
|
+
inputs: { properties: { command: { type: 'string' },
|
|
24
|
+
cwd: { type: 'string' },
|
|
25
|
+
timeout: { type: 'integer' },
|
|
26
|
+
env: { type: 'object' } },
|
|
27
|
+
required: ['command'] },
|
|
28
|
+
trigger_words: %w[shell command execute run terminal]
|
|
29
|
+
|
|
13
30
|
def execute(command:, cwd: Dir.pwd, timeout: Helpers::Constants::DEFAULT_TIMEOUT, env: {}, **)
|
|
14
31
|
check = default_sandbox.allowed?(command)
|
|
15
32
|
return { success: false, error: :blocked, reason: check[:reason] } unless check[:allowed]
|
|
@@ -58,6 +75,15 @@ module Legion
|
|
|
58
75
|
}
|
|
59
76
|
end
|
|
60
77
|
|
|
78
|
+
definition :audit,
|
|
79
|
+
desc: 'Return recent shell execution audit entries and aggregate stats',
|
|
80
|
+
mcp_prefix: 'exec.shell.audit',
|
|
81
|
+
mcp_category: 'exec_shell',
|
|
82
|
+
mcp_tier: :standard,
|
|
83
|
+
idempotent: true,
|
|
84
|
+
inputs: { properties: { limit: { type: 'integer' } } },
|
|
85
|
+
trigger_words: %w[audit history shell executions]
|
|
86
|
+
|
|
61
87
|
def audit(limit: 50, **)
|
|
62
88
|
{ success: true, entries: audit_log.entries(limit: limit), stats: audit_log.stats }
|
|
63
89
|
end
|
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
unless defined?(Legion::Extensions::Definitions)
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Definitions
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
remote_invocable: true,
|
|
11
|
+
mcp_exposed: true,
|
|
12
|
+
idempotent: false,
|
|
13
|
+
risk_tier: :standard,
|
|
14
|
+
tags: [],
|
|
15
|
+
requires: [],
|
|
16
|
+
inputs: {},
|
|
17
|
+
outputs: {}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def definition(method_name, **opts)
|
|
21
|
+
own_definitions[method_name.to_sym] = DEFAULTS.merge(opts)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def definitions
|
|
25
|
+
own_definitions.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def definition_for(method_name)
|
|
29
|
+
definitions[method_name.to_sym]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def own_definitions
|
|
35
|
+
@own_definitions ||= {}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
4
42
|
require_relative 'exec/version'
|
|
5
43
|
require_relative 'exec/helpers/constants'
|
|
6
44
|
require_relative 'exec/helpers/sandbox'
|
|
@@ -8,9 +46,11 @@ require_relative 'exec/helpers/result_parser'
|
|
|
8
46
|
require_relative 'exec/helpers/audit_log'
|
|
9
47
|
require_relative 'exec/helpers/checkpoint'
|
|
10
48
|
require_relative 'exec/helpers/worktree'
|
|
49
|
+
require_relative 'exec/helpers/repo_materializer'
|
|
11
50
|
require_relative 'exec/runners/shell'
|
|
12
51
|
require_relative 'exec/runners/git'
|
|
13
52
|
require_relative 'exec/runners/bundler'
|
|
53
|
+
require_relative 'exec/runners/filesystem'
|
|
14
54
|
require_relative 'exec/client'
|
|
15
55
|
|
|
16
56
|
module Legion
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-exec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -131,10 +131,12 @@ files:
|
|
|
131
131
|
- lib/legion/extensions/exec/helpers/audit_log.rb
|
|
132
132
|
- lib/legion/extensions/exec/helpers/checkpoint.rb
|
|
133
133
|
- lib/legion/extensions/exec/helpers/constants.rb
|
|
134
|
+
- lib/legion/extensions/exec/helpers/repo_materializer.rb
|
|
134
135
|
- lib/legion/extensions/exec/helpers/result_parser.rb
|
|
135
136
|
- lib/legion/extensions/exec/helpers/sandbox.rb
|
|
136
137
|
- lib/legion/extensions/exec/helpers/worktree.rb
|
|
137
138
|
- lib/legion/extensions/exec/runners/bundler.rb
|
|
139
|
+
- lib/legion/extensions/exec/runners/filesystem.rb
|
|
138
140
|
- lib/legion/extensions/exec/runners/git.rb
|
|
139
141
|
- lib/legion/extensions/exec/runners/shell.rb
|
|
140
142
|
- lib/legion/extensions/exec/version.rb
|