aigency 0.2.0 → 0.4.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +12 -13
  4. data/lib/aigency/aigent/current_time.rb +33 -0
  5. data/lib/aigency/aigent/shell.rb +108 -0
  6. data/lib/aigency/aigent.rb +117 -65
  7. data/lib/aigency/delegator.rb +11 -0
  8. data/lib/aigency/plugin/shell.rb +11 -0
  9. data/lib/aigency/plugin.rb +39 -0
  10. data/lib/aigency/rake.rb +13 -0
  11. data/lib/aigency/sandbox/exec.rb +21 -0
  12. data/lib/aigency/sandbox.rb +22 -0
  13. data/lib/aigency/shell.rb +5 -5
  14. data/lib/aigency/tempdir/helpers.rb +15 -0
  15. data/lib/aigency/tempdir/list.rb +17 -0
  16. data/lib/aigency/tempdir/read.rb +32 -0
  17. data/lib/aigency/tempdir/size.rb +27 -0
  18. data/lib/aigency/tempdir/write.rb +28 -0
  19. data/lib/aigency/tempdir.rb +19 -0
  20. data/lib/aigency/tool.rb +5 -0
  21. data/lib/aigency/usability.rb +1 -0
  22. data/lib/aigency/version.rb +1 -1
  23. data/lib/aigency/workspace/cat.rb +11 -0
  24. data/lib/aigency/workspace/cmd.rb +59 -0
  25. data/lib/aigency/workspace/copy_tempdir_file.rb +28 -0
  26. data/lib/aigency/workspace/cp.rb +11 -0
  27. data/lib/aigency/workspace/diff.rb +11 -0
  28. data/lib/aigency/workspace/grep.rb +15 -0
  29. data/lib/aigency/workspace/head.rb +11 -0
  30. data/lib/aigency/workspace/helpers.rb +40 -3
  31. data/lib/aigency/workspace/ls.rb +11 -0
  32. data/lib/aigency/workspace/mkdir.rb +11 -0
  33. data/lib/aigency/workspace/mv.rb +11 -0
  34. data/lib/aigency/workspace/rm.rb +11 -0
  35. data/lib/aigency/workspace/tail.rb +11 -0
  36. data/lib/aigency/workspace/wc.rb +13 -0
  37. data/lib/aigency/workspace/write.rb +36 -0
  38. data/lib/aigency/workspace.rb +20 -7
  39. data/lib/aigency.rb +16 -13
  40. data/scaffold/{Rakefile → aigent/aigent.rake} +2 -11
  41. data/scaffold/enlist/.env.example +4 -0
  42. data/scaffold/{.gitignore → enlist/.gitignore} +1 -0
  43. data/scaffold/{Gemfile → enlist/Gemfile} +1 -1
  44. data/scaffold/enlist/README.md +90 -0
  45. data/scaffold/enlist/Rakefile +9 -0
  46. data/scaffold/enlist/bin/aigent +11 -0
  47. data/scaffold/{config → enlist/config}/schedule.rb +5 -0
  48. data/scaffold/sandbox/Dockerfile +37 -0
  49. data/scaffold/sandbox/docker-compose.yml +37 -0
  50. data/scaffold/sandbox/squid.conf +25 -0
  51. data/scaffold/sandbox/ssh_config +2 -0
  52. metadata +114 -16
  53. data/lib/aigency/tools.rb +0 -55
  54. data/lib/aigency/workspace/directory_deleter.rb +0 -23
  55. data/lib/aigency/workspace/directory_reader.rb +0 -33
  56. data/lib/aigency/workspace/file_deleter.rb +0 -22
  57. data/lib/aigency/workspace/file_reader.rb +0 -27
  58. data/lib/aigency/workspace/file_writer.rb +0 -26
  59. data/scaffold/README.md +0 -74
  60. data/scaffold/config/aigent.yml +0 -6
  61. /data/scaffold/{IDENTITY.md → enlist/IDENTITY.md} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b9b5b24d49d87f69a17cec9420b4815db4646b37d8759951e4ced8431765800
