elelem-tools 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0074cea1a49859fbbcd424c4a30cb791b22e49b3a023484386644f50d8590925
4
- data.tar.gz: f4fd8cacf3d4e8509aac90aeadb0515757ce429a1390a9138f344919983f3c0e
3
+ metadata.gz: 4b0f5e3a9dd3ff1d691507d3eaffb33f083d57eb607ce2be6af72862820d56ad
4
+ data.tar.gz: 918f507fd626ed799f2469412ed990061ba51739363b3296456151691418a0de
5
5
  SHA512:
6
- metadata.gz: 33be226b5096e099d4c1e7c2e32af8f3ae1c0d581ee82c7250585b439a60972da0b396a26ec258ad6130140ed6b66138672799b48a26b575ffa17f1934fc53bd
7
- data.tar.gz: 62e813b53649bce2a34abed94160dda5ba0d1b1e2876977fcce821c4fd5241cda569f2954bdc3b5f42193316c8ef8a2847faf3e8414bfdcbeec1427ab5e11508
6
+ metadata.gz: 3f1fcbc49703bb6fbf87d96593fe5b7e5be04833c9e8af3f4cc27f9a9fc395fbb5b912b57a00eef9d82b9e5bdcf425d1f58424f9d26d384084eaa7ca9f7c7dc5
7
+ data.tar.gz: fc5bff90cd435be07a865803f5f269a50b2b98ba4950934031ce19d9131a1ed7e3ee30a4908784221f87e956062a9bf590ef7b4e3afa25f52fe80a24627d0d0c
@@ -0,0 +1,61 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions: {}
9
+
10
+ concurrency:
11
+ group: release
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
21
+ with:
22
+ persist-credentials: false
23
+ - uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
24
+ with:
25
+ ruby-version: "4.0"
26
+ bundler-cache: true
27
+ - run: bin/test
28
+ - run: bin/lint
29
+
30
+ release-please:
31
+ needs: test
32
+ runs-on: ubuntu-latest
33
+ permissions:
34
+ contents: write
35
+ issues: write
36
+ pull-requests: write
37
+ outputs:
38
+ release_created: ${{ steps.release.outputs.release_created }}
39
+ steps:
40
+ - id: release
41
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
42
+ with:
43
+ config-file: release-please-config.json
44
+ manifest-file: .release-please-manifest.json
45
+
46
+ publish:
47
+ needs: release-please
48
+ if: needs.release-please.outputs.release_created == 'true'
49
+ runs-on: ubuntu-latest
50
+ permissions:
51
+ contents: write
52
+ id-token: write
53
+ steps:
54
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
55
+ with:
56
+ persist-credentials: false
57
+ - uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
58
+ with:
59
+ ruby-version: "4.0"
60
+ bundler-cache: true
61
+ - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
data/.rubocop.yml ADDED
@@ -0,0 +1,62 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 4.0
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "pkg/**/*"
7
+ - "bin/**/*"
8
+ - "vendor/**/*"
9
+ - "fixtures/**/*"
10
+
11
+ Metrics:
12
+ Enabled: false
13
+
14
+ Naming/PredicateMethod:
15
+ AllowedMethods:
16
+ - call
17
+ - run
18
+ - summarize
19
+ - prompt
20
+
21
+ Naming/MethodParameterName:
22
+ AllowedNames:
23
+ - me
24
+ - id
25
+ - as
26
+ - io
27
+
28
+ Style/GlobalVars:
29
+ Exclude:
30
+ - "spec/**/*"
31
+
32
+ Lint/EmptyBlock:
33
+ Exclude:
34
+ - "spec/**/*"
35
+
36
+ Lint/UselessDefaultValueArgument:
37
+ Enabled: false
38
+
39
+ Lint/RedundantRequireStatement:
40
+ Enabled: false
41
+
42
+ Lint/UnusedBlockArgument:
43
+ IgnoreEmptyBlocks: true
44
+ AllowUnusedKeywordArguments: true
45
+
46
+ Style/Documentation:
47
+ Enabled: false
48
+
49
+ Style/FormatStringToken:
50
+ Enabled: false
51
+
52
+ Style/FrozenStringLiteralComment:
53
+ Enabled: true
54
+
55
+ Style/StringLiterals:
56
+ EnforcedStyle: double_quotes
57
+
58
+ Style/StringLiteralsInInterpolation:
59
+ EnforcedStyle: double_quotes
60
+
61
+ Layout/LineLength:
62
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/elelem-sh/tools/compare/v0.2.0...v0.3.0) (2026-11-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * a built command skips its prompt only after a person approved the tool that built it (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([0f47899](https://github.com/elelem-sh/tools/commit/0f4789942fe36b780beab507f2d406286852ca1e))
9
+ * a command a tool builds itself does not ask again after the tool was approved (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([097b5b9](https://github.com/elelem-sh/tools/commit/097b5b98411857bd2b94ea439a41ce175ac889cd))
10
+ * a failed verify check is an error, so the model sees ok: false ([acddd22](https://github.com/elelem-sh/tools/commit/acddd22fe00653bf5f09110dc4d84434f17e8f37))
11
+ * a failing git command is an error, and grep with no matches is not (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([fedc829](https://github.com/elelem-sh/tools/commit/fedc8295646dfed601cc2e9120434738b9542ac1))
12
+ * ask before running a tool whose permission policy is misspelled ([4adf5f6](https://github.com/elelem-sh/tools/commit/4adf5f6d22230dca701cdd72b8ee45efc346655a))
13
+ * edit counts overlapping matches and edits files that are not UTF-8 (elelem-sh/elelem.sh[#21](https://github.com/elelem-sh/tools/issues/21)) ([c3c2f73](https://github.com/elelem-sh/tools/commit/c3c2f737358d9df67f4fd75a4c952c70ab960b60))
14
+ * edit fails when old is missing or repeated, and takes new literally (elelem-sh/elelem.sh[#21](https://github.com/elelem-sh/tools/issues/21)) ([c2fd4eb](https://github.com/elelem-sh/tools/commit/c2fd4eb9f78ce1c13717c9e04ac7d08e5256bb2a))
15
+ * end of input at a permission prompt refuses (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([dd75f4b](https://github.com/elelem-sh/tools/commit/dd75f4b2d5749b6972f2cfc7e24718654c4f809d))
16
+ * git always asks for its exact command line, since repository config can make any git command run a program ([d50005a](https://github.com/elelem-sh/tools/commit/d50005a9be0d56f1561887f7abe55ee4c3be23bb))
17
+ * git asks once, at execute, for its exact command line ([4dc6bd9](https://github.com/elelem-sh/tools/commit/4dc6bd975c7fa3441af6baee50d72e8662a176eb))
18
+ * grep and glob also fall back when rg or fd cannot run (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([8a5a5d7](https://github.com/elelem-sh/tools/commit/8a5a5d7924dcfebd310efbb8cbcb841e02de48c3))
19
+ * grep and glob fall back only when rg or fd is not installed (elelem-sh/elelem.sh[#20](https://github.com/elelem-sh/tools/issues/20)) ([43180e3](https://github.com/elelem-sh/tools/commit/43180e30477eeeec5388a953376c2b53f14b865b))
20
+ * grep, glob, list and git never take a model-chosen value as an option that runs a program ([0aca202](https://github.com/elelem-sh/tools/commit/0aca2029521e1793c0e631dfa4df24c7eafebe46))
21
+ * only Enter, y or yes allow at a permission prompt (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([d853f3b](https://github.com/elelem-sh/tools/commit/d853f3bf714142ce1f1303744815baa1ab44af97))
22
+ * only read-only git commands share the git approval; any other git command asks for its exact command line ([bbc1a0f](https://github.com/elelem-sh/tools/commit/bbc1a0ff5bc66b9c95b145653942f768df90a1a1))
23
+ * permission prompts escape characters that could redraw them (elelem-sh/elelem.sh[#2](https://github.com/elelem-sh/tools/issues/2)) ([fa46378](https://github.com/elelem-sh/tools/commit/fa463789635d446fced2b84dc6358d51c9389ed5))
24
+ * permission prompts show the command, and verify is allowed by default (elelem-sh/elelem.sh[#3](https://github.com/elelem-sh/tools/issues/3)) ([97c3437](https://github.com/elelem-sh/tools/commit/97c3437b6ec494c9b27d61e6ab11ee2632d5d3ed))
25
+ * require elelem ~> 0.12, the floor builtins/skills actually need ([48b5818](https://github.com/elelem-sh/tools/commit/48b5818282b37877d72c55aeb79ec20ded25c1b4))
26
+ * require elelem 0.13.1, verify each written file, and grep with no matches is not an error ([4dfab39](https://github.com/elelem-sh/tools/commit/4dfab39bb738f3e41e3d2cb28093a22204409911))
27
+ * verify escapes the path before passing it to the shell ([ed52cf8](https://github.com/elelem-sh/tools/commit/ed52cf8f4682225fff4d0a1380f6c44fb59dd614))
@@ -6,7 +6,7 @@ Elelem.configure do |config|
6
6
  response = agent.turn("Summarize: accomplishments, state, next steps. Brief.")
7
7
  agent.conversation.clear!
8
8
  agent.conversation.add(role: "user", content: "Context: #{response}")
9
- agent.output.say " → compacted"
9
+ agent.output.say " -> compacted"
10
10
  end
11
11
  end
12
12
  end
@@ -3,15 +3,19 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:edit) do |agent|
5
5
  agent.toolbox.add("edit",
6
- description: "Replace first occurrence of text in file",
7
- params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
8
- required: ["path", "old", "new"]
9
- ) do |args|
10
- path = Pathname.new(args["path"]).expand_path
11
- content = path.read
6
+ description: "Replace the one occurrence of old with new in a file",
7
+ params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
8
+ required: %w[path old new]) do |args|
9
+ content = Pathname.new(args["path"]).expand_path.read
10
+ first = content.index(args["old"]) unless args["old"].empty?
11
+ next { error: "old not found" } unless first
12
+ # index also finds overlapping matches, and copes with invalid bytes.
13
+ next { error: "old matches more than once; include more context" } if content.index(args["old"], first + 1)
14
+
15
+ # The block form takes new literally, so \0 and \1 are not expanded.
12
16
  agent.toolbox
13
- .run("write", { "path" => args["path"], "content" => content.sub(args["old"], args["new"]) })
14
- .merge(replaced: args["old"], with: args["new"])
17
+ .run("write", { "path" => args["path"], "content" => content.sub(args["old"]) { args["new"] } })
18
+ .merge(replaced: args["old"], with: args["new"])
15
19
  end
16
20
  end
17
21
  end
@@ -12,11 +12,10 @@ Elelem.configure do |config|
12
12
  DESC
13
13
 
14
14
  agent.toolbox.add("eval",
15
- description: description,
16
- params: { ruby: { type: "string" } },
17
- required: ["ruby"]
18
- ) do |args|
19
- { result: binding.eval(args["ruby"]) }
15
+ description: description,
16
+ params: { ruby: { type: "string" } },
17
+ required: ["ruby"]) do |args|
18
+ { result: binding.eval(args["ruby"]) } # rubocop:disable Security/Eval
20
19
  end
21
20
  end
22
21
  end
@@ -2,9 +2,49 @@
2
2
 
3
3
  module Elelem
4
4
  module Tools
5
+ # glob, grep and list run their command through execute. When a person was
6
+ # just asked about the outer call, which shows the same arguments, the
7
+ # command it builds does not ask again. When the outer call was allowed
8
+ # without asking, the command still asks. A deny on execute always applies.
9
+ # git is not one of them: repository config can make any git command run a
10
+ # program, so git is allowed by default and execute asks for its exact
11
+ # command line, once.
5
12
  def self.exec(toolbox, *args)
6
13
  command = args.flatten.map { |a| Shellwords.escape(a.to_s) }.join(" ")
7
- toolbox.run("execute", { "command" => command })
14
+ nested { toolbox.run("execute", { "command" => command }) }
15
+ end
16
+
17
+ def self.nested
18
+ outer = Thread.current[:elelem_tools_nested]
19
+ Thread.current[:elelem_tools_nested] = true
20
+ yield
21
+ ensure
22
+ Thread.current[:elelem_tools_nested] = outer
23
+ end
24
+
25
+ def self.nested?
26
+ Thread.current[:elelem_tools_nested] == true
27
+ end
28
+
29
+ def self.approved?
30
+ Thread.current[:elelem_tools_approved] == true
31
+ end
32
+
33
+ def self.approved=(value)
34
+ Thread.current[:elelem_tools_approved] = value
35
+ end
36
+
37
+ # rg and grep exit 1 when nothing matches. That is an answer, not a failure.
38
+ def self.no_matches(result)
39
+ return result unless result[:exit_status] == 1 && result[:content].to_s.strip.empty?
40
+
41
+ result.to_h.except(:ok, :error).merge(content: "no matches")
42
+ end
43
+
44
+ # bash exits 127 when the command is not installed, 126 when it cannot run
45
+ # it. rg exits 1 when nothing matches, which is not a reason to fall back.
46
+ def self.missing?(result)
47
+ [126, 127].include?(result[:exit_status])
8
48
  end
9
49
  end
10
50
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Elelem
4
4
  module Tools
5
- def self.fork(agent, system_prompt:)
6
- Elelem::Agent.new(agent.provider, toolbox: agent.toolbox, output: agent.output, input: agent.input, system_prompt: system_prompt)
5
+ def self.fork(agent, template:)
6
+ Elelem::Agent.new(agent.provider, toolbox: agent.toolbox, output: agent.output, template: template)
7
7
  end
8
8
  end
9
9
  end
@@ -3,11 +3,13 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:git) do |agent|
5
5
  agent.toolbox.add("git",
6
- description: "Run git command",
7
- params: { command: { type: "string" }, args: { type: "array", items: { type: "string" } } },
8
- required: ["command"]
9
- ) do |args|
10
- Elelem::Tools.exec(agent.toolbox, "git", args["command"], *(args["args"] || []))
6
+ description: "Run git command",
7
+ params: { command: { type: "string" }, args: { type: "array", items: { type: "string" } } },
8
+ required: ["command"]) do |args|
9
+ # Not Tools.exec: config (aliases, hooks, filters, textconv, signing) can
10
+ # make any git command run a program, so execute asks for the exact line.
11
+ line = ["git", args["command"], *(args["args"] || [])].map { |a| Shellwords.escape(a.to_s) }.join(" ")
12
+ agent.toolbox.run("execute", { "command" => line })
11
13
  end
12
14
 
13
15
  agent.toolbox.after("git") do |_, result|
@@ -3,13 +3,16 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:glob) do |agent|
5
5
  agent.toolbox.add("glob",
6
- description: "Find files matching pattern",
7
- params: { pattern: { type: "string" }, path: { type: "string" } },
8
- required: ["pattern"]
9
- ) do |args|
6
+ description: "Find files matching pattern",
7
+ params: { pattern: { type: "string" }, path: { type: "string" } },
8
+ required: ["pattern"]) do |args|
10
9
  path = args["path"].to_s.empty? ? "." : args["path"]
11
- result = Elelem::Tools.exec(agent.toolbox, "fd", "--glob", args["pattern"], path)
12
- result[:ok] ? result : Elelem::Tools.exec(agent.toolbox, "find", path, "-name", args["pattern"])
10
+ # find has no --, so a path like -delete becomes ./-delete.
11
+ path = "./#{path}" if path.start_with?("-")
12
+ result = Elelem::Tools.exec(agent.toolbox, "fd", "--glob", "--", args["pattern"], path)
13
+ next result unless Elelem::Tools.missing?(result)
14
+
15
+ Elelem::Tools.exec(agent.toolbox, "find", path, "-name", args["pattern"])
13
16
  end
14
17
  end
15
18
  end
@@ -3,22 +3,25 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:grep) do |agent|
5
5
  agent.toolbox.add("grep",
6
- description: "Search file contents",
7
- params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
8
- required: ["pattern"]
9
- ) do |args|
6
+ description: "Search file contents",
7
+ params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
8
+ required: ["pattern"]) do |args|
10
9
  path = args["path"].to_s.empty? ? "." : args["path"]
11
10
  glob = args["glob"]
12
11
 
13
- rg_args = ["rg", "-n", args["pattern"], path]
12
+ # -e and -- keep a pattern or path starting with - from being read as an
13
+ # option, such as rg --pre=CMD, which runs a program on every file.
14
+ rg_args = ["rg", "-n"]
14
15
  rg_args += ["-g", glob] if glob
16
+ rg_args += ["-e", args["pattern"], "--", path]
15
17
  result = Elelem::Tools.exec(agent.toolbox, *rg_args)
16
- next result if result[:ok]
17
-
18
- grep_args = ["grep", "-rn"]
19
- grep_args += ["--include", glob] if glob
20
- grep_args += [args["pattern"], path]
21
- Elelem::Tools.exec(agent.toolbox, *grep_args)
18
+ if Elelem::Tools.missing?(result)
19
+ grep_args = ["grep", "-rn"]
20
+ grep_args += ["--include", glob] if glob
21
+ grep_args += ["-e", args["pattern"], "--", path]
22
+ result = Elelem::Tools.exec(agent.toolbox, *grep_args)
23
+ end
24
+ Elelem::Tools.no_matches(result)
22
25
  end
23
26
  end
24
27
  end
@@ -3,7 +3,7 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:init) do |agent|
5
5
  agent.commands.register("init", description: "Generate AGENTS.md") do
6
- system_prompt = <<~PROMPT
6
+ template = <<~PROMPT
7
7
  AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
8
8
 
9
9
  # AGENTS.md Spec (https://agents.md/)
@@ -25,7 +25,7 @@ Elelem.configure do |config|
25
25
  Keep it minimal. No fluff.
26
26
  PROMPT
27
27
 
28
- Elelem::Tools.fork(agent, system_prompt: system_prompt).turn("Generate AGENTS.md for this project")
28
+ Elelem::Tools.fork(agent, template: template).turn("Generate AGENTS.md for this project")
29
29
  end
30
30
  end
31
31
  end
@@ -3,12 +3,11 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:interview) do |agent|
5
5
  agent.toolbox.add("interview",
6
- description: "Ask the user a question and wait for their response",
7
- params: {
8
- question: { type: "string", description: "The question to ask the user" },
9
- },
10
- required: ["question"]
11
- ) do |args|
6
+ description: "Ask the user a question and wait for their response",
7
+ params: {
8
+ question: { type: "string", description: "The question to ask the user" }
9
+ },
10
+ required: ["question"]) do |args|
12
11
  agent.output.say(args["question"], as: :markdown)
13
12
  { answer: agent.input.ask("> ") }
14
13
  end
@@ -3,14 +3,13 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:list) do |agent|
5
5
  agent.toolbox.add("list",
6
- description: "List directory contents",
7
- params: { path: { type: "string" }, recursive: { type: "boolean" } },
8
- required: [],
9
- aliases: ["ls"]
10
- ) do |args|
6
+ description: "List directory contents",
7
+ params: { path: { type: "string" }, recursive: { type: "boolean" } },
8
+ required: [],
9
+ aliases: ["ls"]) do |args|
11
10
  path = args["path"] && !args["path"].empty? ? args["path"] : "."
12
11
  flags = args["recursive"] ? "-laR" : "-la"
13
- Elelem::Tools.exec(agent.toolbox, "ls", flags, path)
12
+ Elelem::Tools.exec(agent.toolbox, "ls", flags, "--", path)
14
13
  end
15
14
  end
16
15
  end
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "execute": "ask",
3
+ "git": "allow",
3
4
  "read": "allow",
5
+ "verify": "allow",
4
6
  "write": "allow"
5
7
  }
@@ -9,25 +9,39 @@ module Elelem
9
9
  ".agents/permissions.json"
10
10
  ].freeze
11
11
 
12
+ # Enter, y or yes. Anything else refuses: no, a typo, or end of input (Ctrl+D, nil).
13
+ YES = ["", "y", "yes"].freeze
14
+
12
15
  def initialize(rules: default_rules)
13
16
  @rules = rules
14
17
  end
15
18
 
16
19
  def check(tool_name, args, input:)
17
20
  policy = @rules[tool_name.to_sym] || :ask
18
- case policy
19
- when :allow then true
20
- when :deny then raise "Permission denied: #{tool_name}"
21
- when :ask then prompt(tool_name, args, input)
22
- end
21
+ raise "Permission denied: #{tool_name}" if policy == :deny
22
+ return nested(tool_name, args, input, policy) if Tools.nested?
23
+
24
+ # Remember whether a person just saw and approved this call. Only then
25
+ # may the command it builds skip its own prompt.
26
+ Tools.approved = false
27
+ return true if policy == :allow
28
+
29
+ prompt(tool_name, args, input).tap { Tools.approved = input.interactive? }
23
30
  end
24
31
 
25
32
  private
26
33
 
34
+ def nested(tool_name, args, input, policy)
35
+ policy == :allow || Tools.approved? || prompt(tool_name, args, input)
36
+ end
37
+
27
38
  def load_config(path)
28
39
  return {} unless File.exist?(path)
29
40
 
30
- JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
41
+ data = JSON.parse(File.read(path))
42
+ return {} unless data.is_a?(Hash)
43
+
44
+ data.transform_keys(&:to_sym).transform_values { |v| v.to_s.to_sym }
31
45
  rescue JSON::ParserError
32
46
  {}
33
47
  end
@@ -35,12 +49,22 @@ module Elelem
35
49
  def prompt(tool_name, args, input)
36
50
  return true unless input.interactive?
37
51
 
38
- answer = input.ask(" Allow? [Y/n] > ")&.downcase
39
- raise "User denied permission: #{tool_name}" if answer == "n"
52
+ answer = input.ask(" Allow #{tool_name}#{subject(tool_name, args)}? [Y/n] > ")
53
+ raise "User denied permission: #{tool_name}" unless YES.include?(answer&.strip&.downcase)
40
54
 
41
55
  true
42
56
  end
43
57
 
58
+ # The whole command, never cut, so nothing hidden gets approved.
59
+ def subject(tool_name, args)
60
+ return "" if args.nil? || args.empty?
61
+
62
+ command = args["command"] if tool_name.to_s == "execute"
63
+ text = command ? command.to_s : JSON.generate(args)
64
+ # Control and format characters (\r, ESC, bidi overrides) could redraw the prompt.
65
+ " `#{text.match?(/[\p{Cc}\p{Cf}]/) ? text.dump : text}`"
66
+ end
67
+
44
68
  def default_rules
45
69
  LOAD_PATHS.reduce({}) do |rules, path|
46
70
  rules.merge(load_config(File.expand_path(path)))
@@ -2,7 +2,7 @@
2
2
 
3
3
  Elelem.configure do |config|
4
4
  config.setup(:shell) do |agent|
5
- strip_ansi = ->(text) do
5
+ strip_ansi = lambda do |text|
6
6
  text
7
7
  .gsub(/^Script started.*?\n/, "")
8
8
  .gsub(/\nScript done.*$/, "")
@@ -11,7 +11,7 @@ Elelem.configure do |config|
11
11
  .gsub(/\e[PX^_].*?\e\\/, "")
12
12
  .gsub(/\e./, "")
13
13
  .gsub(/[\b]/, "")
14
- .gsub(/\r/, "")
14
+ .gsub("\r", "")
15
15
  end
16
16
 
17
17
  agent.commands.register("shell", description: "Start interactive shell") do
@@ -3,11 +3,10 @@
3
3
  Elelem.configure do |config|
4
4
  config.setup(:task) do |agent|
5
5
  agent.toolbox.add("task",
6
- description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
7
- params: { prompt: { type: "string" } },
8
- required: ["prompt"]
9
- ) do |args|
10
- sub = Elelem::Tools.fork(agent, system_prompt: "Research agent. Search, analyze, report. Be concise.")
6
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
7
+ params: { prompt: { type: "string" } },
8
+ required: ["prompt"]) do |args|
9
+ sub = Elelem::Tools.fork(agent, template: "Research agent. Search, analyze, report. Be concise.")
11
10
  sub.turn(args["prompt"])
12
11
  { result: sub.conversation.last[:content] }
13
12
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  class Verifiers
4
6
  SYNTAX = {
5
7
  ".rb" => "ruby -c %{path}",
@@ -8,7 +10,7 @@ class Verifiers
8
10
  ".go" => "go vet %{path}",
9
11
  ".rs" => "cargo check --quiet",
10
12
  ".ts" => "npx tsc --noEmit %{path}",
11
- ".js" => "node --check %{path}",
13
+ ".js" => "node --check %{path}"
12
14
  }.freeze
13
15
 
14
16
  def self.for(path)
@@ -16,7 +18,7 @@ class Verifiers
16
18
 
17
19
  cmds = []
18
20
  ext = File.extname(path)
19
- cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
21
+ cmds << format(SYNTAX[ext], path: Shellwords.escape(path)) if SYNTAX[ext]
20
22
  cmds << test_runner
21
23
  cmds.compact
22
24
  end
@@ -29,19 +31,21 @@ end
29
31
  Elelem.configure do |config|
30
32
  config.setup(:verify) do |agent|
31
33
  agent.toolbox.add("verify",
32
- description: "Verify file syntax and run tests",
33
- params: { path: { type: "string" } },
34
- required: ["path"]
35
- ) do |args|
34
+ description: "Verify file syntax and run tests",
35
+ params: { path: { type: "string" } },
36
+ required: ["path"]) do |args|
36
37
  path = args["path"]
37
- Verifiers.for(path).inject({ verified: [] }) do |memo, cmd|
38
+ Verifiers.for(path).each_with_object({ verified: [] }) do |cmd, memo|
38
39
  agent.output.doing("execute", { "command" => cmd })
39
40
  v = agent.toolbox.run("execute", { "command" => cmd })
40
- break v.merge(path: path, command: cmd) if v[:exit_status] != 0
41
+ break v.to_h.merge(path: path, command: cmd) unless v.ok?
41
42
 
42
43
  memo[:verified] << cmd
43
- memo
44
44
  end
45
45
  end
46
+
47
+ agent.toolbox.after("write") do |_, result|
48
+ result[:verify] = agent.toolbox.run("verify", { "path" => result[:path] }).to_h unless result[:error]
49
+ end
46
50
  end
47
51
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Elelem
4
4
  module Tools
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -0,0 +1,13 @@
1
+ {
2
+ "bootstrap-sha": "b15526735ac2f36abe2ac102d87a484ff3d3916b",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "elelem-tools",
7
+ "version-file": "lib/elelem/tools/version.rb",
8
+ "bump-minor-pre-major": true,
9
+ "include-component-in-tag": false,
10
+ "release-as": "0.3.0"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A pattern or path the model chooses must never become an option that runs
4
+ # a program or deletes files.
5
+ RSpec.describe "tool arguments that look like options" do
6
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
7
+
8
+ before do
9
+ File.write("notes.txt", "hello\n")
10
+ File.write("x.sh", "#!/bin/sh\ntouch pwned\n")
11
+ File.chmod(0o755, "x.sh")
12
+ end
13
+
14
+ it "grep searches for a pattern like --pre=CMD instead of running CMD" do
15
+ run_tool("grep", { "pattern" => "--pre=./x.sh" })
16
+
17
+ expect(File).not_to exist("pwned")
18
+ end
19
+
20
+ it "grep finds a pattern that starts with -" do
21
+ File.write("flags.txt", "-v\n")
22
+
23
+ expect(run_tool("grep", { "pattern" => "-v" })[:content]).to include("flags.txt")
24
+ end
25
+
26
+ it "glob does not take a path like -delete as an action" do
27
+ run_tool("glob", { "pattern" => "zzz", "path" => "-delete" })
28
+
29
+ expect(File).to exist("notes.txt")
30
+ end
31
+
32
+ it "list takes a path like -R as a name" do
33
+ expect(run_tool("list", { "path" => "-R" })).not_to be_ok
34
+ end
35
+
36
+ describe "git" do
37
+ def executes_for(call)
38
+ seen = []
39
+ session.toolbox.before("execute") do |a|
40
+ seen << [a["command"], Elelem::Tools.nested?]
41
+ raise "stopped"
42
+ end
43
+ run_tool("git", call)
44
+ seen
45
+ end
46
+
47
+ it "asks for the exact command line of a read-only command, since config can make it run programs" do
48
+ expect(executes_for({ "command" => "status", "args" => ["--short"] })).to eq([["git status --short", false]])
49
+ end
50
+
51
+ it "asks for the exact command line of any other command" do
52
+ expect(executes_for({ "command" => "rebase", "args" => ["-xtouch pwned", "HEAD~1"] }))
53
+ .to eq([["git rebase -xtouch\\ pwned HEAD\\~1", false]])
54
+ end
55
+ end
56
+
57
+ it "git still runs ordinary commands" do
58
+ system("git", "init", "-q", ".")
59
+
60
+ expect(run_tool("git", { "command" => "status", "args" => ["--short"] })).to be_ok
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the edit tool" do
4
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
5
+
6
+ before { File.write("a.txt", "one two two") }
7
+
8
+ it "replaces the one occurrence" do
9
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "1" })).to be_ok
10
+ expect(File.read("a.txt")).to eq("1 two two")
11
+ end
12
+
13
+ it "fails when old is missing" do
14
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "three", "new" => "3" })[:error]).to eq("old not found")
15
+ expect(File.read("a.txt")).to eq("one two two")
16
+ end
17
+
18
+ it "fails when old matches more than once" do
19
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "two", "new" => "2" })[:error]).to match(/more than once/)
20
+ end
21
+
22
+ it "counts overlapping matches" do
23
+ File.write("a.txt", "aaa")
24
+
25
+ expect(run_tool("edit", { "path" => "a.txt", "old" => "aa", "new" => "b" })[:error]).to match(/more than once/)
26
+ end
27
+
28
+ it "edits a file with bytes that are not UTF-8" do
29
+ File.binwrite("a.txt", "caf\xE9 one")
30
+ run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "1" })
31
+
32
+ expect(File.binread("a.txt")).to eq("caf\xE9 1".b)
33
+ end
34
+
35
+ it "takes new literally" do
36
+ run_tool("edit", { "path" => "a.txt", "old" => "one", "new" => "\\0 \\1" })
37
+
38
+ expect(File.read("a.txt")).to eq("\\0 \\1 two two")
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the grep and glob fallbacks" do
4
+ # A PATH with bash and the fallbacks, but no rg or fd.
5
+ around do |example|
6
+ original = ENV.fetch("PATH")
7
+ Dir.mktmpdir do |dir|
8
+ bin = File.join(dir, "bin")
9
+ Dir.mkdir(bin)
10
+ %w[bash grep find].each do |cmd|
11
+ found = ENV.fetch("PATH").split(":").map { |d| File.join(d, cmd) }.find { |f| File.executable?(f) }
12
+ File.symlink(found, File.join(bin, cmd))
13
+ end
14
+ File.write(File.join(dir, "notes.txt"), "needle\n")
15
+ ENV["PATH"] = bin
16
+ @bin = bin
17
+ Dir.chdir(dir) { example.run }
18
+ ensure
19
+ ENV["PATH"] = original
20
+ end
21
+ end
22
+
23
+ it "falls back when rg is there but cannot run" do
24
+ File.binwrite(File.join(@bin, "rg"), "\x7FELF\x00\x00")
25
+ File.chmod(0o755, File.join(@bin, "rg"))
26
+ result = run_tool("grep", { "pattern" => "needle" })
27
+
28
+ expect(result[:content]).to include("notes.txt")
29
+ end
30
+
31
+ it "does not fall back when rg runs and finds nothing" do
32
+ File.write(File.join(@bin, "rg"), "#!/bin/sh\nexit 1\n")
33
+ File.chmod(0o755, File.join(@bin, "rg"))
34
+ result = run_tool("grep", { "pattern" => "needle" })
35
+
36
+ expect([result[:exit_status], result[:content]]).to eq([1, "no matches"])
37
+ expect(result).to be_ok
38
+ end
39
+
40
+ it "greps with grep when rg is not installed" do
41
+ result = run_tool("grep", { "pattern" => "needle" })
42
+
43
+ expect(result[:exit_status]).to eq(0)
44
+ expect(result[:content]).to include("notes.txt")
45
+ end
46
+
47
+ it "globs with find when fd is not installed" do
48
+ result = run_tool("glob", { "pattern" => "*.txt" })
49
+
50
+ expect(result[:exit_status]).to eq(0)
51
+ expect(result[:content]).to include("notes.txt")
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the git tool" do
4
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
5
+
6
+ it "reports a failing git command as an error" do
7
+ result = run_tool("git", { "command" => "log" })
8
+
9
+ expect(result).not_to be_ok
10
+ expect(result[:error]).to include(result[:exit_status].to_s)
11
+ end
12
+
13
+ it "passes a working git command through" do
14
+ system("git", "init", "-q", ".")
15
+
16
+ expect(run_tool("git", { "command" => "status" })).to be_ok
17
+ end
18
+
19
+ it "asks once, showing the command line it runs" do
20
+ home = Dir.home
21
+ ENV["HOME"] = Dir.pwd # ignore ~/.agents/permissions.json
22
+ system("git", "init", "-q", ".")
23
+ prompts = []
24
+ input = Class.new do
25
+ define_method(:interactive?) { true }
26
+ define_method(:ask) { |text| prompts << text and "y" }
27
+ end.new
28
+ session = Elelem::Config.build_session("demo", input: input, output: Elelem::NullOutput.new)
29
+
30
+ expect(session.toolbox.run("git", { "command" => "status" })).to be_ok
31
+ expect(prompts).to eq([" Allow execute `git status`? [Y/n] > "])
32
+ ensure
33
+ ENV["HOME"] = home
34
+ end
35
+ end
@@ -7,6 +7,10 @@ RSpec.describe Elelem::Tools::Permissions do
7
7
 
8
8
  describe "#check" do
9
9
  context "with default allow policies" do
10
+ it "allows verify, whose commands are still checked as execute" do
11
+ expect(subject.check("verify", { "path" => "a.rb" }, input: input)).to be true
12
+ end
13
+
10
14
  it "allows read without prompting" do
11
15
  expect(subject.check("read", {}, input: input)).to be true
12
16
  expect(input).not_to have_received(:ask)
@@ -43,6 +47,135 @@ RSpec.describe Elelem::Tools::Permissions do
43
47
 
44
48
  it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
45
49
  end
50
+
51
+ context "at end of input" do
52
+ let(:answer) { nil }
53
+
54
+ it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
55
+ end
56
+
57
+ context "when the answer is an empty line" do
58
+ let(:answer) { "" }
59
+
60
+ it { expect(subject.check("execute", {}, input: input)).to be true }
61
+ end
62
+
63
+ %w[y Y yes YES].each do |yes|
64
+ context "when the answer is #{yes}" do
65
+ let(:answer) { yes }
66
+
67
+ it { expect(subject.check("execute", {}, input: input)).to be true }
68
+ end
69
+ end
70
+
71
+ %w[no np nope x].each do |other|
72
+ context "when the answer is #{other}" do
73
+ let(:answer) { other }
74
+
75
+ it { expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/) }
76
+ end
77
+ end
78
+
79
+ context "with arguments" do
80
+ let(:answer) { "y" }
81
+
82
+ it "shows the whole command" do
83
+ subject.check("execute", { "command" => "rg -n foo ." }, input: input)
84
+
85
+ expect(input).to have_received(:ask).with(" Allow execute `rg -n foo .`? [Y/n] > ")
86
+ end
87
+
88
+ it "escapes newlines so no line is hidden" do
89
+ subject.check("execute", { "command" => "ls\nrm -rf x" }, input: input)
90
+
91
+ expect(input).to have_received(:ask).with(" Allow execute `\"ls\\nrm -rf x\"`? [Y/n] > ")
92
+ end
93
+
94
+ it "escapes characters that could redraw the prompt" do
95
+ subject.check("execute", { "command" => "rm -rf ~ \r Allow execute `ls" }, input: input)
96
+
97
+ expect(input).to have_received(:ask).with(" Allow execute `\"rm -rf ~ \\r Allow execute `ls\"`? [Y/n] > ")
98
+ end
99
+
100
+ it "shows other tools' arguments as JSON" do
101
+ subject.check("grep", { "pattern" => "foo" }, input: input)
102
+
103
+ expect(input).to have_received(:ask).with(" Allow grep `{\"pattern\":\"foo\"}`? [Y/n] > ")
104
+ end
105
+ end
106
+ end
107
+
108
+ context "inside a tool that runs its own command" do
109
+ let(:input) { double(interactive?: true) }
110
+ let(:answers) { [] }
111
+
112
+ before { allow(input).to receive(:ask) { answers.shift } }
113
+
114
+ def run_tool(permissions, name)
115
+ permissions.check(name, { "pattern" => "x" }, input: input)
116
+ Elelem::Tools.nested { permissions.check("execute", { "command" => "rg -n x ." }, input: input) }
117
+ end
118
+
119
+ it "does not ask again after the person approved the tool" do
120
+ answers.push("y")
121
+
122
+ expect(run_tool(subject, "grep")).to be true
123
+ expect(input).to have_received(:ask).once
124
+ end
125
+
126
+ it "still asks for the command when the tool was allowed without asking" do
127
+ answers.push("n")
128
+ allowed = described_class.new(rules: { git: :allow })
129
+
130
+ expect { run_tool(allowed, "git") }.to raise_error(/User denied permission: execute/)
131
+ end
132
+
133
+ it "still refuses a denied execute" do
134
+ answers.push("y")
135
+ denied = described_class.new(rules: { execute: :deny })
136
+
137
+ expect { run_tool(denied, "grep") }.to raise_error(/Permission denied: execute/)
138
+ end
139
+
140
+ it "forgets the approval at the next tool" do
141
+ answers.push("y", "n")
142
+ permissions = described_class.new(rules: { list: :allow })
143
+ run_tool(permissions, "grep")
144
+
145
+ expect { run_tool(permissions, "list") }.to raise_error(/User denied permission: execute/)
146
+ end
147
+ end
148
+
149
+ context "with a misspelled policy on an interactive input" do
150
+ subject { described_class.new(rules: { execute: :dney }) }
151
+
152
+ let(:input) { double(ask: "n", interactive?: true) }
153
+
154
+ it "asks" do
155
+ expect { subject.check("execute", {}, input: input) }.to raise_error(/User denied permission/)
156
+ expect(input).to have_received(:ask)
157
+ end
158
+ end
159
+ end
160
+
161
+ describe "loading a config" do
162
+ it "asks for a value that is not a string" do
163
+ Dir.mktmpdir do |dir|
164
+ path = File.join(dir, "permissions.json")
165
+ File.write(path, '{"execute": false}')
166
+ rules = subject.send(:load_config, path)
167
+ input = double(ask: "n", interactive?: true)
168
+ expect { described_class.new(rules: rules).check("execute", {}, input: input) }
169
+ .to raise_error(/User denied permission/)
170
+ end
171
+ end
172
+
173
+ it "ignores a file that is not an object" do
174
+ Dir.mktmpdir do |dir|
175
+ path = File.join(dir, "permissions.json")
176
+ File.write(path, "[]")
177
+ expect(subject.send(:load_config, path)).to eq({})
178
+ end
46
179
  end
47
180
  end
48
181
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the verify tool" do
4
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
5
+
6
+ it "fails when a check fails" do
7
+ File.write("bad.rb", "def oops")
8
+ result = run_tool("verify", { "path" => "bad.rb" })
9
+
10
+ expect(result).not_to be_ok
11
+ expect(result[:error]).to include(result[:exit_status].to_s)
12
+ end
13
+
14
+ it "checks a written file once, in the write's own result" do
15
+ checks = []
16
+ session.toolbox.before("execute") { |args| checks << args["command"] }
17
+ result = run_tool("write", { "path" => "bad.rb", "content" => "def oops" })
18
+
19
+ expect(result[:verify]).to include(command: "ruby -c bad.rb", ok: false)
20
+ expect(checks).to eq(["ruby -c bad.rb"])
21
+ end
22
+
23
+ it "passes when every check passes" do
24
+ File.write("good.rb", "1")
25
+
26
+ expect(run_tool("verify", { "path" => "good.rb" })[:verified]).to eq(["ruby -c good.rb"])
27
+ end
28
+
29
+ it "passes the path to the shell as one word" do
30
+ path = "a b; touch pwned.rb"
31
+ File.write(path, "1")
32
+
33
+ expect(run_tool("verify", { "path" => path })).to be_ok
34
+ expect(File).not_to exist("pwned.rb")
35
+ end
36
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../lib/elelem/tools"
4
+ require_relative "support/session"
4
5
 
5
6
  RSpec.configure do |config|
6
7
  config.disable_monkey_patching!
8
+ config.include SessionHelper
7
9
 
8
10
  config.expect_with :rspec do |c|
9
11
  c.syntax = :expect
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SessionHelper
4
+ def session
5
+ @session ||= Elelem::Config.build_session("demo", input: Elelem::NullInput.new, output: Elelem::NullOutput.new)
6
+ end
7
+
8
+ def run_tool(name, args)
9
+ session.toolbox.run(name, args)
10
+ end
11
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elelem-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -13,30 +13,22 @@ dependencies:
13
13
  name: elelem
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.11'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
18
+ version: 0.13.1
19
+ - - "<"
24
20
  - !ruby/object:Gem::Version
25
- version: '0.11'
26
- - !ruby/object:Gem::Dependency
27
- name: elelem-builtins
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.1'
21
+ version: '1'
33
22
  type: :runtime
34
23
  prerelease: false
35
24
  version_requirements: !ruby/object:Gem::Requirement
36
25
  requirements:
37
- - - "~>"
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.13.1
29
+ - - "<"
38
30
  - !ruby/object:Gem::Version
39
- version: '0.1'
31
+ version: '1'
40
32
  - !ruby/object:Gem::Dependency
41
33
  name: json
42
34
  requirement: !ruby/object:Gem::Requirement
@@ -86,7 +78,11 @@ executables: []
86
78
  extensions: []
87
79
  extra_rdoc_files: []
88
80
  files:
81
+ - ".github/workflows/release.yml"
82
+ - ".release-please-manifest.json"
89
83
  - ".rspec"
84
+ - ".rubocop.yml"
85
+ - CHANGELOG.md
90
86
  - LICENSE.txt
91
87
  - Rakefile
92
88
  - lib/elelem/tools.rb
@@ -108,15 +104,23 @@ files:
108
104
  - lib/elelem/tools/task.rb
109
105
  - lib/elelem/tools/verify.rb
110
106
  - lib/elelem/tools/version.rb
107
+ - release-please-config.json
108
+ - spec/elelem/tools/arguments_spec.rb
109
+ - spec/elelem/tools/edit_spec.rb
110
+ - spec/elelem/tools/fallback_spec.rb
111
+ - spec/elelem/tools/git_spec.rb
111
112
  - spec/elelem/tools/permissions_spec.rb
113
+ - spec/elelem/tools/verify_spec.rb
112
114
  - spec/spec_helper.rb
113
- homepage: https://src.mokhan.ca/elelem/tools
115
+ - spec/support/session.rb
116
+ homepage: https://elelem.sh
114
117
  licenses:
115
118
  - MIT
116
119
  metadata:
117
120
  allowed_push_host: https://rubygems.org
118
- homepage_uri: https://src.mokhan.ca/elelem/tools
119
- source_code_uri: https://src.mokhan.ca/elelem/tools
121
+ homepage_uri: https://elelem.sh
122
+ source_code_uri: https://github.com/elelem-sh/tools
123
+ rubygems_mfa_required: 'true'
120
124
  rdoc_options: []
121
125
  require_paths:
122
126
  - lib
@@ -131,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
135
  - !ruby/object:Gem::Version
132
136
  version: '0'
133
137
  requirements: []
134
- rubygems_version: 4.0.20
138
+ rubygems_version: 4.0.6
135
139
  specification_version: 4
136
140
  summary: Optional tools for elelem.
137
141
  test_files: []