ace-llm-providers-cli 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ace-defaults/llm/providers/claude.yml +24 -0
- data/.ace-defaults/llm/providers/codex.yml +22 -0
- data/.ace-defaults/llm/providers/codexoss.yml +13 -0
- data/.ace-defaults/llm/providers/gemini.yml +32 -0
- data/.ace-defaults/llm/providers/opencode.yml +26 -0
- data/.ace-defaults/llm/providers/pi.yml +43 -0
- data/CHANGELOG.md +457 -0
- data/LICENSE +21 -0
- data/README.md +36 -0
- data/Rakefile +14 -0
- data/exe/ace-llm-providers-cli-check +76 -0
- data/lib/ace/llm/providers/cli/atoms/args_normalizer.rb +82 -0
- data/lib/ace/llm/providers/cli/atoms/auth_checker.rb +74 -0
- data/lib/ace/llm/providers/cli/atoms/command_formatters.rb +19 -0
- data/lib/ace/llm/providers/cli/atoms/command_rewriter.rb +75 -0
- data/lib/ace/llm/providers/cli/atoms/execution_context.rb +28 -0
- data/lib/ace/llm/providers/cli/atoms/provider_detector.rb +48 -0
- data/lib/ace/llm/providers/cli/atoms/session_finders/claude_session_finder.rb +79 -0
- data/lib/ace/llm/providers/cli/atoms/session_finders/codex_session_finder.rb +84 -0
- data/lib/ace/llm/providers/cli/atoms/session_finders/gemini_session_finder.rb +66 -0
- data/lib/ace/llm/providers/cli/atoms/session_finders/open_code_session_finder.rb +119 -0
- data/lib/ace/llm/providers/cli/atoms/session_finders/pi_session_finder.rb +87 -0
- data/lib/ace/llm/providers/cli/atoms/skill_command_rewriter.rb +30 -0
- data/lib/ace/llm/providers/cli/atoms/worktree_dir_resolver.rb +56 -0
- data/lib/ace/llm/providers/cli/claude_code_client.rb +358 -0
- data/lib/ace/llm/providers/cli/claude_oai_client.rb +322 -0
- data/lib/ace/llm/providers/cli/cli_args_support.rb +19 -0
- data/lib/ace/llm/providers/cli/codex_client.rb +291 -0
- data/lib/ace/llm/providers/cli/codex_oai_client.rb +274 -0
- data/lib/ace/llm/providers/cli/gemini_client.rb +346 -0
- data/lib/ace/llm/providers/cli/molecules/health_checker.rb +80 -0
- data/lib/ace/llm/providers/cli/molecules/safe_capture.rb +153 -0
- data/lib/ace/llm/providers/cli/molecules/session_finder.rb +44 -0
- data/lib/ace/llm/providers/cli/molecules/skill_name_reader.rb +64 -0
- data/lib/ace/llm/providers/cli/open_code_client.rb +271 -0
- data/lib/ace/llm/providers/cli/pi_client.rb +331 -0
- data/lib/ace/llm/providers/cli/version.rb +11 -0
- data/lib/ace/llm/providers/cli.rb +47 -0
- metadata +139 -0
data/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1> ACE - LLM Providers CLI </h1>
|
|
3
|
+
|
|
4
|
+
CLI-backed provider adapters that extend ace-llm with local tool execution.
|
|
5
|
+
|
|
6
|
+
<img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
|
|
7
|
+
<br><br>
|
|
8
|
+
|
|
9
|
+
<a href="https://rubygems.org/gems/ace-llm-providers-cli"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-llm-providers-cli.svg" /></a>
|
|
10
|
+
<a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
|
|
16
|
+
|
|
17
|
+
[ace-llm Usage Guide](../ace-llm/docs/usage.md) | [ace-llm Handbook](../ace-llm/docs/handbook.md)
|
|
18
|
+
`ace-llm-providers-cli` extends [ace-llm](../ace-llm) with provider clients that execute through installed CLI tools (Claude, Codex, OpenCode, Gemini, pi, Codex OSS) while preserving the shared command interface. Provider defaults live in versioned YAML, and a health-check command verifies local readiness.
|
|
19
|
+
|
|
20
|
+
## How It Works
|
|
21
|
+
|
|
22
|
+
1. The gem registers CLI provider clients on require, making them available to [ace-llm](../ace-llm) automatically.
|
|
23
|
+
2. Provider defaults are read from `.ace-defaults/llm/providers/*.yml` and can be overridden through the [ace-support-config](../ace-support-config) cascade.
|
|
24
|
+
3. When [ace-llm](../ace-llm) routes a model call, the matching CLI adapter executes a subprocess command and returns a normalized response.
|
|
25
|
+
|
|
26
|
+
## Use Cases
|
|
27
|
+
|
|
28
|
+
**Use CLI-native providers through one surface** - run prompts against Claude, Codex, OpenCode, Gemini, pi, and Codex OSS via [ace-llm](../ace-llm) without changing calling conventions.
|
|
29
|
+
|
|
30
|
+
**Keep provider configuration in versioned YAML** - tune model behavior and provider settings through [ace-support-config](../ace-support-config) instead of custom glue code.
|
|
31
|
+
|
|
32
|
+
**Diagnose local provider readiness** - run `ace-llm-providers-cli-check` to verify that CLI tools are installed, authenticated, and reachable before starting work.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
Part of [ACE](https://github.com/cs3b/ace)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
desc "Run tests using ace-test"
|
|
7
|
+
task :test do
|
|
8
|
+
sh "ace-test"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "Run tests directly (CI mode)"
|
|
12
|
+
Minitest::TestTask.create(:ci)
|
|
13
|
+
|
|
14
|
+
task default: :test
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Handle --help explicitly before loading dependencies
|
|
5
|
+
if ARGV.include?("--help") || ARGV.include?("-h") || ARGV.include?("help")
|
|
6
|
+
puts <<~HELP
|
|
7
|
+
Usage: ace-llm-providers-cli-check
|
|
8
|
+
|
|
9
|
+
Checks availability and authentication status of CLI-based LLM tools.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-h, --help Display this help message
|
|
13
|
+
|
|
14
|
+
This utility checks for the following CLI tools:
|
|
15
|
+
- Claude Code (claude)
|
|
16
|
+
- Codex (codex)
|
|
17
|
+
- OpenCode (opencode)
|
|
18
|
+
- Codex OSS (codex-oss)
|
|
19
|
+
|
|
20
|
+
Exit codes:
|
|
21
|
+
0 - At least one CLI tool is available
|
|
22
|
+
1 - No CLI tools are available
|
|
23
|
+
HELP
|
|
24
|
+
exit 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "../lib/ace/llm/providers/cli/molecules/health_checker"
|
|
28
|
+
|
|
29
|
+
checker = Ace::Llm::Providers::Cli::Molecules::HealthChecker.new
|
|
30
|
+
results = checker.check_all
|
|
31
|
+
|
|
32
|
+
puts "🔍 Checking CLI-based LLM providers for ace-llm-providers-cli\n\n"
|
|
33
|
+
|
|
34
|
+
max_name_length = results.map { |r| r[:name].length }.max
|
|
35
|
+
|
|
36
|
+
results.each do |result|
|
|
37
|
+
name_padded = result[:name].ljust(max_name_length)
|
|
38
|
+
status_icon = result[:available] ? "✅" : "❌"
|
|
39
|
+
auth_icon = result[:authenticated] ? "🔓" : "🔒"
|
|
40
|
+
config = result[:config]
|
|
41
|
+
|
|
42
|
+
puts "#{status_icon} #{name_padded} (#{result[:provider]})"
|
|
43
|
+
|
|
44
|
+
if result[:available]
|
|
45
|
+
puts " Version: #{result[:version]}"
|
|
46
|
+
puts " Auth: #{auth_icon} #{result[:auth_status]}"
|
|
47
|
+
else
|
|
48
|
+
puts " Status: Not installed"
|
|
49
|
+
puts " Install: #{config[:install_cmd]}"
|
|
50
|
+
puts " URL: #{config[:install_url]}" if config[:install_url]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Summary
|
|
57
|
+
available_count = results.count { |r| r[:available] }
|
|
58
|
+
authenticated_count = results.count { |r| r[:authenticated] }
|
|
59
|
+
|
|
60
|
+
puts "─" * 50
|
|
61
|
+
puts "\n📊 Summary:"
|
|
62
|
+
puts " Available: #{available_count}/#{results.size} CLI tools installed"
|
|
63
|
+
puts " Authenticated: #{authenticated_count}/#{available_count} tools authenticated" if available_count > 0
|
|
64
|
+
|
|
65
|
+
if available_count == 0
|
|
66
|
+
puts "\n💡 To use CLI providers, install at least one tool from above."
|
|
67
|
+
puts " After installation, run this check again to verify authentication."
|
|
68
|
+
elsif authenticated_count < available_count
|
|
69
|
+
puts "\n💡 Some tools need authentication. Run the auth commands shown above."
|
|
70
|
+
else
|
|
71
|
+
puts "\n🎉 All installed CLI tools are ready to use with ace-llm!"
|
|
72
|
+
puts " Example: ace-llm claude:opus \"Hello, Claude!\""
|
|
73
|
+
puts " Or using alias: ace-llm cc \"Hello, Claude!\""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
exit(available_count == 0 ? 1 : 0)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Providers
|
|
8
|
+
module CLI
|
|
9
|
+
module Atoms
|
|
10
|
+
class ArgsNormalizer
|
|
11
|
+
def normalize_cli_args(cli_args)
|
|
12
|
+
return [] if cli_args.nil?
|
|
13
|
+
|
|
14
|
+
args = begin
|
|
15
|
+
if cli_args.is_a?(String)
|
|
16
|
+
Shellwords.split(cli_args)
|
|
17
|
+
else
|
|
18
|
+
normalize_cli_arg_array(cli_args)
|
|
19
|
+
end
|
|
20
|
+
rescue ArgumentError => e
|
|
21
|
+
raise ArgumentError, "Malformed --cli-args '#{cli_args}': #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
args = normalize_arg_tokens(args)
|
|
25
|
+
|
|
26
|
+
normalized = []
|
|
27
|
+
previous_flag = false
|
|
28
|
+
seen_sentinel = false
|
|
29
|
+
|
|
30
|
+
args.each do |arg|
|
|
31
|
+
if seen_sentinel
|
|
32
|
+
normalized << arg
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if arg == "--"
|
|
37
|
+
normalized << arg
|
|
38
|
+
seen_sentinel = true
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if arg.start_with?("-")
|
|
43
|
+
normalized << arg
|
|
44
|
+
previous_flag = !arg.include?("=")
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
normalized << if previous_flag
|
|
49
|
+
arg
|
|
50
|
+
else
|
|
51
|
+
"--#{arg}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
previous_flag = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
normalized
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def normalize_cli_arg_array(cli_args)
|
|
63
|
+
Array(cli_args).flat_map do |arg|
|
|
64
|
+
next [] if arg.nil?
|
|
65
|
+
next [""] if arg == ""
|
|
66
|
+
|
|
67
|
+
Shellwords.split(arg.to_s)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_arg_tokens(args)
|
|
72
|
+
args.compact.map do |arg|
|
|
73
|
+
string_arg = arg.to_s
|
|
74
|
+
string_arg.empty? ? string_arg : string_arg.strip
|
|
75
|
+
end.reject { |arg| arg != "" && arg.empty? }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Llm
|
|
7
|
+
module Providers
|
|
8
|
+
module Cli
|
|
9
|
+
module Atoms
|
|
10
|
+
# Checks authentication status for CLI-based LLM providers
|
|
11
|
+
class AuthChecker
|
|
12
|
+
# Check authentication for a specific provider
|
|
13
|
+
# @param provider [String] Provider name (claude, codex, opencode, codexoss)
|
|
14
|
+
# @return [Hash] Result with :authenticated and :message
|
|
15
|
+
def self.check(provider)
|
|
16
|
+
case provider
|
|
17
|
+
when "claude" then check_claude
|
|
18
|
+
when "codex" then check_codex
|
|
19
|
+
when "opencode" then check_opencode
|
|
20
|
+
when "codexoss" then check_codexoss
|
|
21
|
+
else
|
|
22
|
+
{authenticated: false, message: "Unknown provider: #{provider}"}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.check_claude
|
|
27
|
+
stdout, _, status = Open3.capture3("claude", "--version")
|
|
28
|
+
if status.success? && (stdout.include?("Claude") || stdout.include?("claude"))
|
|
29
|
+
{authenticated: true, message: "Authenticated"}
|
|
30
|
+
else
|
|
31
|
+
{authenticated: false, message: "Run: claude setup-token"}
|
|
32
|
+
end
|
|
33
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
34
|
+
{authenticated: false, message: "Authentication check failed"}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.check_codex
|
|
38
|
+
_, _, status = Open3.capture3("codex", "--help")
|
|
39
|
+
if status.success?
|
|
40
|
+
{authenticated: true, message: "Authenticated"}
|
|
41
|
+
else
|
|
42
|
+
{authenticated: false, message: "Run: codex login"}
|
|
43
|
+
end
|
|
44
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
45
|
+
{authenticated: false, message: "Authentication check failed"}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.check_opencode
|
|
49
|
+
_, _, status = Open3.capture3("opencode", "--version")
|
|
50
|
+
if status.success?
|
|
51
|
+
{authenticated: true, message: "Authenticated"}
|
|
52
|
+
else
|
|
53
|
+
{authenticated: false, message: "Run: opencode auth"}
|
|
54
|
+
end
|
|
55
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
56
|
+
{authenticated: false, message: "Authentication check failed"}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.check_codexoss
|
|
60
|
+
stdout, _, status = Open3.capture3("codex-oss", "--version")
|
|
61
|
+
if status.success? && stdout.include?("codex")
|
|
62
|
+
{authenticated: true, message: "Configured"}
|
|
63
|
+
else
|
|
64
|
+
{authenticated: false, message: "Run: codex-oss init"}
|
|
65
|
+
end
|
|
66
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
67
|
+
{authenticated: false, message: "Configuration check failed"}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Providers
|
|
6
|
+
module CLI
|
|
7
|
+
module Atoms
|
|
8
|
+
module CommandFormatters
|
|
9
|
+
# Pi CLI: `/ace-git-commit` → `/skill:ace-git-commit`
|
|
10
|
+
PI_FORMATTER = ->(name) { "/skill:#{name}" }
|
|
11
|
+
|
|
12
|
+
# Codex CLI: `/ace-git-commit` → `$ace-git-commit`
|
|
13
|
+
CODEX_FORMATTER = ->(name) { "$#{name}" }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Providers
|
|
6
|
+
module CLI
|
|
7
|
+
module Atoms
|
|
8
|
+
# Provider-agnostic skill/command rewriter.
|
|
9
|
+
# Transforms commands based on a formatter proc.
|
|
10
|
+
#
|
|
11
|
+
# Rules:
|
|
12
|
+
# - Only matches `/name` preceded by start-of-line or whitespace
|
|
13
|
+
# - Skips matches inside backtick code blocks (inline or fenced)
|
|
14
|
+
# - Skips URL-like patterns
|
|
15
|
+
# - Matches longest names first (avoids partial matches)
|
|
16
|
+
class CommandRewriter
|
|
17
|
+
# Rewrite command references in a prompt string.
|
|
18
|
+
#
|
|
19
|
+
# @param prompt [String] The prompt text to rewrite
|
|
20
|
+
# @param skill_names [Array<String>] Known command names
|
|
21
|
+
# @param formatter [Proc] Transformation proc (e.g., ->(name) { "/skill:#{name}" })
|
|
22
|
+
# @return [String] Rewritten prompt
|
|
23
|
+
def self.call(prompt, skill_names:, formatter:)
|
|
24
|
+
return prompt if prompt.nil? || prompt.empty?
|
|
25
|
+
return prompt if skill_names.nil? || skill_names.empty?
|
|
26
|
+
|
|
27
|
+
sorted_names = skill_names.sort_by { |n| -n.length }
|
|
28
|
+
name_pattern = sorted_names.map { |n| Regexp.escape(n) }.join("|")
|
|
29
|
+
pattern = /(?<=\A|\s)\/(#{name_pattern})(?=\s|\z)/m
|
|
30
|
+
|
|
31
|
+
rewrite_outside_code_blocks(prompt, pattern, formatter)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.rewrite_outside_code_blocks(prompt, pattern, formatter)
|
|
35
|
+
lines = prompt.split("\n", -1)
|
|
36
|
+
in_fenced_block = false
|
|
37
|
+
result = []
|
|
38
|
+
|
|
39
|
+
lines.each do |line|
|
|
40
|
+
if line.match?(/\A\s*```/)
|
|
41
|
+
in_fenced_block = !in_fenced_block
|
|
42
|
+
result << line
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if in_fenced_block
|
|
47
|
+
result << line
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
result << rewrite_line(line, pattern, formatter)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.rewrite_line(line, pattern, formatter)
|
|
58
|
+
segments = line.split(/(`[^`]*`)/)
|
|
59
|
+
|
|
60
|
+
segments.each_with_index.map do |segment, i|
|
|
61
|
+
if i.odd?
|
|
62
|
+
segment
|
|
63
|
+
else
|
|
64
|
+
segment.gsub(pattern) { formatter.call(Regexp.last_match(1)) }
|
|
65
|
+
end
|
|
66
|
+
end.join
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :rewrite_outside_code_blocks, :rewrite_line
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Providers
|
|
6
|
+
module CLI
|
|
7
|
+
module Atoms
|
|
8
|
+
# Resolves filesystem execution context for CLI-backed providers.
|
|
9
|
+
module ExecutionContext
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def resolve_working_dir(working_dir: nil, subprocess_env: nil)
|
|
13
|
+
explicit = working_dir.to_s.strip
|
|
14
|
+
return File.expand_path(explicit) unless explicit.empty?
|
|
15
|
+
|
|
16
|
+
env = subprocess_env.respond_to?(:to_h) ? subprocess_env.to_h : {}
|
|
17
|
+
project_root = env["PROJECT_ROOT_PATH"] || env[:PROJECT_ROOT_PATH]
|
|
18
|
+
project_root = project_root.to_s.strip
|
|
19
|
+
return File.expand_path(project_root) unless project_root.empty?
|
|
20
|
+
|
|
21
|
+
Dir.pwd
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Llm
|
|
7
|
+
module Providers
|
|
8
|
+
module Cli
|
|
9
|
+
module Atoms
|
|
10
|
+
# Detects whether a CLI tool is installed and retrieves its version
|
|
11
|
+
class ProviderDetector
|
|
12
|
+
# Check if a CLI tool is available on the system
|
|
13
|
+
# @param cli_name [String] Name of the CLI tool
|
|
14
|
+
# @return [Boolean] true if the tool is found in PATH
|
|
15
|
+
def self.available?(cli_name)
|
|
16
|
+
system("which", cli_name, out: File::NULL, err: File::NULL)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get the version of a CLI tool
|
|
20
|
+
# @param check_cmd [Array<String>] Command to run for version check
|
|
21
|
+
# @return [String] Version string or "Unknown"
|
|
22
|
+
def self.version(check_cmd)
|
|
23
|
+
stdout, _, status = Open3.capture3(*check_cmd)
|
|
24
|
+
return "Unknown" unless status.success?
|
|
25
|
+
|
|
26
|
+
extract_version(stdout)
|
|
27
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
28
|
+
"Unknown"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extract version number from command output
|
|
32
|
+
# @param output [String] Raw command output
|
|
33
|
+
# @return [String] Extracted version or first line
|
|
34
|
+
def self.extract_version(output)
|
|
35
|
+
if output =~ /(\d+\.\d+\.\d+)/
|
|
36
|
+
$1
|
|
37
|
+
elsif output =~ /v(\d+\.\d+)/
|
|
38
|
+
$1
|
|
39
|
+
else
|
|
40
|
+
output.lines.first&.strip || "Unknown"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Providers
|
|
8
|
+
module CLI
|
|
9
|
+
module Atoms
|
|
10
|
+
module SessionFinders
|
|
11
|
+
# Finds a Claude Code session by scanning JSONL session files.
|
|
12
|
+
#
|
|
13
|
+
# Claude stores sessions under ~/.claude/projects/<encoded-path>/*.jsonl
|
|
14
|
+
# Path encoding: replace `/` and `.` with `-`
|
|
15
|
+
# Session ID: `sessionId` field on conversation entries
|
|
16
|
+
# First user message: `type:"user"` with `message.content` string
|
|
17
|
+
class ClaudeSessionFinder
|
|
18
|
+
DEFAULT_BASE = File.expand_path("~/.claude/projects").freeze
|
|
19
|
+
|
|
20
|
+
# @param working_dir [String] project directory to match
|
|
21
|
+
# @param prompt [String] expected first user message
|
|
22
|
+
# @param base_path [String] override base path for testing
|
|
23
|
+
# @param max_candidates [Integer] max files to scan
|
|
24
|
+
# @return [Hash, nil] { session_id:, session_path: } or nil
|
|
25
|
+
def self.call(working_dir:, prompt:, base_path: DEFAULT_BASE, max_candidates: 5)
|
|
26
|
+
encoded = encode_path(working_dir)
|
|
27
|
+
project_dir = File.join(base_path, encoded)
|
|
28
|
+
return nil unless File.directory?(project_dir)
|
|
29
|
+
|
|
30
|
+
candidates = Dir.glob(File.join(project_dir, "*.jsonl"))
|
|
31
|
+
.sort_by { |f| -File.mtime(f).to_f }
|
|
32
|
+
.first(max_candidates)
|
|
33
|
+
|
|
34
|
+
candidates.each do |path|
|
|
35
|
+
result = scan_file(path, prompt)
|
|
36
|
+
return result if result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
rescue
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.encode_path(path)
|
|
45
|
+
path.gsub(%r{[/.]}, "-")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.scan_file(path, prompt)
|
|
49
|
+
session_id = nil
|
|
50
|
+
File.foreach(path) do |line|
|
|
51
|
+
entry = JSON.parse(line)
|
|
52
|
+
session_id ||= entry["sessionId"]
|
|
53
|
+
|
|
54
|
+
next unless entry["type"] == "user"
|
|
55
|
+
|
|
56
|
+
content = entry.dig("message", "content")
|
|
57
|
+
content = content.first["text"] if content.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
# Substring match: Claude wraps user input with prefixes (e.g. "User: "),
|
|
60
|
+
# so exact equality would miss valid sessions.
|
|
61
|
+
if content.is_a?(String) && content.include?(prompt.strip)
|
|
62
|
+
return {session_id: session_id, session_path: path}
|
|
63
|
+
end
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
rescue
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :encode_path, :scan_file
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Providers
|
|
8
|
+
module CLI
|
|
9
|
+
module Atoms
|
|
10
|
+
module SessionFinders
|
|
11
|
+
# Finds a Codex session by scanning JSONL session files.
|
|
12
|
+
#
|
|
13
|
+
# Codex stores sessions under ~/.codex/sessions/YYYY/MM/DD/*.jsonl
|
|
14
|
+
# Session ID: `session_meta.payload.id`
|
|
15
|
+
# First user message: `payload.role:"user"` + `payload.content[0].text`
|
|
16
|
+
class CodexSessionFinder
|
|
17
|
+
DEFAULT_BASE = File.expand_path("~/.codex/sessions").freeze
|
|
18
|
+
|
|
19
|
+
# @param working_dir [String] project directory (unused for path encoding, kept for interface)
|
|
20
|
+
# @param prompt [String] expected first user message
|
|
21
|
+
# @param base_path [String] override base path for testing
|
|
22
|
+
# @param max_candidates [Integer] max files to scan
|
|
23
|
+
# @return [Hash, nil] { session_id:, session_path: } or nil
|
|
24
|
+
def self.call(working_dir:, prompt:, base_path: DEFAULT_BASE, max_candidates: 5)
|
|
25
|
+
candidates = recent_session_files(base_path)
|
|
26
|
+
.first(max_candidates)
|
|
27
|
+
|
|
28
|
+
candidates.each do |path|
|
|
29
|
+
result = scan_file(path, prompt)
|
|
30
|
+
return result if result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
nil
|
|
34
|
+
rescue
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.recent_session_files(base_path)
|
|
39
|
+
return [] unless File.directory?(base_path)
|
|
40
|
+
|
|
41
|
+
Dir.glob(File.join(base_path, "**", "*.jsonl"))
|
|
42
|
+
.sort_by { |f| -File.mtime(f).to_f }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.scan_file(path, prompt)
|
|
46
|
+
session_id = nil
|
|
47
|
+
File.foreach(path) do |line|
|
|
48
|
+
entry = JSON.parse(line)
|
|
49
|
+
|
|
50
|
+
# Extract session ID from session_meta entry
|
|
51
|
+
if entry.dig("session_meta", "payload", "id")
|
|
52
|
+
session_id = entry.dig("session_meta", "payload", "id")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check for user message match
|
|
56
|
+
payload = entry["payload"] || entry
|
|
57
|
+
next unless payload["role"] == "user"
|
|
58
|
+
|
|
59
|
+
content = payload["content"]
|
|
60
|
+
text = if content.is_a?(Array)
|
|
61
|
+
content.first&.dig("text")
|
|
62
|
+
elsif content.is_a?(String)
|
|
63
|
+
content
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if text.is_a?(String) && text.strip == prompt.strip
|
|
67
|
+
return {session_id: session_id, session_path: path}
|
|
68
|
+
end
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
nil
|
|
73
|
+
rescue
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :recent_session_files, :scan_file
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Providers
|
|
9
|
+
module CLI
|
|
10
|
+
module Atoms
|
|
11
|
+
module SessionFinders
|
|
12
|
+
# Finds a Gemini CLI session by scanning JSON chat files.
|
|
13
|
+
#
|
|
14
|
+
# Gemini stores sessions under ~/.gemini/tmp/<sha256>/chats/*.json
|
|
15
|
+
# Directory name: SHA256 of the absolute working directory path
|
|
16
|
+
# Session ID: `sessionId` field in the JSON file
|
|
17
|
+
# First user message: `messages[0].content[0].text`
|
|
18
|
+
class GeminiSessionFinder
|
|
19
|
+
DEFAULT_BASE = File.expand_path("~/.gemini/tmp").freeze
|
|
20
|
+
|
|
21
|
+
# @param working_dir [String] project directory to match
|
|
22
|
+
# @param prompt [String] expected first user message
|
|
23
|
+
# @param base_path [String] override base path for testing
|
|
24
|
+
# @param max_candidates [Integer] max files to scan
|
|
25
|
+
# @return [Hash, nil] { session_id:, session_path: } or nil
|
|
26
|
+
def self.call(working_dir:, prompt:, base_path: DEFAULT_BASE, max_candidates: 5)
|
|
27
|
+
dir_hash = Digest::SHA256.hexdigest(File.expand_path(working_dir))
|
|
28
|
+
chats_dir = File.join(base_path, dir_hash, "chats")
|
|
29
|
+
return nil unless File.directory?(chats_dir)
|
|
30
|
+
|
|
31
|
+
candidates = Dir.glob(File.join(chats_dir, "*.json"))
|
|
32
|
+
.sort_by { |f| -File.mtime(f).to_f }
|
|
33
|
+
.first(max_candidates)
|
|
34
|
+
|
|
35
|
+
candidates.each do |path|
|
|
36
|
+
result = scan_file(path, prompt)
|
|
37
|
+
return result if result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
rescue
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.scan_file(path, prompt)
|
|
46
|
+
data = JSON.parse(File.read(path))
|
|
47
|
+
session_id = data["sessionId"]
|
|
48
|
+
|
|
49
|
+
first_message = data.dig("messages", 0, "content", 0, "text")
|
|
50
|
+
if first_message.is_a?(String) && first_message.strip == prompt.strip
|
|
51
|
+
return {session_id: session_id, session_path: path}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
nil
|
|
55
|
+
rescue
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private_class_method :scan_file
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|