4
- data.tar.gz: 04ecc1ddb1433cfcd2c18b7c3af420df7dd52a417ef6ab90a2a3de35adb2b99b
3
+ metadata.gz: 4fce24d7da34c83fac5e0d3ce82d8bb79c31bede2b99ddfacec3b7efa4d157e3
4
+ data.tar.gz: 1a36424698a8a9b4bba0a0a0cf6a14e9b4cf8318a40a166893be3e093c909f9e
5
5
  SHA512:
6
- metadata.gz: 6ab68e574725ebb40bad60ff6e807e480733c91f406d75c8e45b6678e16952e0cd67dde124df13ef9036ef82437bcdae97a4182169cbc615b30116f29c89667c
7
- data.tar.gz: 6a4e0866fc61258b3fee11e76baeadc72ade5d5d66b87f4d8d41ad2127ac27a50a5f7ef394d0d940e03d7cc389e567f4d7e814ebb1792ef56c125904a75c4dcc
6
+ metadata.gz: 9ce7989e062eb10afbb767d3e61f297ddf521f0b7c5fe0459c8021428843e6b05069db1ae88afbda7c29ab4ea0ca9f9f277b7472393f085f87dc5b36866cd786
7
+ data.tar.gz: 9a7a1efaad6467200964715b3db7920b52cc1cc39c7292fd8a52a2b4d4d023b16502ea511ee6824664970fecbdab19b2529dd95a2a5d0a8dfc49f9c53a5e062d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 0.4.0 - October 21, 2026
2
+ - Config file directory
3
+ - Add openssh to sandbox
4
+ - Sandbox configuration
5
+ - Tempdir tools
6
+ - Aigent plugin
7
+ - Proxy environment variables
8
+ - apk update && apk upgrade
9
+ - Plugins add rake files
10
+ - Aigent setup in README
11
+ - Rearrange sandbox scaffold
12
+ - Add bin/aigent to aigent directory
13
+ - Fix grep spec for BusyBox
14
+ - CurrentTime tool
15
+ - Dockerfile packages and mise setup
16
+ - Instructions subcommand
17
+ - Aigent#stream errors write to error.log
18
+ - Aigent stream command
19
+ - Remove adduser recommendation
20
+ - Clarify Docker requirement
21
+ - Plugin shells
22
+ - Tempdir file size management
23
+ - CopyTempdirFile tool
24
+ - Aigent plugin setup
25
+ - Update chat calls for RubyLLM 2.0
26
+ - Use the chat completions protocol
27
+
28
+ ## 0.3.0 - October 9, 2026
29
+ - New plugin architecture
30
+ - Workspace and Sandbox plugins
31
+
1
32
  ## 0.2.0 - October 2, 2026
2
33
  - Log token usage
3
34
  - Stale code
data/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  A framework for creating AI agents with RubyLLM.
4
4
 
5
+ ## Requirements
6
+
7
+ - Ruby 3.2+
8
+ - Docker 29+ (required for sandbox)
9
+
10
+ (Note: Aigency is not tested on Windows. Some features are known to be Windows-incompatible.)
11
+
5
12
  ## Installation
6
13
 
7
14
  Install the gem from the command line:
@@ -20,28 +27,20 @@ gem 'aigency'
20
27
 
21
28
  ### Creating a New Aigent
22
29
 
23
- The recommended way to create an aigent is to give it a dedicated system user.
24
-
25
- ```bash
26
- useradd aigent_user
27
- sudo su - aigent_user
28
- ```
29
-
30
- Once you're logged in as the aigent user, create the aigent directory:
31
-
32
30
  ```bash
33
31
  aigency enlist
34
32
  cd aigent
35
33
  bundle install
36
34
  rake setup
35
+ cp .env.example .env
37
36
  ```
38
37
 
39
- The bare minimum configuration you need to do is setting `openai_api_base` and `openai_api_key` in `config/aigent.yml`.
38
+ The bare minimum configuration you need to do is setting `OPENAI_API_BASE` and `OPENAI_API_KEY` in `.env`.
40
39
 
