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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0e24d56db8e7444e6ea2af9d29b91a509d95aabac9f057dcc6dd4ec8c2c886e
4
- data.tar.gz: 15efcf34aae86cd254501fd976511d77de01acf5202b8bd0f87156f0cd03d46d
3
+ metadata.gz: 99f24f743720335d307955a43fafde85fb5eb89e40f169d7b800c48fe47eb24f
4
+ data.tar.gz: 816a4d2cbb3a04fa7dd1c62f689896a4671ad1042275bad1abd68d8cc0911a6c
5
5
  SHA512:
6
- metadata.gz: 7381db9bc39961927fff09796a591b3f18e104ade241f3a174bb9a3047a2737af8332cdd3b98c407bc42a5eb29eb6a5c47b53e5e76a9b486f41ed9050480c182
7
- data.tar.gz: 7a73b0059b112190168c19e60b1aff03015fda95ad09de69c88f6f6972f87cefb2d5949b329b7d1fdf5a2a0b2af2167f02625d6f6e331871579881f5173d8369
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 three runners:
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)
@@ -13,7 +13,7 @@ module Legion
13
13
 
14
14
  BASE_ALLOWED_COMMANDS = %w[
15
15
  bundle git gh ruby rspec rubocop ls cat mkdir cp mv rm touch echo wc head tail
16
- python3 pip3
16
+ pwd python3 pip3
17
17
  ].freeze
18
18
 
19
19
  VENV_ALLOWED_COMMANDS = [
@@ -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
- _stdout, stderr, status = Open3.capture3('git', 'worktree', 'add', path, '-b', branch, base_ref)
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
- _stdout, stderr, status = Open3.capture3('git', 'worktree', 'remove', path, '--force')
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
- stdout, _stderr, _status = Open3.capture3('git', 'worktree', 'list', '--porcelain')
40
- worktrees = parse_worktree_list(stdout)
41
- { success: true, worktrees: worktrees }
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 = Legion::Settings.dig(:worktree, :base_dir) if defined?(Legion::Settings)
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).join(' ')}"
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
- safe_msg = message.gsub("'", "\\'")
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
- def push(path:, remote: 'origin', branch: 'main', set_upstream: false, **)
25
- cmd = set_upstream ? "git push -u #{remote} #{branch}" : 'git push'
26
- Runners::Shell.execute(command: cmd, cwd: path)
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 '#{description}' --clone",
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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Exec
6
- VERSION = '0.1.8'
6
+ VERSION = '0.1.10'
7
7
  end
8
8
  end
9
9
  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.8
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