Module: ClaudeCli
- Defined in:
- app/services/claude_cli.rb
Overview
The single auth path for all of Cardinal's AI (§17): every tier — planning assistant, maintenance agents, rules compiler, worker agents — goes through the claude CLI, so wherever Claude Code is logged in (or an API key is exported), Cardinal works. No separate key provisioning.
This module covers the one-shot tiers; worker agents have their own streaming path in Agent::Runner.
Defined Under Namespace
Classes: Error
Constant Summary collapse
- STRIP_ENV =
Nested-session guards + creds the model never needs. A blank ANTHROPIC_API_KEY is removed too (it would shadow CLI session auth).
%w[CLAUDECODE CLAUDE_CODE_ENTRYPOINT GH_TOKEN GITHUB_TOKEN].freeze
- WRAP_UP =
"You have hit your exploration limit. Using only what you have already " \ "learned, give your best complete reply now. Do not use any tools.".freeze
Class Method Summary collapse
- .available? ⇒ Boolean
- .friendly_failure(json) ⇒ Object
- .invoke(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil) ⇒ Object
-
.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil, with_session: false, ledger: nil) ⇒ Object
tools: comma-separated read-only tool list (e.g. "Read,Glob,Grep") with cwd pointing at the repo.
-
.record_usage!(json, ledger, model) ⇒ Object
Best-effort by design: a ledger failure must never break the AI call that already succeeded.
- .success?(json) ⇒ Boolean
Class Method Details
.available? ⇒ Boolean
27 28 29 30 |
# File 'app/services/claude_cli.rb', line 27 def self.available? return @available if defined?(@available) @available = system("which claude > /dev/null 2>&1") end |
.friendly_failure(json) ⇒ Object
86 87 88 89 90 91 92 |
# File 'app/services/claude_cli.rb', line 86 def self.friendly_failure(json) case json["subtype"] when "error_max_turns" then "ran out of working turns before finishing" when "error_during_execution" then "hit an internal error while working" else "failed (#{json["subtype"].presence || "unknown error"})" end end |
.invoke(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil) ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'app/services/claude_cli.rb', line 94 def self.invoke(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil) cmd = ["claude", "-p", text, "--output-format", "json", "--max-turns", max_turns.to_s, "--tools", tools.presence || ""] cmd += ["--append-system-prompt", system] if system.present? cmd += ["--model", model] if model.present? cmd += ["--resume", resume] if resume.present? env = STRIP_ENV.index_with { nil } env["ANTHROPIC_API_KEY"] = nil if ENV["ANTHROPIC_API_KEY"].blank? spawn_opts = cwd.present? && Dir.exist?(cwd) ? { chdir: cwd } : {} out, err, status = Open3.capture3(env, *cmd, **spawn_opts) JSON.parse(out) rescue JSON::ParserError raise Error.new("claude produced no readable result (exit #{status&.exitstatus || "?"})", detail: [err, out].compact_blank.join("\n---\n").truncate(1500)) end |
.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil, with_session: false, ledger: nil) ⇒ Object
tools: comma-separated read-only tool list (e.g. "Read,Glob,Grep") with cwd pointing at the repo. Default remains tool-less single-turn. resume: continue an existing claude session (context carries over). with_session: return [text, session_id] instead of just text, so callers can keep a continuing conversation (the planning assistant does). ledger: { kind:, card: } — record this call's tokens/cost as an AiCall (§ money honesty: planning conversations and maintenance calls spend real dollars; only worker runs used to be counted).
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'app/services/claude_cli.rb', line 40 def self.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil, with_session: false, ledger: nil) raise Error.new("claude CLI not found on PATH") unless available? json = invoke(text, system:, model:, tools:, cwd:, max_turns:, resume:) record_usage!(json, ledger, model) if success?(json) return with_session ? [json["result"].to_s, json["session_id"]] : json["result"].to_s end # Ran out of turns mid-exploration: resume the same session tool-less and # force an answer from the context it already gathered. if json["subtype"] == "error_max_turns" && json["session_id"].present? wrapped = invoke(WRAP_UP, model:, cwd:, tools: "", max_turns: 2, resume: json["session_id"]) record_usage!(wrapped, ledger, model) if success?(wrapped) return with_session ? [wrapped["result"].to_s, wrapped["session_id"] || json["session_id"]] : wrapped["result"].to_s end raise Error.new("ran out of working turns and couldn't wrap up — try again, or simplify the ask", detail: wrapped.to_json) end raise Error.new(friendly_failure(json), detail: json.to_json) end |
.record_usage!(json, ledger, model) ⇒ Object
Best-effort by design: a ledger failure must never break the AI call that already succeeded. Failed calls are recorded too — they cost money.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'app/services/claude_cli.rb', line 67 def self.record_usage!(json, ledger, model) return unless ledger.is_a?(Hash) && ledger[:kind].present? usage = json["usage"] || {} AiCall.create!( card: ledger[:card], kind: ledger[:kind].to_s, model: json["model"] || model, input_tokens: usage["input_tokens"].to_i, output_tokens: usage["output_tokens"].to_i, cost: json["total_cost_usd"].to_f ) rescue StandardError => e Rails.logger.warn("AiCall ledger write failed: #{e.class}: #{e.}") end |
.success?(json) ⇒ Boolean
82 83 84 |
# File 'app/services/claude_cli.rb', line 82 def self.success?(json) json["subtype"] == "success" && !json["is_error"] end |