41
- Test the aigent with with the `rake say` task.
40
+ Test the aigent with the `say` command.
42
41
 
43
42
  ```bash
44
- rake say["How many fibers are intertwined in a shredded wheat biscuit?"]
43
+ bin/aigent say "How many fibers are intertwined in a shredded wheat biscuit?"
45
44
  ```
46
45
 
47
- See the aigent's README (scaffold/README.md in this codebase) for more information.
46
+ See the aigent's README for more information (`README.md` in your aigent directory or `scaffold/enlist/README.md` in this codebase).
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Aigency
6
+ class Aigent
7
+ class CurrentTime < Aigency::Tool
8
+ description 'Get the current time'
9
+
10
+ parameters do
11
+ any_of :format, description: "Date/time format" do
12
+ string enum: %w[iso8601 unix]
13
+ 'iso8601'
14
+ end
15
+ end
16
+
17
+ def execute(format: 'iso8601')
18
+ aigent.log.puts "[Aigent::CurrentTime] #{format}"
19
+ now = DateTime.now
20
+ case format
21
+ when 'iso8601'
22
+ { success: true, current_time: now.iso8601 }
23
+ when 'unix'
24
+ { success: true, current_time: now.to_time.to_i }
25
+ else
26
+ { success: false, error: "Invalid format: #{format}" }
27
+ end
28
+ rescue StandardError => e
29
+ { success: false, error: "[#{e.class}] #{e.message}"}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Aigency
6
+ class Aigent
7
+ # Commands that can be executed in aigent directories.
8
+ #
9
+ class Shell < Thor
10
+ include Delegator
11
+
12
+ desc "say PROMPT", "Send a prompt to the aigent"
13
+ def say(prompt)
14
+ aigent.stream prompt
15
+ end
16
+
17
+ desc "load FILE", "Load a prompt from a file"
18
+ def load(file)
19
+ aigent.load filename
20
+ end
21
+
22
+ desc "setup", "Initialize aigent plugins"
23
+ def setup
24
+ aigent.setup
25
+ end
26
+
27
+ desc "tools", "Get a list of available tools"
28
+ def tools
29
+ puts aigent.tools.map(&:to_s).sort
30
+ end
31
+
32
+ desc "status", "Get the aigent's current status"
33
+ def status
34
+ puts "Name: #{aigent.config('aigent')['name']}"
35
+ puts "Busy: #{aigent.busy?}"
36
+ if aigent.busy?
37
+ puts "Process: #{File.read(aigent.data('lock'))}"
38
+ end
39
+ end
40
+
41
+ desc "instructions", "List base instructions"
42
+ long_desc <<~DESC
43
+ Show all instructions that will be given to the aigent before the user
44
+ prompt. They typically include the IDENTITY.md file and any prompts injected
45
+ by installed plugins.
46
+ DESC
47
+ def instructions
48
+ puts aigent.instructions
49
+ end
50
+
51
+ desc "stream", "Tail the currently active chat stream"
52
+ option :follow, type: :boolean, aliases: :f, desc: "Follow the stream (tail -f)"
53
+ option :lines, type: :numeric, aliases: :n, desc: "Number of lines (tail -n)", default: 10
54
+ long_desc <<~DESC
55
+ This is useful for monitoring non-interactive chat streams, e.g., chat sessions
56
+ that were started by cron jobs or rake tasks. It runs `tail` on the current chat
57
+ file in the aigent's data/chats folder.
58
+ DESC
59
+ def stream
60
+ if aigent.busy?
61
+ file = Dir.glob(aigent.data('chats', '*.txt')).max { |file| File.mtime(file).to_i }
62
+ cmd = ['tail', file, '-n', options[:lines].to_s]
63
+ cmd.push '-f' if options[:follow]
64
+ puts "(#{file})"
65
+ exec *cmd
66
+ else
67
+ puts "No active chat stream."
68
+ end
69
+ end
70
+
71
+ desc "kill", "Kill a blocking process"
72
+ long_desc <<~DESC
73
+ Kill the process that owns an exclusive lock on the aigent's data/lock file.
74
+ DESC
75
+ def kill
76
+ if aigent.busy?
77
+ pid = File.read(aigent.data('lock')).to_i
78
+ puts "Killing process #{pid}"
79
+ Process.kill 'TERM', pid
80
+ else
81
+ puts "No blocking process exists."
82
+ end
83
+ end
84
+
85
+ class << self
86
+ def mounted? = false
87
+
88
+ def start(args = ARGV, config = {})
89
+ return super if mounted?
90
+ build('.').start(args, config)
91
+ end
92
+
93
+ def build(directory)
94
+ Class.new(self) do
95
+ def self.mounted? = true
96
+ Aigent.new(directory).plugins.each do |plugin|
97
+ next unless plugin.shell
98
+ if all_commands.key?(plugin.name)
99
+ raise "Plugin '#{plugin.name}' cannot register CLI command '#{plugin.name}': already taken"
100
+ end
101
+ register(plugin.shell, plugin.name, "#{plugin.name} [SUBCOMMAND ...ARGS]", "#{plugin.name} plugin commands")
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -2,16 +2,19 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'yaml'
5
+ require 'set'
5
6
 
