ace-llm-providers-cli 0.27.2 → 0.31.2

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: 6f5ca95d3a35ffa8444237fc73e23009fbf00b11c2a7b08e96cd97471d579096
4
- data.tar.gz: 24a6c886c4c267b7cd8181453570fc6f14b18bea8a8bd6483a3dd02869a20796
3
+ metadata.gz: 0ae114fa300c121e8b2d358ae3ef2ad16e3d53bf981b11e2c2be59eb2c5c29d6
4
+ data.tar.gz: e3b9479472b714507dfb0bbb82dd10b19b3e403bfcd9fe0a107fd3194975e65a
5
5
  SHA512:
6
- metadata.gz: b564fe237a162b9eec870def80c0da8239f4137d5c8485ad28d4bfc6cb4e36eb70d6cd6940f63a9ffe5c5741109558e17c46c75106fe82174cad8d7439baea86
7
- data.tar.gz: 1fa88f5b98701c38caa0e7ddc8e4450d4fd9f7166a7647169185989c0a5b2e8759e0423d6780fd370d3d1f77bd5763dabd6ecce87fc201ed0cf19bb8eb718684
6
+ metadata.gz: 68cf79d47bbdf06e62e8e8d79a72ed5a607d249ff57cbf767696f8580066b5188ac1f3920a3bf8f3b998de290067f75d58379670472cd5b57c61d613499e8de6
7
+ data.tar.gz: f3d14332bb1d44bdf0fc8906afd6576e7349bf2ea12ac1ebeedc32d535156bce4ff948bd1323e5ee0691b0d88f365d8a4b1ec9a0b8460849867c899a8af8cc4d
@@ -2,16 +2,21 @@ name: codex
2
2
  class: Ace::LLM::Providers::CLI::CodexClient
3
3
  gem: ace-llm-providers-cli
4
4
  models:
5
- - gpt-5
6
- - gpt-5-mini
5
+ - gpt-5.3-codex
6
+ - gpt-5.3-codex-spark
7
+ - gpt-5.4
8
+ - gpt-5.4-mini
7
9
  aliases:
8
10
  global:
9
- codex-gpt5: codex:gpt-5
10
- codex-mini: codex:gpt-5-mini
11
+ codex-gpt5: codex:gpt-5.4
12
+ codex-mini: codex:gpt-5.4-mini
11
13
  model:
12
- 5: gpt-5
13
- mini: gpt-5-mini
14
- m: gpt-5-mini
14
+ 5: gpt-5.4
15
+ gpt: gpt-5.4
16
+ codex: gpt-5.3-codex
17
+ spark: gpt-5.3-codex-spark
18
+ mini: gpt-5.4-mini
19
+ m: gpt-5.4-mini
15
20
  capabilities:
16
21
  - text_generation
17
22
  - code_generation
@@ -19,7 +19,9 @@ aliases:
19
19
  2.0-flash: gemini-2.0-flash
20
20
  1.5-pro: gemini-1.5-pro-latest
21
21
  flash: gemini-2.5-flash
22
+ flash-latest: gemini-2.5-flash
22
23
  pro: gemini-2.5-pro
24
+ pro-latest: gemini-2.5-pro
23
25
  capabilities:
24
26
  - text_generation
25
27
  - code_generation
