Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ee9a79d
Implement rubocop autocorrections
gillisd Mar 28, 2026
f31d1dd
Add another ticket
gillisd Mar 28, 2026
c35ba4f
Fix frozen Zeitwerk loader and add module documentation
gillisd Apr 2, 2026
435f417
Add rspec, integration specs
gillisd Apr 2, 2026
55a3319
Add accessory scripts that should be integrated at some point
gillisd Apr 3, 2026
ab41bf2
Implement all integration spec examples
gillisd Apr 3, 2026
9e4cf3f
Add rake spec task and include it in default
gillisd Apr 3, 2026
fa6010e
Fix all rubocop offenses across codebase
gillisd Apr 4, 2026
3269e47
Add new domain entities: Ball, Border, Cursor, Destination
gillisd Apr 5, 2026
dc246ab
Fix code review findings: serialize arity and Cursor abstraction
gillisd Apr 5, 2026
bb98e02
Add unit specs for Ball, Border, Cursor, Destination
gillisd Apr 5, 2026
4b8692e
Harden Cursor API: make raw_line private, guard previous_line
gillisd Apr 5, 2026
2ef696a
Rename parse_errors to parse_warnings, add Destination#summary
gillisd Apr 5, 2026
2115835
Migrate CLI commands from Bundle to Ball + Destination
gillisd Apr 5, 2026
befa1a3
Fix code review findings: pattern match crash, LoD, booleans
gillisd Apr 5, 2026
99c2770
Fix content with no trailing newline: apply Border.strip_suffix
gillisd Apr 5, 2026
743d951
Simplify Entry, delete Bundle/Config, update all tests
gillisd Apr 5, 2026
4acd3ca
Stub text? on binary_entry to avoid platform-dependent FileMagic
gillisd Apr 5, 2026
bcabead
Fix stale bundle terminology in comments and spec descriptions
gillisd Apr 5, 2026
9ac5ef4
Add issue: test suite writes to /tmp via Dir.mktmpdir
gillisd Apr 6, 2026
30e1c11
Extract ball_text_for helper to shared spec_helper
gillisd Apr 6, 2026
5326de1
Fix CI: install libmagic-dev, run full rake (test + spec + rubocop)
gillisd Apr 6, 2026
dd9d1d3
Implement lexer/stream architecture for codeball parsing
gillisd Apr 6, 2026
578c189
Fix Cursor: require border context for END marker termination
gillisd Apr 6, 2026
c1bb61b
Clean up review findings: dead code, nil guards, gemspec terminology
gillisd Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ jobs:
ruby-version: ['4.0.1']
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libmagic-dev
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Run tests
run: bundle exec rake test
- name: Run RuboCop
run: bundle exec rake rubocop
- name: Run tests and specs
run: bundle exec rake
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
Gemfile.lock
*.gem
.rspec_status
.patches/
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ Style/StringLiteralsInInterpolation:
Style/FrozenStringLiteralComment:
EnforcedStyle: never

# Freezing constants breaks objects that need post-assignment setup (e.g.
# Zeitwerk loaders). Not worth the churn.
Style/MutableConstant:
Enabled: false

# Pipeline style. Chaining multi-line blocks is the whole point.
#
# # good — this is how we write Ruby
Expand Down
5 changes: 2 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec

gem "command_kit"
gem "minitest", "~> 6.0"
gem "minitest-mock", "~> 5.0"
gem "minitest-reporters", github: 'minitest-reporters/minitest-reporters'
gem "minitest-reporters", github: "minitest-reporters/minitest-reporters"
gem "rake"
gem "rubocop"
gem "rubocop-md"
Expand All @@ -17,4 +15,5 @@ gem "rubocop-rake"
gem "ruby-filemagic", "~> 0.7.3"
gem "warning", "~> 1.5"

gem "rspec", "~> 3.0"
gem "rubocop-claude", "~> 0.1"
4 changes: 3 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
require "bundler/gem_tasks"
require "minitest/test_task"
require "rspec/core/rake_task"
require "rubocop/rake_task"

Minitest::TestTask.create
RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new

namespace :zeitwerk do
Expand All @@ -16,4 +18,4 @@ namespace :zeitwerk do
end
end

task default: [:test, :rubocop]
task default: [:test, :spec, :rubocop]
8 changes: 4 additions & 4 deletions codeball.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ Gem::Specification.new do |spec|
spec.version = Codeball::VERSION
spec.authors = ["David Gillis"]
spec.email = ["david@flipmine.com"]
spec.summary = "Bidirectional file bundler for clipboard-friendly LLM workflows"
spec.description = "Pack multiple source files into a single plaintext bundle for " \
spec.summary = "Bidirectional file packer for clipboard-friendly LLM workflows"
spec.description = "Pack multiple source files into a single plaintext codeball for " \
"pasting into LLM context windows, then unpack the response back into files."
spec.homepage = "https://github.com/gillisd/codeball"
spec.license = "MIT"
spec.required_ruby_version = ">= 4.0.1"
spec.required_ruby_version = ">= 3.4"