6
7
  module Aigency
7
8
  class Aigent
9
+ require 'aigency/aigent/current_time'
10
+ require 'aigency/aigent/shell'
11
+
8
12
  # @return [IO]
9
13
  attr_reader :log
10
14
 
11
15
  def initialize(directory)
12
16
  @base_directory = File.absolute_path(directory)
13
17
  @log = $stdout
14
- load_requires
15
18
  end
16
19
 
17
20
  # @return [String]
@@ -38,30 +41,49 @@ module Aigency
38
41
  # Initialize a RubyLLM::Chat.
39
42
  #
40
43
  # @param extra [Array<String>] Additional instructions
41
- # @param tools [Array<Class<Tool>>] Available tools (default is all registered tools)
44
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
42
45
  # @return [RubyLLM::Chat]
43
- def brief(*extra, tools: Tools.registry)
44
- chat = rubyllm_context.chat(provider: :openai)
45
- tools.each { |klass| chat.with_tool(klass.new(self)) }
46
- chat.with_instructions(build_instructions(extra), append: true)
47
- chat.with_params(max_tokens: max_tokens)
46
+ def brief(*extra, except: [])
47
+ chat = rubyllm_context.chat(provider: :openai, protocol: :chat_completions)
48
+ (tools - except).each { |klass| chat.with_tools(klass.new(self)) }
49
+ chat.with_instructions(build_instructions(extra).join, append: true)
50
+ chat.with_provider_options(max_tokens: max_tokens)
48
51
  end
49
52
 
50
- def load filename, *extra, tools: Tools.registry, out: $stdout
51
- stream File.read(filename), *extra, tools: tools, out: out
53
+ # Prompt a chat with a file.
54
+ #
55
+ # @param filename [String]
56
+ # @param extra [Array<String>] Additional instructions
57
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
58
+ # @param out [IO]
59
+ # @return [RubyLLM::Chat]
60
+ def load filename, *extra, except: [], out: $stdout
61
+ stream File.read(filename), *extra, except: except, out: out
52
62
  end
53
63
 
54
- def stream text, *extra, with: [], tools: Tools.registry, out: $stdout
64
+ # Prompt a chat.
65
+ #
66
+ # @param text [String]
67
+ # @param extra [Array<String>] Additional instructions
68
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
69
+ # @param out [IO]
70
+ # @return [RubyLLM::Chat]
71
+ def stream text, *extra, attach: [], except: [], out: $stdout
55
72
  original_log = @log
56
73
  @log = out
57
- response = brief(*extra, tools: tools).ask text, with: with do |chunk|
58
- @log.print chunk.thinking&.text
59
- @log.print chunk.content
60
- end
74
+ response = brief(*extra, except: except).ask text, with: attach #do |chunk|
75
+ # @log.print chunk.thinking&.text
76
+ #end
77
+ @log.puts response.content
61
78
  @log.puts "Input tokens: #{response.tokens.input}"