data/CHANGELOG.md CHANGED
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.31.2] - 2026-04-16
11
+
12
+ ### Fixed
13
+ - Updated shipped Codex defaults from the rejected `gpt-5-mini` alias path to the current `gpt-5.4` / `gpt-5.4-mini` catalog and aligned the CLI client metadata with those model IDs.
14
+ - Added shipped `flash-latest` and `pro-latest` Gemini aliases backed by declared default models so fresh-provider fallback chains no longer target missing Gemini aliases.
15
+
16
+ ## [0.31.1] - 2026-04-16
17
+
18
+ ### Fixed
19
+ - Updated `TS-LLMCLI-001` sandbox setup to source `mise.toml` from `${ACE_E2E_SOURCE_ROOT:-$PROJECT_ROOT_PATH}`, keeping CLI smoke runs compatible with read-only source-root mounts.
20
+
21
+ ## [0.31.0] - 2026-04-15
22
+
23
+ ### Changed
24
+ - Reworked `TS-LLMCLI-001` to public-surface goal-style guidance by removing hidden `which` interception,
25
+ introducing deterministic sandbox-safe runtime/stub setup, and clarifying provider-discovery harness rules.
26
+
27
+ ### Technical
28
+ - Added E2E verification lifecycle metadata guidance (`Last verified`, `Verified by`, refresh trigger) in
29
+ scenario decision/verifier docs.
30
+ - Updated package README testing guidance for deterministic provider-discovery harness behavior.
31
+
32
+ ## [0.30.0] - 2026-04-15
33
+
34
+ ### Fixed
35
+ - Added a Codex tmux startup overlay that trusts the exact working directory through a temporary per-workspace Codex home, preventing fresh tmux sandboxes from blocking on the trust prompt before agent work begins.
36
+
37
+ ### Changed
38
+ - Switched tmux-facing Codex launches to reuse the shared `ace-llm --interactive` entrypoint, so provider skill translation, alias resolution, preset handling, and startup policy all flow through one path.
39
+
40
+ ## [0.29.0] - 2026-04-15
41
+
42
+ ### Added
43
+ - Added native interactive invocation builders for Codex, Claude Code, and pi so CLI providers can be launched without their one-shot print/exec flags while still receiving translated ACE skill handoffs.
44
+
45
+ ### Technical
46
+ - Added shared CLI-arg conflict guards plus Claude slash-command formatting support for the new interactive launch path.
47
+
48
+ ## [0.28.6] - 2026-04-13
49
+
50
+ ### Fixed
51
+ - Cleared `CLAUDECODE` during Claude help capability probes so nested Claude Code sessions no longer cache false negatives for `--max-tokens` support.
52
+
53
+ ### Technical
54
+ - Added regression coverage for the help-probe environment handling.
55
+
56
+ ## [0.28.5] - 2026-04-13
57
+
58
+ ### Fixed
59
+ - Avoided eager Claude CLI capability probes during normal command construction so non-`max-tokens` requests no longer shell out to `claude --help`.
60
+
61
+ ### Technical
62
+ - Restored deterministic command-builder coverage by guarding common-path unit tests from unexpected capability probing.
63
+
64
+ ## [0.28.4] - 2026-04-13
65
+
66
+ ### Fixed
67
+ - Filter forwarded Claude `--max-tokens` CLI args when the installed Claude CLI does not advertise support, preventing explicit passthrough flags from reintroducing unsupported-option failures.
68
+
69
+ ## [0.28.3] - 2026-04-13
70
+
71
+ ### Fixed
72
+ - Guarded Claude CLI `--max-tokens` usage so commit-generation flows skip the flag on unsupported Claude versions while preserving it when available.
73
+
74
+ ### Technical
75
+ - Added command-construction coverage for supported and unsupported Claude `--max-tokens` capability paths.
76
+
77
+ ## [0.28.2] - 2026-04-13
78
+
79
+ ### Technical
80
+ - Tightened the package version contract test to require a full semantic-version match instead of accepting partial matches.
81
+ ## [0.28.1] - 2026-04-13
82
+
83
+ ### Changed
84
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
85
+
86
+ ### Technical
87
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
88
+
89
+
90
+ ### Changed
91
+ - Migrated package tests to the `fast` / `feat` / `e2e` model and moved deterministic coverage into `test/fast` and `test/feat`.
92
+ - Updated `TS-LLMCLI-001` scenario metadata and decision records to reference deterministic fast coverage locations.
93
+ - Updated README testing guidance to teach the `ace-test ...`, `ace-test ... feat`, `ace-test ... all`, and `ace-test-e2e ...` contract.
94
+
95
+ ## [0.27.3] - 2026-04-10
96
+
97
+ ### Fixed
98
+ - Forwarded `subprocess_env` into Codex and Gemini `SafeCapture` execution.
99
+ - Updated Gemini prompt-cache and subprocess working-dir resolution to use execution context directly.
100
+
101
+ ### Technical
102
+ - Added Codex and Gemini regression coverage to assert `SafeCapture` receives forwarded `env`.
103
+
10
104
  ## [0.27.2] - 2026-03-29
11
105
 
12
106
  ### Technical
data/README.md CHANGED
@@ -14,9 +14,12 @@
14
14
 
15
15
  > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
16
 
