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
|
@@ -0,0 +1,119 @@
|
|
|
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 an OpenCode session by scanning its JSON storage.
|
|
12
|
+
#
|
|
13
|
+
# OpenCode stores data under ~/.local/share/opencode/storage/
|
|
14
|
+
# Project mapping: `project/*.json` with `worktree` field matching working_dir
|
|
15
|
+
# Session: `session/<hash>/*.json` with `id` (ses_ prefix)
|
|
16
|
+
# Messages: 3-level chain: `message/<sid>/` -> `part/<mid>/` -> text content
|
|
17
|
+
class OpenCodeSessionFinder
|
|
18
|
+
DEFAULT_BASE = File.expand_path("~/.local/share/opencode/storage").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 sessions 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
|
+
# Verify the working_dir is a known OpenCode project (nil-gate).
|
|
27
|
+
# OpenCode sessions don't store a project reference, so we can't filter
|
|
28
|
+
# sessions by project — prompt matching is the primary identification.
|
|
29
|
+
project_id = find_project_id(base_path, working_dir)
|
|
30
|
+
return nil unless project_id
|
|
31
|
+
|
|
32
|
+
sessions = find_sessions(base_path, max_candidates)
|
|
33
|
+
|
|
34
|
+
sessions.each do |session_path, session_data|
|
|
35
|
+
session_id = session_data["id"]
|
|
36
|
+
next unless session_id
|
|
37
|
+
|
|
38
|
+
if first_message_matches?(base_path, session_id, prompt)
|
|
39
|
+
return {session_id: session_id, session_path: session_path}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
rescue
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.find_project_id(base_path, working_dir)
|
|
49
|
+
project_dir = File.join(base_path, "project")
|
|
50
|
+
return nil unless File.directory?(project_dir)
|
|
51
|
+
|
|
52
|
+
expanded = File.expand_path(working_dir)
|
|
53
|
+
Dir.glob(File.join(project_dir, "*.json")).each do |path|
|
|
54
|
+
data = JSON.parse(File.read(path))
|
|
55
|
+
return data["id"] if data["worktree"] == expanded
|
|
56
|
+
rescue
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.find_sessions(base_path, max_candidates)
|
|
63
|
+
session_base = File.join(base_path, "session")
|
|
64
|
+
return [] unless File.directory?(session_base)
|
|
65
|
+
|
|
66
|
+
Dir.glob(File.join(session_base, "**", "*.json"))
|
|
67
|
+
.sort_by { |f| -File.mtime(f).to_f }
|
|
68
|
+
.first(max_candidates)
|
|
69
|
+
.filter_map do |path|
|
|
70
|
+
data = JSON.parse(File.read(path))
|
|
71
|
+
[path, data]
|
|
72
|
+
rescue
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.first_message_matches?(base_path, session_id, prompt)
|
|
78
|
+
message_dir = File.join(base_path, "message", session_id)
|
|
79
|
+
return false unless File.directory?(message_dir)
|
|
80
|
+
|
|
81
|
+
# Find the earliest message file
|
|
82
|
+
message_files = Dir.glob(File.join(message_dir, "*.json"))
|
|
83
|
+
.sort_by { |f| File.mtime(f).to_f }
|
|
84
|
+
|
|
85
|
+
message_files.each do |msg_path|
|
|
86
|
+
msg_data = JSON.parse(File.read(msg_path))
|
|
87
|
+
next unless msg_data["role"] == "user"
|
|
88
|
+
|
|
89
|
+
message_id = msg_data["id"]
|
|
90
|
+
next unless message_id
|
|
91
|
+
|
|
92
|
+
# Check parts for text content
|
|
93
|
+
part_dir = File.join(base_path, "part", message_id)
|
|
94
|
+
next unless File.directory?(part_dir)
|
|
95
|
+
|
|
96
|
+
Dir.glob(File.join(part_dir, "*.json")).each do |part_path|
|
|
97
|
+
part_data = JSON.parse(File.read(part_path))
|
|
98
|
+
text = part_data["text"]
|
|
99
|
+
return true if text.is_a?(String) && text.strip == prompt.strip
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return false
|
|
103
|
+
rescue
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
false
|
|
108
|
+
rescue
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method :find_project_id, :find_sessions, :first_message_matches?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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 Pi agent session by scanning JSONL session files.
|
|
12
|
+
#
|
|
13
|
+
# Pi stores sessions under ~/.pi/agent/sessions/<encoded-path>/*.jsonl
|
|
14
|
+
# Path encoding: replace `/` with `-`, wrap in `--`
|
|
15
|
+
# Session ID: `type:"session"` entry → `id` field
|
|
16
|
+
# First user message: `message.role:"user"` + `message.content[0].text`
|
|
17
|
+
class PiSessionFinder
|
|
18
|
+
DEFAULT_BASE = File.expand_path("~/.pi/agent/sessions").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.tr("/", "-")}--"
|
|
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
|
+
|
|
53
|
+
# Extract session ID from session-type entry
|
|
54
|
+
if entry["type"] == "session" && entry["id"]
|
|
55
|
+
session_id = entry["id"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check for user message match
|
|
59
|
+
message = entry["message"]
|
|
60
|
+
next unless message.is_a?(Hash) && message["role"] == "user"
|
|
61
|
+
|
|
62
|
+
content = message["content"]
|
|
63
|
+
text = if content.is_a?(Array)
|
|
64
|
+
content.first&.dig("text")
|
|
65
|
+
elsif content.is_a?(String)
|
|
66
|
+
content
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if text.is_a?(String) && text.strip == prompt.strip
|
|
70
|
+
return {session_id: session_id, session_path: path}
|
|
71
|
+
end
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
rescue
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method :encode_path, :scan_file
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_rewriter"
|
|
4
|
+
require_relative "command_formatters"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Providers
|
|
9
|
+
module CLI
|
|
10
|
+
module Atoms
|
|
11
|
+
# Convenience wrapper for Pi-style skill rewriting.
|
|
12
|
+
# Delegates to CommandRewriter with PI_FORMATTER.
|
|
13
|
+
#
|
|
14
|
+
# Transforms `/name` → `/skill:name` for known skill names,
|
|
15
|
+
# enabling Pi CLI to discover and invoke skills correctly.
|
|
16
|
+
class SkillCommandRewriter
|
|
17
|
+
# Rewrite skill command references in a prompt string.
|
|
18
|
+
#
|
|
19
|
+
# @param prompt [String] The prompt text to rewrite
|
|
20
|
+
# @param skill_names [Array<String>] Known skill names (e.g. ["ace-onboard", "ace-git-commit"])
|
|
21
|
+
# @return [String] Prompt with `/name` rewritten to `/skill:name`
|
|
22
|
+
def self.call(prompt, skill_names:)
|
|
23
|
+
CommandRewriter.call(prompt, skill_names: skill_names, formatter: CommandFormatters::PI_FORMATTER)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Providers
|
|
6
|
+
module CLI
|
|
7
|
+
module Atoms
|
|
8
|
+
# Detects if the current directory is a git worktree and returns
|
|
9
|
+
# the common git dir path that needs to be writable for sandbox tools.
|
|
10
|
+
#
|
|
11
|
+
# In a worktree, `.git` is a file containing `gitdir: <path>` pointing
|
|
12
|
+
# to the worktree metadata under the parent repo's `.git/worktrees/`.
|
|
13
|
+
# The parent `.git/` directory must be writable for index.lock etc.
|
|
14
|
+
class WorktreeDirResolver
|
|
15
|
+
# @param working_dir [String] directory to check (default: Dir.pwd)
|
|
16
|
+
# @return [String, nil] path to common .git dir, or nil if not a worktree
|
|
17
|
+
def self.call(working_dir: Dir.pwd)
|
|
18
|
+
new.call(working_dir: working_dir)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(working_dir: Dir.pwd)
|
|
22
|
+
dot_git = File.join(working_dir, ".git")
|
|
23
|
+
|
|
24
|
+
# If .git is a directory (normal repo) or doesn't exist, not a worktree
|
|
25
|
+
return nil unless File.file?(dot_git)
|
|
26
|
+
|
|
27
|
+
content = File.read(dot_git).strip
|
|
28
|
+
return nil unless content.start_with?("gitdir:")
|
|
29
|
+
|
|
30
|
+
gitdir = content.sub(/\Agitdir:\s*/, "")
|
|
31
|
+
|
|
32
|
+
# Resolve relative paths against working_dir
|
|
33
|
+
gitdir = File.expand_path(gitdir, working_dir) unless gitdir.start_with?("/")
|
|
34
|
+
|
|
35
|
+
# Walk up from gitdir to find the common .git/ directory
|
|
36
|
+
# Typical path: /repo/.git/worktrees/<name> → we want /repo/.git
|
|
37
|
+
path = gitdir
|
|
38
|
+
while path != "/" && path != "."
|
|
39
|
+
basename = File.basename(path)
|
|
40
|
+
parent = File.dirname(path)
|
|
41
|
+
|
|
42
|
+
if basename == "worktrees" && File.directory?(File.join(parent, "refs"))
|
|
43
|
+
return parent
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
path = parent
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
require_relative "cli_args_support"
|
|
8
|
+
require_relative "atoms/execution_context"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module LLM
|
|
12
|
+
module Providers
|
|
13
|
+
module CLI
|
|
14
|
+
# Client for interacting with Claude Code via the Claude CLI
|
|
15
|
+
# Provides access to Claude Code models through subprocess execution
|
|
16
|
+
class ClaudeCodeClient < Ace::LLM::Organisms::BaseClient
|
|
17
|
+
include CliArgsSupport
|
|
18
|
+
|
|
19
|
+
# Not used for CLI interaction but required by BaseClient
|
|
20
|
+
API_BASE_URL = "https://claude.ai"
|
|
21
|
+
DEFAULT_GENERATION_CONFIG = {}.freeze
|
|
22
|
+
|
|
23
|
+
# Provider registration - auto-registers as "claude"
|
|
24
|
+
def self.provider_name
|
|
25
|
+
"claude"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Default model (can be overridden by config)
|
|
29
|
+
DEFAULT_MODEL = "claude-sonnet-4-0"
|
|
30
|
+
|
|
31
|
+
def initialize(model: nil, **options)
|
|
32
|
+
@model = model || DEFAULT_MODEL
|
|
33
|
+
# Skip normal BaseClient initialization that requires API key
|
|
34
|
+
@options = options
|
|
35
|
+
@generation_config = options[:generation_config] || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Override to indicate this client doesn't need API credentials
|
|
39
|
+
def needs_credentials?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generate a response from the LLM
|
|
44
|
+
# @param messages [Array<Hash>] Conversation messages
|
|
45
|
+
# @param options [Hash] Generation options
|
|
46
|
+
# @return [Hash] Response with text and metadata
|
|
47
|
+
def generate(messages, **options)
|
|
48
|
+
validate_claude_availability!
|
|
49
|
+
|
|
50
|
+
# Convert messages to prompt format
|
|
51
|
+
prompt = format_messages_as_prompt(messages)
|
|
52
|
+
|
|
53
|
+
cmd = build_claude_command(options)
|
|
54
|
+
subprocess_env = options.delete(:subprocess_env)
|
|
55
|
+
working_dir = Atoms::ExecutionContext.resolve_working_dir(
|
|
56
|
+
working_dir: options[:working_dir],
|
|
57
|
+
subprocess_env: subprocess_env
|
|
58
|
+
)
|
|
59
|
+
stdout, stderr, status = execute_claude_command(
|
|
60
|
+
cmd,
|
|
61
|
+
prompt,
|
|
62
|
+
subprocess_env: subprocess_env,
|
|
63
|
+
working_dir: working_dir
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parse_claude_response(stdout, stderr, status, prompt, options)
|
|
67
|
+
rescue => e
|
|
68
|
+
handle_claude_error(e)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# List available Claude Code models
|
|
72
|
+
def list_models
|
|
73
|
+
# Return models based on what the CLI supports
|
|
74
|
+
# This is a simplified list - actual models come from YAML config
|
|
75
|
+
[
|
|
76
|
+
{id: "claude-opus-4-1", name: "Claude Opus 4.1", description: "Most capable model", context_size: 200_000},
|
|
77
|
+
{id: "claude-sonnet-4-0", name: "Claude Sonnet 4.0", description: "Balanced model", context_size: 200_000},
|
|
78
|
+
{id: "claude-3-5-haiku-latest", name: "Claude Haiku 3.5", description: "Fast model", context_size: 200_000}
|
|
79
|
+
]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def format_messages_as_prompt(messages)
|
|
85
|
+
# Handle both array of message hashes and string prompt
|
|
86
|
+
return messages if messages.is_a?(String)
|
|
87
|
+
|
|
88
|
+
# Convert array of messages to formatted prompt
|
|
89
|
+
formatted = messages.map do |msg|
|
|
90
|
+
role = msg[:role] || msg["role"]
|
|
91
|
+
content = msg[:content] || msg["content"]
|
|
92
|
+
|
|
93
|
+
case role
|
|
94
|
+
when "system"
|
|
95
|
+
"System: #{content}"
|
|
96
|
+
when "user"
|
|
97
|
+
"User: #{content}"
|
|
98
|
+
when "assistant"
|
|
99
|
+
"Assistant: #{content}"
|
|
100
|
+
else
|
|
101
|
+
content
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
formatted.join("\n\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def claude_available?
|
|
109
|
+
system("which claude > /dev/null 2>&1")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_claude_availability!
|
|
113
|
+
unless claude_available?
|
|
114
|
+
raise Ace::LLM::ProviderError, "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-cli"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if Claude is authenticated (quick check)
|
|
118
|
+
unless claude_authenticated?
|
|
119
|
+
raise Ace::LLM::AuthenticationError, "Claude authentication required. Run 'claude setup-token' to configure"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def claude_authenticated?
|
|
124
|
+
# Quick check if Claude can execute (will fail fast if not authenticated)
|
|
125
|
+
# Using a minimal test that should complete quickly
|
|
126
|
+
cmd = ["claude", "--version"]
|
|
127
|
+
stdout, _, status = Open3.capture3(*cmd)
|
|
128
|
+
status.success? && (stdout.include?("Claude") || stdout.include?("claude"))
|
|
129
|
+
rescue
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_claude_command(options)
|
|
134
|
+
cmd = ["claude"]
|
|
135
|
+
cmd << "-p"
|
|
136
|
+
|
|
137
|
+
# Always use JSON output for consistent parsing
|
|
138
|
+
cmd << "--output-format" << "json"
|
|
139
|
+
|
|
140
|
+
# Add model selection if not default
|
|
141
|
+
if @model && @model != DEFAULT_MODEL
|
|
142
|
+
cmd << "--model" << @model
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Prompt is passed via stdin to avoid exceeding Linux MAX_ARG_STRLEN
|
|
146
|
+
# (128KB per-argument limit). System content is already embedded in the
|
|
147
|
+
# formatted prompt via format_messages_as_prompt.
|
|
148
|
+
|
|
149
|
+
# Add max tokens if provided
|
|
150
|
+
max_tokens = options[:max_tokens] || @generation_config[:max_tokens]
|
|
151
|
+
if max_tokens
|
|
152
|
+
cmd << "--max-tokens" << max_tokens.to_s
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# User CLI args last so they take precedence (last-wins in most CLIs)
|
|
156
|
+
cmd.concat(normalized_cli_args(options))
|
|
157
|
+
|
|
158
|
+
cmd
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil)
|
|
162
|
+
timeout_val = @options[:timeout] || 120
|
|
163
|
+
# Clear CLAUDECODE env var so `claude -p` (non-interactive, one-shot mode)
|
|
164
|
+
# can run as a subprocess from within a Claude Code session.
|
|
165
|
+
# The guard was added in Claude Code v2.1.41 to prevent nested interactive
|
|
166
|
+
# sessions, but -p mode doesn't share session state.
|
|
167
|
+
env = {"CLAUDECODE" => nil}
|
|
168
|
+
env.merge!(subprocess_env) if subprocess_env
|
|
169
|
+
debug_subprocess("spawn timeout=#{timeout_val}s cmd=#{cmd.join(" ")} prompt_bytes=#{prompt.to_s.bytesize}")
|
|
170
|
+
Molecules::SafeCapture.call(
|
|
171
|
+
cmd,
|
|
172
|
+
timeout: timeout_val,
|
|
173
|
+
stdin_data: prompt.to_s,
|
|
174
|
+
chdir: working_dir,
|
|
175
|
+
env: env,
|
|
176
|
+
provider_name: "Claude"
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parse_claude_response(stdout, stderr, status, prompt, options)
|
|
181
|
+
unless status.success?
|
|
182
|
+
error_msg = stderr.empty? ? stdout : stderr
|
|
183
|
+
raise Ace::LLM::ProviderError, "Claude CLI failed: #{error_msg}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
# Allow duplicate keys to avoid warnings from Claude CLI output
|
|
188
|
+
# Some versions of Claude CLI may return JSON with duplicate keys
|
|
189
|
+
response = JSON.parse(stdout, allow_duplicate_key: true)
|
|
190
|
+
rescue JSON::ParserError => e
|
|
191
|
+
raise Ace::LLM::ProviderError, "Failed to parse Claude response: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if response["is_error"]
|
|
195
|
+
raise Ace::LLM::ProviderError, build_response_error(response)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Extract the text result
|
|
199
|
+
text = extract_claude_text(response)
|
|
200
|
+
|
|
201
|
+
if text.strip.empty?
|
|
202
|
+
raise Ace::LLM::ProviderError, build_response_error(response)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Build metadata
|
|
206
|
+
metadata = build_metadata(response, prompt, options)
|
|
207
|
+
|
|
208
|
+
# Return hash compatible with ace-llm format
|
|
209
|
+
{
|
|
210
|
+
text: text,
|
|
211
|
+
metadata: metadata
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def extract_claude_text(response)
|
|
216
|
+
direct_result = response["result"]
|
|
217
|
+
direct_response = response["response"]
|
|
218
|
+
direct_message = response["message"]
|
|
219
|
+
direct_text = response["text"]
|
|
220
|
+
|
|
221
|
+
candidates = [
|
|
222
|
+
direct_result,
|
|
223
|
+
direct_response,
|
|
224
|
+
direct_message,
|
|
225
|
+
direct_text,
|
|
226
|
+
response["content"]
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
candidates.each do |candidate|
|
|
230
|
+
text = extract_claude_text_value(candidate)
|
|
231
|
+
return text unless text.empty?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
""
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def extract_claude_text_value(value)
|
|
238
|
+
case value
|
|
239
|
+
when String
|
|
240
|
+
text = value.to_s.strip
|
|
241
|
+
return text unless text.empty?
|
|
242
|
+
when Hash
|
|
243
|
+
nested_candidates = [
|
|
244
|
+
value["text"],
|
|
245
|
+
value["content"],
|
|
246
|
+
value["response"],
|
|
247
|
+
value["result"]
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
nested_candidates.each do |candidate|
|
|
251
|
+
text = extract_claude_text_value(candidate)
|
|
252
|
+
return text unless text.empty?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if value["messages"].is_a?(Array)
|
|
256
|
+
text = extract_claude_text_value(value["messages"])
|
|
257
|
+
return text unless text.empty?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if value["parts"].is_a?(Array)
|
|
261
|
+
text = extract_claude_text_value(value["parts"])
|
|
262
|
+
return text unless text.empty?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if value["choices"].is_a?(Array)
|
|
266
|
+
text = extract_claude_text_value(value["choices"])
|
|
267
|
+
return text unless text.empty?
|
|
268
|
+
end
|
|
269
|
+
when Array
|
|
270
|
+
value.each do |entry|
|
|
271
|
+
text = extract_claude_text_value(entry)
|
|
272
|
+
return text unless text.empty?
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
""
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def build_metadata(response, prompt, options)
|
|
280
|
+
usage = response["usage"] || {}
|
|
281
|
+
|
|
282
|
+
# Build standard metadata structure
|
|
283
|
+
metadata = {
|
|
284
|
+
provider: "claude",
|
|
285
|
+
model: @model || DEFAULT_MODEL,
|
|
286
|
+
input_tokens: usage["input_tokens"] || 0,
|
|
287
|
+
output_tokens: usage["output_tokens"] || 0,
|
|
288
|
+
total_tokens: (usage["input_tokens"] || 0) + (usage["output_tokens"] || 0),
|
|
289
|
+
cached_tokens: usage["cache_read_input_tokens"] || 0,
|
|
290
|
+
finish_reason: response["subtype"] || "success",
|
|
291
|
+
took: (response["duration_ms"] || 0) / 1000.0,
|
|
292
|
+
timestamp: Time.now.utc.iso8601
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Add cost information if available
|
|
296
|
+
if response["total_cost_usd"]
|
|
297
|
+
metadata[:cost] = {
|
|
298
|
+
input_cost: 0.0, # Claude provides total only
|
|
299
|
+
output_cost: 0.0,
|
|
300
|
+
total_cost: response["total_cost_usd"],
|
|
301
|
+
currency: "USD"
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Add session ID if available
|
|
306
|
+
metadata[:session_id] = response["session_id"] if response["session_id"]
|
|
307
|
+
|
|
308
|
+
# Add any Claude-specific data
|
|
309
|
+
metadata[:provider_specific] = {
|
|
310
|
+
uuid: response["uuid"],
|
|
311
|
+
service_tier: usage["service_tier"],
|
|
312
|
+
duration_api_ms: response["duration_api_ms"],
|
|
313
|
+
cache_creation_tokens: usage["cache_creation_input_tokens"]
|
|
314
|
+
}.compact
|
|
315
|
+
|
|
316
|
+
metadata
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def handle_claude_error(error)
|
|
320
|
+
# Re-raise the error for proper handling by the base client error flow
|
|
321
|
+
raise error
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def build_response_error(response)
|
|
325
|
+
summary = {
|
|
326
|
+
"type" => response["type"],
|
|
327
|
+
"subtype" => response["subtype"],
|
|
328
|
+
"stop_reason" => response["stop_reason"],
|
|
329
|
+
"is_error" => response["is_error"],
|
|
330
|
+
"session_id" => response["session_id"],
|
|
331
|
+
"duration_ms" => response["duration_ms"]
|
|
332
|
+
}.compact
|
|
333
|
+
|
|
334
|
+
result_preview = response["result"].to_s.strip
|
|
335
|
+
if response["is_error"] || !result_preview.empty?
|
|
336
|
+
details = summary.map { |key, value| "#{key}=#{value}" }.join(", ")
|
|
337
|
+
message = result_preview.empty? ? "no result text provided" : result_preview
|
|
338
|
+
return "Claude CLI returned an error payload without review text: #{message} (#{details})"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
details = summary.map { |key, value| "#{key}=#{value}" }.join(", ")
|
|
342
|
+
if details.empty?
|
|
343
|
+
"Claude CLI returned empty response (exit 0 but no output text)"
|
|
344
|
+
else
|
|
345
|
+
"Claude CLI returned empty response (exit 0, no output text; #{details})"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def debug_subprocess(message)
|
|
350
|
+
return unless ENV["ACE_LLM_DEBUG_SUBPROCESS"] == "1"
|
|
351
|
+
|
|
352
|
+
warn("[ClaudeCodeClient] #{message}")
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|