62
79
  @log.puts "Output tokens: #{response.tokens.output}"
63
- @log = original_log
64
80
  response
81
+ rescue StandardError => e
82
+ message = "[#{e.class}] #{e.message}\n" + e.backtrace.map { |line| " #{line}\n" }.join
83
+ @log.puts message
84
+ File.write data('log', 'error.log'), "#{DateTime.now.iso8601} #{message}", mode: 'a'
85
+ ensure
86
+ @log = original_log
65
87
  end
66
88
 
67
89
  # @param name [String]
@@ -70,43 +92,16 @@ module Aigency
70
92
  configs[name] ||= read_config(name)
71
93
  end
72
94
 
73
- # Initialize the aigent's tool configurations.
95
+ # Initialize the aigent's plugin configurations.
74
96
  #
75
- # @param only [Array<String>] Limit the tools being configured
76
- # @param overwrite [Boolean] Replace existing configs if true
77
- def setup(only: [], overwrite: false)
78
- only = [only].flatten.compact
79
- config_count = 0
80
- Tools.config_map.each do |name, text|
81
- next unless only.empty? || only.include?(name)
82
-
83
- dir = directory('config')
84
- FileUtils.mkdir_p dir
85
- path = File.join(dir, "#{name}.yml")
86
- if overwrite || !File.file?(path)
87
- doing = File.file?(path) ? 'Overwriting' : 'Writing new'
88
- log.puts "#{doing} config for #{name}"
89
- File.write path, text
90
- config_count += 1
91
- else
92
- log.puts "Config for #{name} exists, skipping"
93
- end
94
- end
95
- dir_count = 0
96
- Tools.directories.each do |path|
97
- absolute = directory(path)
98
- next if File.directory?(absolute)
99
-
100
- FileUtils.mkdir_p absolute
101
- dir_count += 1
102
- end
103
- log.puts "#{config_count} config files initialized."
104
- log.puts "#{dir_count} directories created."
97
+ # @return [void]
98
+ def setup
99
+ plugins.each { |plugin| boot plugin }
105
100
  end
106
101
 
107
102
  # Run code in an exclusive lock
108
103
  #
109
- def lock(&block)
104
+ def block(&block)
110
105
  File.open(data('lock'), 'w') do |file|
111
106
  file.flock(File::LOCK_EX)
112
107
  file.write(Process.pid)
@@ -114,6 +109,8 @@ module Aigency
114
109
  block&.call
115
110
  end
116
111
  end
112
+ alias lock block
113
+ alias flock block
117
114
 
118
115
  # Run code if an exclusive lock is available or skip if a lock exists
119
116
  #
@@ -138,12 +135,56 @@ module Aigency
138
135
  end
139
136
  alias busy? locked?
140
137
 
138
+ # @return [String]
141
139
  def instructions
142
- @instructions ||= build_instructions([])
140
+ @instructions ||= build_instructions([]).join("\n\n")
141
+ end
142
+
143
+ # @return [Array<Plugin>]
144
+ def plugins
145
+ config('aigent').fetch('plugins', [])
146
+ .unshift('aigent')
147
+ .uniq
148
+ .map { |name| Plugin.registry[name] }
149
+ end
150
+
151
+ def tools
152
+ plugins.flat_map(&:tools).uniq
143
153
  end
144
154
 
145
155
  private
146
156
 
157
+ # @param plugin [Plugin]
158
+ def boot(plugin)
159
+ plugin.directories.each do |dir|
160
+ absolute = File.absolute_path(dir, directory)
161
+ if File.directory?(absolute)
162
+ log.puts "Directory #{dir} exists, skipping"
163
+ else
164
+ log.puts "Creating #{dir}"
165
+ FileUtils.mkdir_p absolute
166
+ end
167
+ end
168
+ unless plugin.config.empty?
169
+ config_file = File.absolute_path(File.join('config', "#{plugin.name}.yml"), directory)
170
+ if File.exist?(config_file)
171
+ log.puts "File config/#{plugin.name}.yml exists, skipping"
172
+ else
173
+ log.puts "Creating config/#{plugin.name}.yml"
174
+ File.write config_file, plugin.config
175
+ end
176
+ end
177
+ plugin.files.each do |file, content|
178
+ absolute = File.absolute_path(file, directory)
179
+ if File.exist?(absolute)
180
+ log.puts "File #{file} exists, skipping"
181
+ else
182
+ log.puts "Creating #{file}"
183
+ File.write absolute, content
184
+ end
185
+ end
186
+ end
187
+
147
188
  def configs