17
- [ace-llm Usage Guide](../ace-llm/docs/usage.md) | [ace-llm Handbook](../ace-llm/docs/handbook.md)
17
+ [ace-llm Usage Guide](../ace-llm/docs/usage.md) | [ace-llm Handbook](../ace-llm/docs/handbook.md) | Part of [ACE](https://github.com/cs3b/ace)
18
+
18
19
  `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
 
21
+ This package intentionally does not ship a package-local `docs/` directory. Public usage and operator guidance live in the shared [ace-llm Usage Guide](../ace-llm/docs/usage.md), while `ace-llm-providers-cli-check --help` remains the authoritative runtime help surface for local readiness checks.
22
+
20
23
  ## How It Works
21
24
 
22
25
  1. The gem registers CLI provider clients on require, making them available to [ace-llm](../ace-llm) automatically.
@@ -31,6 +34,14 @@
31
34
 
32
35
  **Diagnose local provider readiness** - run `ace-llm-providers-cli-check` to verify that CLI tools are installed, authenticated, and reachable before starting work.
33
36
 
34
- ---
37
+ ## Testing
38
+
39
+ - `ace-test ace-llm-providers-cli` runs deterministic fast coverage from `test/fast/`.
40
+ - `ace-test ace-llm-providers-cli feat` runs deterministic feature coverage from `test/feat/`.
41
+ - `ace-test ace-llm-providers-cli all` runs fast + feat deterministic coverage.
42
+ - `ace-test-e2e ace-llm-providers-cli` runs retained workflow scenarios from `test/e2e/`.
43
+ - E2E provider-discovery scenarios avoid helper interception: the no-tools path uses a baseline PATH
44
+ (`/usr/bin:/bin`), and success-path stubs are kept as text scripts in a dedicated sandbox stub directory.
35
45
 
36
- Part of [ACE](https://github.com/cs3b/ace)
46
+ The package no longer uses legacy `test/integration` naming and does not use
47
+ `ace-test ace-llm-providers-cli e2e` as a deterministic test path.
@@ -11,6 +11,9 @@ module Ace
11
11
 
12
12
  # Codex CLI: `/ace-git-commit` → `$ace-git-commit`
13
13
  CODEX_FORMATTER = ->(name) { "$#{name}" }
14
+
15
+ # Claude interactive CLI keeps slash commands unchanged.
16
+ CLAUDE_FORMATTER = ->(name) { "/#{name}" }
14
17
  end
15
18
  end
16
19
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Providers
9
+ module CLI
10
+ module Atoms
11
+ module InteractiveStartupPolicy
12
+ module_function
13
+
14
+ def tmux_context?(subprocess_env = nil)
15
+ env = normalized_env(subprocess_env)
16
+ !tmux_session_name(env).empty?
17
+ end
18
+
19
+ def codex_trust_override(working_dir:, subprocess_env: nil)
20
+ return nil unless tmux_context?(subprocess_env)
21
+
22
+ path = working_dir.to_s.strip
23
+ return nil if path.empty?
24
+
25
+ %{projects."#{escape_toml_basic_string(path)}".trust_level="trusted"}
26
+ end
27
+
28
+ def codex_overlay_home(working_dir:, subprocess_env: nil)
29
+ return nil unless tmux_context?(subprocess_env)
30
+
31
+ root = working_dir.to_s.strip
32
+ return nil if root.empty?
33
+
34
+ overlay_home = File.join(root, ".ace-local", "llm", "codex-home", Digest::SHA256.hexdigest(root)[0, 12])
35
+ prepare_codex_overlay_home(overlay_home: overlay_home, working_dir: root)
36
+ overlay_home
37
+ end
38
+
39
+ def normalized_env(subprocess_env)
40
+ env = ENV.to_h
41
+ return env unless subprocess_env
42
+
43
+ subprocess_env.each_with_object(env) do |(key, value), memo|
44
+ memo[key.to_s] = value
45
+ end
46
+ end
47
+
48
+ def tmux_session_name(env)
49
+ explicit = env["ACE_TMUX_SESSION"].to_s.strip
50
+ return explicit unless explicit.empty?
51
+
52
+ env["TMUX"].to_s.strip
53
+ end
54
+
55
+ def escape_toml_basic_string(value)
56
+ value.to_s.gsub("\\", "\\\\\\\\").gsub("\"", "\\\\\"")
57
+ end
58
+
59
+ def prepare_codex_overlay_home(overlay_home:, working_dir:)
60
+ codex_dir = File.join(overlay_home, ".codex")
61
+ FileUtils.mkdir_p(codex_dir)
62
+
63
+ sync_codex_overlay_links(codex_dir)
64
+ write_codex_overlay_config(codex_dir: codex_dir, working_dir: working_dir)
65
+ end
66
+
67
+ def sync_codex_overlay_links(codex_dir)
68
+ source_dir = File.expand_path("~/.codex")
69
+ return unless Dir.exist?(source_dir)
70
+
71
+ %w[auth.json installation_id version.json skills rules memories].each do |entry|
72
+ source = File.join(source_dir, entry)
73
+ next unless File.exist?(source)
74
+
75
+ target = File.join(codex_dir, entry)
76
+ next if File.exist?(target) || File.symlink?(target)
77
+
78
+ File.symlink(source, target)
79
+ end
80
+ end
81
+
82
+ def write_codex_overlay_config(codex_dir:, working_dir:)
83
+ source_config = File.expand_path("~/.codex/config.toml")
84
+ base = File.exist?(source_config) ? File.read(source_config) : ""
85
+ trust_block = <<~TOML
86
+
87
+ [projects."#{escape_toml_basic_string(working_dir)}"]
88
+ trust_level = "trusted"
89
+ TOML
90
+
91
+ target = File.join(codex_dir, "config.toml")
92
+ content = base.include?(trust_block.strip) ? base : "#{base.rstrip}#{trust_block}"
93
+ File.write(target, content)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -6,6 +6,9 @@ require "shellwords"
6
6
 
7
7
  require_relative "cli_args_support"
8
8
  require_relative "atoms/execution_context"
9
+ require_relative "atoms/command_rewriter"
10
+ require_relative "atoms/command_formatters"
11
+ require_relative "molecules/skill_name_reader"
9
12
 
10
13
  module Ace
11
14
  module LLM
@@ -33,6 +36,7 @@ module Ace
33
36
  # Skip normal BaseClient initialization that requires API key
34
37
  @options = options
35
38
  @generation_config = options[:generation_config] || {}
39
+ @skill_name_reader = Molecules::SkillNameReader.new
36
40
  end
37
41
 
38
42
  # Override to indicate this client doesn't need API credentials
@@ -60,7 +64,8 @@ module Ace
60
64
  cmd,
61
65
  prompt,
62
66
  subprocess_env: subprocess_env,
63
- working_dir: working_dir
67
+ working_dir: working_dir,
68
+ subprocess_command_prefix: options[:subprocess_command_prefix]
64
69
  )
65
70
 
66
71
  parse_claude_response(stdout, stderr, status, prompt, options)
@@ -79,6 +84,32 @@ module Ace
79
84
  ]
80
85
  end
81
86
 
87
+ def interactive_supported?
88
+ true
89
+ end
90
+
91
+ def build_interactive_invocation(messages, **options)
92
+ validate_claude_availability!
93
+
94
+ prompt = format_messages_as_prompt(messages)
95
+ subprocess_env = options[:subprocess_env]
96
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
97
+ working_dir: options[:working_dir],
98
+ subprocess_env: subprocess_env
99
+ )
100
+ prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
101
+
102
+ cmd = build_claude_interactive_command(prompt, options)
103
+ env = {"CLAUDECODE" => nil}
104
+ env.merge!(subprocess_env) if subprocess_env
105
+ {
106
+ command: cmd,
107
+ env: env,
108
+ working_dir: working_dir,
109
+ prompt: prompt
110
+ }
111
+ end
112
+
82
113
  private
83
114
 
84
115
  def format_messages_as_prompt(messages)
@@ -146,19 +177,42 @@ module Ace
146
177
  # (128KB per-argument limit). System content is already embedded in the
147
178
  # formatted prompt via format_messages_as_prompt.
148
179
 
180
+ cli_args = normalized_cli_args(options)
181
+ max_tokens_requested = cli_args_include_max_tokens_flag?(cli_args)
182
+
149
183
  # Add max tokens if provided
150
184
  max_tokens = options[:max_tokens] || @generation_config[:max_tokens]
151
185
  if max_tokens
152
- cmd << "--max-tokens" << max_tokens.to_s
186
+ max_tokens_requested = true
187
+ cmd.concat(["--max-tokens", max_tokens.to_s]) if supports_max_tokens_flag?
153
188
  end
154
189
 
155
190
  # User CLI args last so they take precedence (last-wins in most CLIs)
156
- cmd.concat(normalized_cli_args(options))
191
+ cmd.concat(filter_unsupported_cli_args(cli_args, max_tokens_requested: max_tokens_requested))
192
+
193
+ cmd
194
+ end
195
+
196
+ def build_claude_interactive_command(prompt, options)
197
+ cmd = ["claude"]
157
198
 
199
+ if @model && @model != DEFAULT_MODEL
200
+ cmd << "--model" << @model
201
+ end
202
+
203
+ cmd.concat(
204
+ normalized_cli_args_without_conflicts(
205
+ options,
206
+ forbidden_flags: ["-p", "--print", "--output-format", "--input-format", "--json-schema",
207
+ "--include-partial-messages", "--include-hook-events", "--no-session-persistence"],
208
+ label: "Claude"
209
+ )
210
+ )
211
+ cmd << prompt.to_s unless prompt.to_s.empty?
158
212
  cmd
159
213
  end
160
214
 
161
- def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil)
215
+ def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil, subprocess_command_prefix: nil)
162
216
  timeout_val = @options[:timeout] || 120
163
217
  # Clear CLAUDECODE env var so `claude -p` (non-interactive, one-shot mode)
164
218
  # can run as a subprocess from within a Claude Code session.
@@ -173,10 +227,58 @@ module Ace
173
227
  stdin_data: prompt.to_s,
174
228
  chdir: working_dir,
175
229
  env: env,
230
+ command_prefix: subprocess_command_prefix,
176
231
  provider_name: "Claude"
177
232
  )
178
233
  end
179
234
 
235
+ def supports_max_tokens_flag?
236
+ return @supports_max_tokens_flag unless @supports_max_tokens_flag.nil?
237
+
238
+ @supports_max_tokens_flag = command_help_supports_flag?("--max-tokens")
239
+ end
240
+
241
+ def command_help_supports_flag?(flag)
242
+ [["claude", "-p", "--help"], ["claude", "--help"]].any? do |help_cmd|
243
+ stdout, stderr, status = Open3.capture3({"CLAUDECODE" => nil}, *help_cmd)
244
+ status.success? && [stdout, stderr].join("\n").include?(flag)
245
+ rescue
246
+ false
247
+ end
248
+ end
249
+
250
+ def cli_args_include_max_tokens_flag?(cli_args)
251
+ cli_args.each_with_index.any? do |arg, index|
252
+ arg == "--max-tokens" || arg.start_with?("--max-tokens=") || (arg == "--max-tokens" && !cli_args[index + 1].nil?)
253
+ end
254
+ end
255
+
256
+ def filter_unsupported_cli_args(cli_args, max_tokens_requested: false)
257
+ return cli_args unless max_tokens_requested
258
+ return cli_args if supports_max_tokens_flag?
259
+
260
+ filtered = []
261
+ skip_next = false
262
+
263
+ cli_args.each do |arg|
264
+ if skip_next
265
+ skip_next = false
266
+ next
267
+ end
268
+
269
+ if arg == "--max-tokens"
270
+ skip_next = true
271
+ next
272
+ end
273
+
274
+ next if arg.start_with?("--max-tokens=")
275
+
276
+ filtered << arg
277
+ end
278
+
279
+ filtered
280
+ end
281
+
180
282
  def parse_claude_response(stdout, stderr, status, prompt, options)
181
283
  unless status.success?
182
284
  error_msg = stderr.empty? ? stdout : stderr
@@ -212,6 +314,29 @@ module Ace
212
314
  }
213
315
  end
214
316
 
317
+ def rewrite_skill_commands(prompt, working_dir: nil)
318
+ skills_dir = resolve_skills_dir(working_dir: working_dir)
319
+ return prompt unless skills_dir
320
+
321
+ skill_names = @skill_name_reader.call(skills_dir)
322
+ return prompt if skill_names.empty?
323
+
324
+ Atoms::CommandRewriter.call(
325
+ prompt,
326
+ skill_names: skill_names,
327
+ formatter: Atoms::CommandFormatters::CLAUDE_FORMATTER
328
+ )
329
+ end
330
+
331
+ def resolve_skills_dir(working_dir: nil)
332
+ configured = @options[:skills_dir] || @generation_config[:skills_dir]
333
+ return configured if configured && Dir.exist?(configured)
334
+
335
+ working_dir ||= Atoms::ExecutionContext.resolve_working_dir
336
+ candidate_dir = File.join(working_dir, ".claude", "skills")
337
+ candidate_dir if Dir.exist?(candidate_dir)
338
+ end
339
+
215
340
  def extract_claude_text(response)
216
341
  direct_result = response["result"]
217
342
  direct_response = response["response"]
@@ -61,7 +61,8 @@ module Ace
61
61
  cmd,
62
62
  prompt,
63
63
  subprocess_env: subprocess_env,
64
- working_dir: working_dir
64
+ working_dir: working_dir,
65
+ subprocess_command_prefix: options[:subprocess_command_prefix]
65
66
  )
66
67
 
67
68
  parse_claude_response(stdout, stderr, status, prompt, options)
@@ -154,7 +155,7 @@ module Ace
154
155
  cmd
155
156
  end
156
157
 
157
- def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil)
158
+ def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil, subprocess_command_prefix: nil)
158
159
  timeout_val = @options[:timeout] || 120
159
160
 
160
161
  # Build env with backend-specific vars for Anthropic-compatible routing
@@ -169,6 +170,7 @@ module Ace
169
170
  stdin_data: prompt.to_s,
170
171
  chdir: working_dir,
171
172
  env: env,
173
+ command_prefix: subprocess_command_prefix,
172
174
  provider_name: "Claude OAI"
173
175
  )
174
176
  end
@@ -12,6 +12,21 @@ module Ace
12
12
  def normalized_cli_args(options)
13
13
  Atoms::ArgsNormalizer.new.normalize_cli_args(options[:cli_args])
14
14
  end
15
+
16
+ def normalized_cli_args_without_conflicts(options, forbidden_flags:, label:)
17
+ args = normalized_cli_args(options)
18
+ conflict = find_conflicting_cli_arg(args, forbidden_flags)
19
+ return args unless conflict
20
+
21
+ raise Ace::LLM::ProviderError, "#{label} interactive mode does not support cli arg #{conflict}"
22
+ end
23
+
24
+ def find_conflicting_cli_arg(args, forbidden_flags)
25
+ normalized_forbidden = Array(forbidden_flags).map(&:to_s)
26
+ args.find do |arg|
27
+ normalized_forbidden.any? { |flag| arg == flag || arg.start_with?("#{flag}=") }
28
+ end
29
+ end
15
30
  end
16
31
  end
17
32
  end
@@ -8,6 +8,7 @@ require_relative "cli_args_support"
8
8
  require_relative "atoms/execution_context"
9
9
  require_relative "atoms/command_rewriter"
10
10
  require_relative "atoms/command_formatters"
11
+ require_relative "atoms/interactive_startup_policy"
11
12
  require_relative "atoms/worktree_dir_resolver"
12
13
  require_relative "molecules/skill_name_reader"
13
14
 
@@ -30,7 +31,7 @@ module Ace
30
31
  end
31
32
 
32
33
  # Default model (can be overridden by config)
33
- DEFAULT_MODEL = "gpt-5"
34
+ DEFAULT_MODEL = "gpt-5.4"
34
35
 
35
36
  def initialize(model: nil, **options)
36
37
  @model = model || DEFAULT_MODEL
@@ -74,11 +75,48 @@ module Ace
74
75
  # Return models based on what the CLI supports
75
76
  # Actual models come from YAML config
76
77
  [
77
- {id: "gpt-5", name: "GPT-5", description: "Advanced Codex model", context_size: 128_000},
78
- {id: "gpt-5-mini", name: "GPT-5 Mini", description: "Smaller, faster model", context_size: 128_000}
78
+ {id: "gpt-5.3-codex", name: "GPT-5.3 Codex", description: "Code-specialized Codex model", context_size: 128_000},
79
+ {id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", description: "Faster Codex model", context_size: 128_000},
80
+ {id: "gpt-5.4", name: "GPT-5.4", description: "Advanced Codex model", context_size: 128_000},
81
+ {id: "gpt-5.4-mini", name: "GPT-5.4 Mini", description: "Smaller, faster Codex model", context_size: 128_000}
79
82
  ]
80
83
  end
81
84
 
85
+ def interactive_supported?
86
+ true
87
+ end
88
+
89
+ def build_interactive_invocation(messages, **options)
90
+ validate_codex_availability!
91
+
92
+ prompt = format_messages_as_prompt(messages)
93
+ subprocess_env = options[:subprocess_env]
94
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
95
+ working_dir: options[:working_dir],
96
+ subprocess_env: subprocess_env
97
+ )
98
+ prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
99
+
100
+ env = subprocess_env ? subprocess_env.to_h.dup : {}
101
+ overlay_home = Atoms::InteractiveStartupPolicy.codex_overlay_home(
102
+ working_dir: working_dir,
103
+ subprocess_env: subprocess_env
104
+ )
105
+ env["HOME"] = overlay_home if overlay_home
106
+
107
+ cmd = build_codex_interactive_command(
108
+ prompt,
109
+ options,
110
+ working_dir: working_dir
111
+ )
112
+ {
113
+ command: cmd,
114
+ env: env,
115
+ working_dir: working_dir,
116
+ prompt: prompt
117
+ }
118
+ end
119
+
82
120
  private
83
121
 
84
122
  def format_messages_as_prompt(messages)
@@ -182,6 +220,36 @@ module Ace
182
220
  cmd
183
221
  end
184
222
 
223
+ def build_codex_interactive_command(prompt, options, working_dir: nil)
224
+ working_dir ||= Atoms::ExecutionContext.resolve_working_dir(
225
+ working_dir: options[:working_dir],
226
+ subprocess_env: options[:subprocess_env]
227
+ )
228
+ cmd = ["codex"]
229
+
230
+ if options[:sandbox]
231
+ cmd << "--sandbox" << options[:sandbox].to_s
232
+ end
233
+
234
+ if @model && @model != DEFAULT_MODEL
235
+ cmd << "--model" << @model
236
+ end
237
+
238
+ if (git_dir = Atoms::WorktreeDirResolver.call(working_dir: working_dir))
239
+ cmd << "--add-dir" << git_dir
240
+ end
241
+
242
+ cmd.concat(
243
+ normalized_cli_args_without_conflicts(
244
+ options,
245
+ forbidden_flags: ["exec", "review", "--output-last-message"],
246
+ label: "Codex"
247
+ )
248
+ )
249
+ cmd << prompt.to_s unless prompt.to_s.empty?
250
+ cmd
251
+ end
252
+
185
253
  def execute_codex_command(cmd, prompt, options)
186
254
  # Prepare the input - combine system prompt with user prompt if needed
187
255
  input = prompt.to_s
@@ -206,6 +274,8 @@ module Ace
206
274
  timeout: timeout_val,
207
275
  stdin_data: input,
208
276
  chdir: working_dir,
277
+ env: options[:subprocess_env],
278
+ command_prefix: options[:subprocess_command_prefix],
209
279
  provider_name: "Codex"
210
280
  )
211
281
  end
@@ -197,6 +197,8 @@ module Ace
197
197
  timeout: timeout_val,
198
198
  stdin_data: input,
199
199
  chdir: working_dir,
200
+ env: options[:subprocess_env],
201
+ command_prefix: options[:subprocess_command_prefix],
200
202
  provider_name: "Codex OAI"
201
203
  )
202
204
  end
@@ -245,7 +245,11 @@ module Ace
245
245
  end
246
246
 
247
247
  def create_prompt_cache_dir(working_dir = nil, subprocess_env: nil)
248
- cache_dir = File.join(find_project_root(working_dir, subprocess_env: subprocess_env), ".ace-local", "llm", "prompts")
248
+ resolved_working_dir = Atoms::ExecutionContext.resolve_working_dir(
249
+ working_dir: working_dir,
250
+ subprocess_env: subprocess_env
251
+ )
252
+ cache_dir = File.join(resolved_working_dir, ".ace-local", "llm", "prompts")
249
253
  FileUtils.mkdir_p(cache_dir) unless Dir.exist?(cache_dir)
250
254
  cache_dir
251
255
  end
@@ -256,17 +260,18 @@ module Ace
256
260
 
257
261
  def execute_gemini_command(cmd, prompt, options)
258
262
  timeout_val = options[:timeout] || @options[:timeout] || 120
259
- project_root = find_project_root(options[:working_dir], subprocess_env: options[:subprocess_env])
260
- Molecules::SafeCapture.call(cmd, timeout: timeout_val, chdir: project_root, provider_name: "Gemini")
261
- end
262
-
263
- def find_project_root(working_dir = nil, subprocess_env: nil)
264
- require "ace/support/fs"
265
- start_path = Atoms::ExecutionContext.resolve_working_dir(
266
- working_dir: working_dir,
267
- subprocess_env: subprocess_env
263
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
264
+ working_dir: options[:working_dir],
265
+ subprocess_env: options[:subprocess_env]
266
+ )
267
+ Molecules::SafeCapture.call(
268
+ cmd,
269
+ timeout: timeout_val,
270
+ chdir: working_dir,
271
+ env: options[:subprocess_env],
272
+ command_prefix: options[:subprocess_command_prefix],
273
+ provider_name: "Gemini"
268
274
  )
269
- Ace::Support::Fs::Molecules::ProjectRootFinder.find(start_path: start_path) || start_path
270
275
  end
271
276
 
272
277
  def parse_gemini_response(stdout, stderr, status, prompt, options)
@@ -27,13 +27,15 @@ module Ace
27
27
  # @return [Array(String, String, Process::Status)] [stdout, stderr, status]
28
28
  # @raise [Ace::LLM::ProviderError] on timeout
29
29
  def self.call(cmd, timeout:, stdin_data: nil, chdir: nil, env: nil, provider_name: "CLI",
30
+ command_prefix: nil,
30
31
  isolate_process_group: true, cleanup_group_on_exit: true)
31
32
  normalized_timeout = normalize_timeout(timeout)
32
33
  opts = {}
33
34
  opts[:chdir] = chdir if chdir
34
35
  opts[:pgroup] = true if isolate_process_group
35
36
 
36
- args = env ? [env, *cmd] : cmd
37
+ full_cmd = Array(command_prefix) + cmd
38
+ args = env ? [env, *full_cmd] : full_cmd
37
39
 
38
40
  Open3.popen3(*args, **opts) do |stdin, stdout, stderr, wait_thr|
39
41
  pid = wait_thr.pid
@@ -205,6 +205,8 @@ module Ace
205
205
  timeout: timeout_val,
206
206
  stdin_data: "",
207
207
  chdir: working_dir,
208
+ env: options[:subprocess_env],
209
+ command_prefix: options[:subprocess_command_prefix],
208
210
  provider_name: "OpenCode"
209
211
  )
210
212
  end
@@ -57,7 +57,7 @@ module Ace
57
57
  full_prompt = rewrite_skill_commands(full_prompt, working_dir: working_dir)
58
58
 
59
59
  cmd = build_pi_command(full_prompt, options, system_prompt: system_prompt)
60
- stdout, stderr, status = execute_pi_command(cmd, working_dir: working_dir)
60
+ stdout, stderr, status = execute_pi_command(cmd, working_dir: working_dir, options: options)
61
61
 
62
62
  parse_pi_response(stdout, stderr, status, full_prompt, options)
63
63
  rescue => e
@@ -77,6 +77,31 @@ module Ace
77
77
  ]
78
78
  end
79
79
 
80
+ def interactive_supported?
81
+ true
82
+ end
83
+
84
+ def build_interactive_invocation(messages, **options)
85
+ validate_pi_availability!
86
+
87
+ prompt = format_messages_as_prompt(messages)
88
+ full_prompt, system_prompt = build_full_prompt(prompt, options)
89
+ subprocess_env = options[:subprocess_env]
90
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
91
+ working_dir: options[:working_dir],
92
+ subprocess_env: subprocess_env
93
+ )
94
+ full_prompt = rewrite_skill_commands(full_prompt, working_dir: working_dir)
95
+
96
+ cmd = build_pi_interactive_command(full_prompt, options, system_prompt: system_prompt)
97
+ {
98
+ command: cmd,
99
+ env: subprocess_env,
100
+ working_dir: working_dir,
101
+ prompt: full_prompt
102
+ }
103
+ end
104
+
80
105
  private
81
106
 
82
107
  def format_messages_as_prompt(messages)
@@ -178,6 +203,31 @@ module Ace
178
203
  cmd
179
204
  end
180
205
 
206
+ def build_pi_interactive_command(full_prompt, options, system_prompt: nil)
207
+ cmd = ["pi"]
208
+
209
+ if system_prompt
210
+ cmd << "--system-prompt" << system_prompt
211
+ end
212
+
213
+ model_to_use = @model || @generation_config[:model] || DEFAULT_MODEL
214
+ provider_name, model_id = split_provider_model(model_to_use)
215
+ if provider_name && model_id
216
+ cmd << "--provider" << provider_name
217
+ cmd << "--model" << model_id
218
+ end
219
+
220
+ cmd.concat(
221
+ normalized_cli_args_without_conflicts(
222
+ options,
223
+ forbidden_flags: ["-p", "--print", "--no-session", "--no-skills", "--mode"],
224
+ label: "Pi"
225
+ )
226
+ )
227
+ cmd << full_prompt.to_s unless full_prompt.to_s.empty?
228
+ cmd
229
+ end
230
+
181
231
  # Split "provider/model" into ["provider", "model"]
182
232
  # Handles multi-segment providers like "google-gemini-cli/gemini-2.5-pro"
183
233
  # Also handles nested providers like "openrouter:openai/gpt-oss-120b"
@@ -200,13 +250,15 @@ module Ace
200
250
  [parts[0], parts[1]]
201
251
  end
202
252
 
203
- def execute_pi_command(cmd, timeout: nil, working_dir: nil)
253
+ def execute_pi_command(cmd, timeout: nil, working_dir: nil, options: {})
204
254
  timeout_val = timeout || @options[:timeout] || 120
205
255
  Molecules::SafeCapture.call(
206
256
  cmd,
207
257
  timeout: timeout_val,
208
258
  stdin_data: "",
209
259
  chdir: working_dir,
260
+ env: options[:subprocess_env],
261
+ command_prefix: options[:subprocess_command_prefix],
210
262
  provider_name: "Pi"
211
263
  )
212
264
  end
@@ -4,7 +4,7 @@ module Ace
4
4
  module LLM
5
5
  module Providers
6
6
  module CLI
7
- VERSION = '0.27.2'
7
+ VERSION = '0.31.2'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm-providers-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.2
4
+ version: 0.31.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 2026-04-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-llm
@@ -91,6 +91,7 @@ files:
91
91
  - lib/ace/llm/providers/cli/atoms/command_formatters.rb
92
92
  - lib/ace/llm/providers/cli/atoms/command_rewriter.rb
93
93
  - lib/ace/llm/providers/cli/atoms/execution_context.rb
94
+ - lib/ace/llm/providers/cli/atoms/interactive_startup_policy.rb
94
95
  - lib/ace/llm/providers/cli/atoms/provider_detector.rb
95
96
  - lib/ace/llm/providers/cli/atoms/session_finders/claude_session_finder.rb
96
97
  - lib/ace/llm/providers/cli/atoms/session_finders/codex_session_finder.rb