gemspec_file = File.basename(__FILE__)
files = IO.popen(["git", "ls-files", "-z"], chdir: __dir__, err: IO::NULL) { |ls|
ls.readlines("\x0", chomp: true).reject do |f|
ls.readlines(0.chr, chomp: true).reject do |f|
(f == gemspec_file) ||
f.start_with?("bin/", "test/", "spec/", "features/", ".git", "Gemfile")
end
Expand Down
13 changes: 12 additions & 1 deletion issues.rec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
%key: Id
%typedef: text_t regexp /^.*$/
%typedef: Status_t enum open in_progress closed
%type: Id int
%type: Id uuid
%type: Title line
%type: Description text_t
%type: Status Status_t
Expand Down Expand Up @@ -31,3 +31,14 @@ Description: Version number is not appearing, instead showing:
+ An idiomatic solution should lean on CommandKit's version number feature
Status: open

Id: 631BE27A-2A48-11F1-93E9-FE6CB9572C2D
Updated: Fri, 27 Mar 2026 21:49:41 -0400
Title: Fix all rubocop issues
Description: Many violations are present that claude code is responsible for. They need to be addressed, and no rubocop configuration should be edited unless it would be unreasonable to work around the rule
Status: open

Id: f2ca5c36-31c2-11f1-bf73-fa9e1a133f8e
Updated: Mon, 06 Apr 2026 14:14:39 +0000
Title: Test suite writes to /tmp via Dir.mktmpdir
Description: Dir.mktmpdir uses Dir.tmpdir to resolve the parent directory. Dir.tmpdir checks in order (per /usr/share/ruby/tmpdir.rb lines 130-135): ENV['TMPDIR'], ENV['TMP'], ENV['TEMP'], Etc.systmpdir, /tmp, then current directory. When none of the env vars are set, it falls back to /tmp. This violates the CLAUDE.md hard rule: NEVER write to /tmp. Affected files: spec/spec_helper.rb (CLIHelper#tmp_dir), spec/codeball/destination_spec.rb, and test/entry_test.rb. All calls have after/teardown cleanup so /tmp is not leaked permanently -- the issue is that writes happen to /tmp at all. Fix: set ENV['TMPDIR'] to a project-local directory in spec_helper.rb and test_helper.rb, or pass an explicit second argument to Dir.mktmpdir.
Status: open
14 changes: 10 additions & 4 deletions lib/codeball.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
require 'warning'
require "warning"
require "zeitwerk"

##
# Bidirectional file packer for clipboard-friendly LLM workflows.
#
# Packs multiple source files into a single plaintext codeball and extracts
# them back to disk. Uses Zeitwerk for autoloading.
module Codeball
LOADER = Zeitwerk::Loader.for_gem
LOADER.inflector.inflect( 'cli' => 'CLI' )
LOADER.inflector.inflect("cli" => "CLI")
LOADER.ignore("#{__dir__}/command_kit")
LOADER.setup

# CLI requires command_kit gem - only load if available
# CLI requires command_kit gem - only load if available
begin
require 'command_kit'
require "command_kit"
require_relative "codeball/cli"
Warning.ignore(/FileMagic/)
rescue LoadError
Expand Down
54 changes: 54 additions & 0 deletions lib/codeball/ball.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
module Codeball
# A codeball -- the aggregate root.
#
# Ball starts empty and grows as entries are added, like a snowball.
# It does not touch the filesystem. Parse is a thin factory that
# wires Cursor -> Stream -> Ball.
#
class Ball
def self.parse(text)
raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty?

ball = new
stream = Stream.new(cursor: Cursor.new(text))
stream.each_entry { |entry| ball.add_entry(entry) }
ball.validate!
ball
end

def initialize
@entries = []
@warnings = []
end

def add_entry(entry)
@entries << entry
@warnings << entry.error if entry.errors?
@warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated?
end

def validate!
valid = entries.select(&:valid?)
if valid.empty? && warnings.any?
raise MalformedBallError, "no valid entries found (#{warnings.length} malformed)"
elsif valid.empty?
raise MalformedBallError, "no content found - is this a codeball?"
end
end

def each_entry(&) = entries.select(&:valid?).each(&)
def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&)
def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&)
def each_warning(&) = warnings.each(&)
def all_text? = entries.select(&:valid?).all?(&:text?)
def warning_count = warnings.length

def serialize
entries.select(&:valid?).select(&:text?).map(&:serialize).join
end

private

attr_reader :entries, :warnings
end
end
9 changes: 9 additions & 0 deletions lib/codeball/body.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "delegate"

module Codeball
# File content extracted from between markers in a codeball.
#
# String wrapper providing identity for pattern matching.
#
class Body < SimpleDelegator; end
end
47 changes: 47 additions & 0 deletions lib/codeball/border.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Codeball
# Domain knowledge about the visual delimiter between sections in a codeball.
#
# Borders are repeated punctuation patterns that separate entries in
# serialized codeball text. The pattern is fixed, not configurable.
#
# During serialization, SEPARATOR is used as-is.
# During parsing, recognition is heuristic to tolerate mangling
# by browsers, editors, and clipboard transfer.
#
module Border
PATTERN = "---\t"
WIDTH = 10
SEPARATOR = (PATTERN * WIDTH).freeze
SUFFIX = /[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/
MIN_LENGTH = 6
MIN_PUNCTUATION_LENGTH = 9

module_function

def recognize?(line)
return false if line.empty?
return false if line.start_with?("BEGIN ", "END ")

stripped = line.gsub(/\s+/, "")
return false if stripped.empty?
return false if stripped.length < MIN_LENGTH

single_char?(stripped) || punctuation_run?(stripped)
end

def strip_suffix(text)
text.match?(SUFFIX) ? text.sub(SUFFIX, "").chomp : text
end

def single_char?(stripped)
chars = stripped.chars.uniq
chars.length == 1 && !chars.first.match?(/[a-zA-Z0-9]/)
end

def punctuation_run?(stripped)
stripped.match?(/\A[-#=~*_|+]+\z/) && stripped.length >= MIN_PUNCTUATION_LENGTH
end

private_class_method :single_char?, :punctuation_run?
end
end
Loading
Loading