148
189
  @configs ||= {}
149
190
  end
@@ -157,7 +198,9 @@ module Aigency
157
198
  end
158
199
  full_path = File.join(directory, 'config', filename)
159
200
  if File.file?(full_path)
160
- YAML.load_file(full_path)
201
+ content = File.read(full_path)
202
+ .gsub(/\${([^}]+)}/) { ENV.fetch($1) }
203
+ YAML.load(content)
161
204
  else
162
205
  warn "Config name #{name} not found at #{full_path}"
163
206
  {}
@@ -176,26 +219,17 @@ module Aigency
176
219
  @base_instructions ||= identity_file? ? File.read(identity_file) : "You are a helpful AI assistant."
177
220
  end
178
221
 
222
+ # @return [Array<String>]
179
223
  def build_instructions(extra)
180
- [base_instructions].concat tool_prompts
181
- .push "Today's date is #{DateTime.now.strftime('%B %e, %Y')}."
182
- .concat extra
183
- .join("\n\n")
224
+ [base_instructions].concat(tool_prompts)
225
+ .concat(extra)
184
226
  end
185
227
 
186
228
  def tool_prompts
187
- Tools.config_map
188
- .keys
189
- .map { |name| config(name)['prompt'] }
190
- .compact
191
- end
192
-
193
- def load_requires
194
- read_requires.each { |name| require name }
195
- end
196
-
197
- def read_requires
198
- config('aigent')['require'] || []
229
+ # @todo Should aigents load prompts from config files that do not have
230
+ # a corresponding plugin?
231
+ plugins.map { |plugin| config(plugin.name)['prompt'] }
232
+ .compact
199
233
  end
200
234
 
201
235
  def max_tokens
@@ -208,5 +242,23 @@ module Aigency
208
242
  config.openai_api_key = config('aigent')['openai_api_key']
209
243
  end
210
244
  end
245
+
246
+ Plugin.register Plugin.new(
247
+ 'aigent',
248
+ tools: [CurrentTime],
249
+ directories: ['config', 'data/chats', 'data/log'],
250
+ rakefile: File.join(GEM_PATH, 'scaffold', 'aigent', 'aigent.rake'),
251
+ config: <<~FILE
252
+ name: Aigent
253
+ openai_api_base: ${OPENAI_API_BASE}
254
+ openai_api_key: ${OPENAI_API_KEY}
255
+ # max_tokens: 65536
256
+ plugins:
257
+ # You should only need either a workspace or a sandbox, not both. If you
258
+ # enable one, it's recommended that you disable the other.
259
+ # - sandbox
260
+ - workspace
261
+ FILE
262
+ )
211
263
  end
