Last active
March 10, 2026 21:31
-
-
Save coorasse/9ebf421447cc8bcf5b3d93399794eb49 to your computer and use it in GitHub Desktop.
Record claude sessions outputs and replay them. VCR for Claude
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # frozen_string_literal: true | |
| require "open3" | |
| require "yaml" | |
| # Provides VCR-like recording and replay for Claude CLI calls. | |
| # | |
| # Usage: | |
| # it "calls Claude" do | |
| # with_claude_cassette("my_test/scenario") | |
| # # ... code that calls ClaudeClient ... | |
| # end | |
| # | |
| # On first run the real Claude CLI is invoked and the response is saved to | |
| # spec/claude_cassettes/<name>.yml. On subsequent runs the saved response | |
| # is replayed. Delete the cassette file to re-record. | |
| module ClaudeCassette | |
| CASSETTE_DIR = Rails.root.join("spec/claude_cassettes") | |
| def with_claude_cassette(name) | |
| cassette_path = CASSETTE_DIR.join("#{name}.yml") | |
| @_claude_cassette = {} | |
| if cassette_path.exist? | |
| replay_claude_cassette(cassette_path) | |
| else | |
| record_claude_cassette(cassette_path) | |
| end | |
| end | |
| # Returns the prompt input that was sent to Claude in the current test run. | |
| def claude_cassette_input | |
| @_claude_cassette&.dig("input") | |
| end | |
| private | |
| def replay_claude_cassette(path) | |
| recorded = YAML.load_file(path, permitted_classes: [Symbol]) | |
| cassette = @_claude_cassette | |
| status = instance_double(Process::Status, success?: recorded["exit_status"].zero?, | |
| exitstatus: recorded["exit_status"]) | |
| allow(Open3).to receive(:capture3).and_wrap_original do |original, *args| | |
| if args.first != "claude" | |
| next original.call(*args) | |
| else | |
| cassette["input"] = args.last | |
| [recorded["stdout"], recorded["stderr"], status] | |
| end | |
| end | |
| end | |
| def record_claude_cassette(path) | |
| cassette = @_claude_cassette | |
| allow(Open3).to receive(:capture3).and_wrap_original do |original, *args| | |
| stdout, stderr, status = original.call(*args) | |
| if args.first == "claude" | |
| cassette.merge!( | |
| "input" => args.last, | |
| "stdout" => stdout, | |
| "stderr" => stderr, | |
| "exit_status" => status.exitstatus, | |
| "recorded_at" => Time.current.iso8601 | |
| ) | |
| FileUtils.mkdir_p(path.dirname) | |
| File.write(path, cassette.to_yaml) | |
| end | |
| [stdout, stderr, status] | |
| end | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment