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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/llm/providers/claude.yml +24 -0
  3. data/.ace-defaults/llm/providers/codex.yml +22 -0
  4. data/.ace-defaults/llm/providers/codexoss.yml +13 -0
  5. data/.ace-defaults/llm/providers/gemini.yml +32 -0
  6. data/.ace-defaults/llm/providers/opencode.yml +26 -0
  7. data/.ace-defaults/llm/providers/pi.yml +43 -0
  8. data/CHANGELOG.md +457 -0
  9. data/LICENSE +21 -0
  10. data/README.md +36 -0
  11. data/Rakefile +14 -0
  12. data/exe/ace-llm-providers-cli-check +76 -0
  13. data/lib/ace/llm/providers/cli/atoms/args_normalizer.rb +82 -0
  14. data/lib/ace/llm/providers/cli/atoms/auth_checker.rb +74 -0
  15. data/lib/ace/llm/providers/cli/atoms/command_formatters.rb +19 -0
  16. data/lib/ace/llm/providers/cli/atoms/command_rewriter.rb +75 -0
  17. data/lib/ace/llm/providers/cli/atoms/execution_context.rb +28 -0
  18. data/lib/ace/llm/providers/cli/atoms/provider_detector.rb +48 -0
  19. data/lib/ace/llm/providers/cli/atoms/session_finders/claude_session_finder.rb +79 -0
  20. data/lib/ace/llm/providers/cli/atoms/session_finders/codex_session_finder.rb +84 -0
  21. data/lib/ace/llm/providers/cli/atoms/session_finders/gemini_session_finder.rb +66 -0
  22. data/lib/ace/llm/providers/cli/atoms/session_finders/open_code_session_finder.rb +119 -0
  23. data/lib/ace/llm/providers/cli/atoms/session_finders/pi_session_finder.rb +87 -0
  24. data/lib/ace/llm/providers/cli/atoms/skill_command_rewriter.rb +30 -0
  25. data/lib/ace/llm/providers/cli/atoms/worktree_dir_resolver.rb +56 -0
  26. data/lib/ace/llm/providers/cli/claude_code_client.rb +358 -0
  27. data/lib/ace/llm/providers/cli/claude_oai_client.rb +322 -0
  28. data/lib/ace/llm/providers/cli/cli_args_support.rb +19 -0
  29. data/lib/ace/llm/providers/cli/codex_client.rb +291 -0
  30. data/lib/ace/llm/providers/cli/codex_oai_client.rb +274 -0
  31. data/lib/ace/llm/providers/cli/gemini_client.rb +346 -0
  32. data/lib/ace/llm/providers/cli/molecules/health_checker.rb +80 -0
  33. data/lib/ace/llm/providers/cli/molecules/safe_capture.rb +153 -0
  34. data/lib/ace/llm/providers/cli/molecules/session_finder.rb +44 -0
  35. data/lib/ace/llm/providers/cli/molecules/skill_name_reader.rb +64 -0
  36. data/lib/ace/llm/providers/cli/open_code_client.rb +271 -0
  37. data/lib/ace/llm/providers/cli/pi_client.rb +331 -0
  38. data/lib/ace/llm/providers/cli/version.rb +11 -0
  39. data/lib/ace/llm/providers/cli.rb +47 -0
  40. 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