212
264
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Delegator
5
+ module_function
6
+
7
+ def aigent
8
+ @aigent ||= Aigent.new(Dir.pwd)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Aigency
6
+ class Plugin
7
+ class Shell < Thor
8
+ include Delegator
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ class Plugin
5
+ require 'aigency/plugin/shell'
6
+
7
+ attr_reader :name, :config, :tools, :directories, :files, :rakefile, :shell
8
+
9
+ # @param name [String]
10
+ # @param tools [Array<Class<Tool>>]
11
+ # @param config [String]
12
+ # @param directories [Array<String>]
13
+ # @param files [Hash{String => String}]
14
+ def initialize name, tools: [], config: '', directories: [], files: {}, rakefile: nil, shell: nil
15
+ raise "A plugin named '#{name}' already exists" if self.class.registry.key?(name)
16
+ @name = name
17
+ @config = config
18
+ @tools = tools
19
+ @directories = directories
20
+ @files = files
21
+ @rakefile = rakefile
22
+ @shell = shell
23
+ self.class.registry[name] = self
24
+ end
25
+
26
+ def self.registry
27
+ @registry ||= {}
28
+ end
29
+
30
+ # @param plugin [Plugin]
31
+ def self.register plugin
32
+ registry[plugin.name] = plugin
33
+ end
34
+
35
+ def self.create(...)
36
+ register new(...)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Rake
5
+ def self.define(directory = '.')
6
+ aigent = Aigent.new(directory)
7
+ aigent.plugins
8
+ .map(&:rakefile)
9
+ .compact
10
+ .each { |rakefile| ::Rake.load_rakefile(rakefile) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Aigency
6
+ module Sandbox
7
+ class Exec < Aigency::Tool
8
+ description 'Run a shell command in your sandbox'
9
+
10
+ parameters do
11
+ string :command, description: "The complete command to execute", required: true
12
+ end
13
+
14
+ def execute(command:)
15
+ aigent.log.puts "[Docker::Compose::Exec] #{command}"
16
+ output, error, result = Open3.capture3('docker', 'compose', 'exec', 'app', 'sh', '-c', command, chdir: aigent.directory)
17
+ { success: result.success?, stdout: output, stderr: error }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Sandbox
5
+ require 'aigency/sandbox/exec'
6
+
7
+ Plugin.register Plugin.new(
8
+ 'sandbox',
9
+ tools: [Sandbox::Exec],
10
+ directories: ['sandbox', 'data/sandbox', 'data/sandbox_ssh'],
11
+ files: {
12
+ 'sandbox/Dockerfile' => File.read(File.join(GEM_PATH, 'scaffold', 'sandbox', 'Dockerfile')),
13
+ 'sandbox/squid.conf' => File.read(File.join(GEM_PATH, 'scaffold', 'sandbox', 'squid.conf')),
14
+ 'docker-compose.yml' => File.read(File.join(GEM_PATH, 'scaffold', 'sandbox', 'docker-compose.yml')),
15
+ 'data/sandbox_ssh/config' => File.read(File.join(GEM_PATH, 'scaffold', 'sandbox', 'ssh_config'))
16
+ },
17
+ config: <<~FILE
18
+ prompt: "The sandbox tools provide access to a Docker container."
19
+ FILE
20
+ )
21
+ end
22
+ end
data/lib/aigency/shell.rb CHANGED
@@ -4,12 +4,12 @@ require 'fileutils'
4
4
  require 'thor'
5
5
 
6
6
  module Aigency
7
+ # Top-level aigency commands for creating aigents.
8
+ #
7
9
  class Shell < Thor
8
10
  map %i[new] => :enlist
9
- desc "enlist [DIRECTORY]", "Enable the current user as an aigent"
10
- option :directory, type: :string, desc: 'Aigent directory', default: File.join(Dir.home, 'aigent')
11
- def enlist
12
- directory = options[:directory]
11
+ desc "enlist [DIRECTORY]", "Create an aigent directory"
12
+ def enlist directory = 'aigent'
13
13
  puts "Installing aigent at #{directory}"
14
14
  if File.exist?(directory)
15
15
  if !File.directory?(directory)
@@ -20,7 +20,7 @@ module Aigency
20
20
  end
21
21
  end
22
22
  FileUtils.mkdir(directory) unless File.directory?(directory)
23
- FileUtils.copy_entry(File.join(GEM_PATH, 'scaffold'), directory)
23
+ FileUtils.copy_entry(File.join(GEM_PATH, 'scaffold', 'enlist'), directory)
24
24
  end
25
25
  end
26
26
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Tempdir
5
+ module Helpers
6
+ module_function
7
+
8
+ def expand_in_tempdir path
9
+ absolute = File.absolute_path(path, PATH)
10
+ raise PathError, "#{path} is not relative to tempdir" unless absolute.start_with?(PATH)
11
+ absolute
12
+ end
13
+ end
14
+ end
15
+ end