codeball 0.2.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.
@@ -0,0 +1,107 @@
1
+ require "pathname"
2
+ require "filemagic"
3
+
4
+ module Codeball
5
+ # A single file within a codeball.
6
+ #
7
+ # Entry is a state machine with write-once setters for header, body,
8
+ # and footer. It enforces the Header -> Body -> Footer sequence by
9
+ # rejecting duplicate assignments and detecting mismatched footers.
10
+ #
11
+ # Two construction paths, same invariants:
12
+ # 1. Token-by-token via Stream (parsing)
13
+ # 2. All-at-once via Entry.from_file (packing)
14
+ #
15
+ class Entry
16
+ attr_reader :header, :body, :footer, :error
17
+
18
+ def self.from_file(path)
19
+ pathname = Pathname.new(path)
20
+ return nil unless pathname.exist? && pathname.readable?
21
+
22
+ entry = new
23
+ name = pathname.to_s
24
+ entry.header = Header.new(name)
25
+ entry.body = Body.new(pathname.read)
26
+ entry.footer = Footer.new(name)
27
+ entry
28
+ end
29
+
30
+ def self.magic_client
31
+ @magic_client ||= FileMagic.mime
32
+ end
33
+
34
+ def initialize
35
+ @header = nil
36
+ @body = nil
37
+ @footer = nil
38
+ @error = nil
39
+ @magic_client = self.class.magic_client
40
+ end
41
+
42
+ def header=(header)
43
+ if @header
44
+ @error = "duplicate header: already have #{@header}, received #{header}"
45
+ return
46
+ end
47
+ @header = header
48
+ end
49
+
50
+ def body=(body)
51
+ if @body
52
+ @error = "duplicate body for #{path}"
53
+ return
54
+ end
55
+ @body = body
56
+ end
57
+
58
+ def footer=(footer)
59
+ if @footer
60
+ @error = "duplicate footer for #{path}"
61
+ return
62
+ end
63
+ @footer = footer
64
+ @error = "footer #{footer} does not match header #{header}" unless footer_matches_header?
65
+ end
66
+
67
+ def valid? = !!(header && body && footer && !errors? && footer_matches_header?)
68
+ def incomplete? = !valid? && !errors?
69
+ def errors? = !error.nil?
70
+ def truncated? = !!(header && (body.nil? || footer.nil?) && !errors?)
71
+
72
+ def path = header&.to_s
73
+ def contents = body&.to_s
74
+
75
+ def empty? = contents&.empty? || contents.nil?
76
+ def byte_size = contents&.bytesize || 0
77
+
78
+ def line_count
79
+ return 0 if contents.nil? || contents.empty?
80
+
81
+ contents.count("\n") + (contents.end_with?("\n") ? 0 : 1)
82
+ end
83
+
84
+ def text?
85
+ contents.nil? || contents.empty? || !mime_type.include?("charset=binary")
86
+ end
87
+
88
+ def serialize
89
+ border = Border::SEPARATOR
90
+ "#{border}\nBEGIN #{path.inspect}\n#{border}\n#{contents}#{border}\nEND #{path.inspect}\n#{border}\n"
91
+ end
92
+
93
+ def mime_type
94
+ @mime_type ||= @magic_client.buffer(contents)
95
+ end
96
+
97
+ private
98
+
99
+ attr_reader :magic_client
100
+
101
+ def footer_matches_header?
102
+ return true unless header && footer
103
+
104
+ header.to_s == footer.to_s
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,4 @@
1
+ module Codeball
2
+ # Base error class for all Codeball-specific exceptions.
3
+ class Error < StandardError; end
4
+ end
@@ -0,0 +1,16 @@
1
+ module Codeball
2
+ # Represents the outcome of extracting a single entry from a codeball.
3
+ #
4
+ # ## Example
5
+ #
6
+ # ```ruby
7
+ # puts "Wrote #{result.path}"
8
+ # puts "Failed: #{result.error}"
9
+ # ```
10
+ #
11
+ ExtractionResult = Struct.new(:path, :line_count, :status, :error) do
12
+ # Whether the extraction completed successfully.
13
+ # Both actual writes and dry-run simulations count as success.
14
+ def success? = %i[written dry_run].include?(status)
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ module Codeball
2
+ # Aggregates results from extracting multiple entries.
3
+ # Pure data class - no output methods.
4
+ #
5
+ class ExtractionSummary
6
+ attr_reader :results, :malformed
7
+
8
+ def initialize(results, malformed: 0)
9
+ @results = results
10
+ @malformed = malformed
11
+ end
12
+
13
+ def extracted = results.count(&:success?)
14
+ def skipped = results.count { !it.success? }
15
+ def dry_run? = results.any? { it.status == :dry_run }
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ require "delegate"
2
+
3
+ module Codeball
4
+ # A file path extracted from an END marker in a codeball.
5
+ #
6
+ # String wrapper providing identity for pattern matching.
7
+ #
8
+ class Footer < SimpleDelegator; end
9
+ end
@@ -0,0 +1,9 @@
1
+ require "delegate"
2
+
3
+ module Codeball
4
+ # A file path extracted from a BEGIN marker in a codeball.
5
+ #
6
+ # String wrapper providing identity for pattern matching.
7
+ #
8
+ class Header < SimpleDelegator; end
9
+ end
@@ -0,0 +1,3 @@
1
+ module Codeball
2
+ class MalformedBallError < Error; end
3
+ end
@@ -0,0 +1,60 @@
1
+ module Codeball
2
+ # Assembles Entry objects from a stream of tokens produced by Cursor.
3
+ #
4
+ # Stream pulls tokens one at a time, feeds them to the current Entry,
5
+ # and emits it when Entry reports valid or errored. Stream does not
6
+ # know the Header -> Body -> Footer rules -- Entry enforces those
7
+ # through its write-once setters.
8
+ #
9
+ # Nothing is discarded. Every entry -- valid, errored, or truncated
10
+ # -- is emitted so the consumer can decide what to do with it.
11
+ #
12
+ class Stream
13
+ include Enumerable
14
+
15
+ def initialize(cursor:)
16
+ @cursor = cursor
17
+ new_entry
18
+ end
19
+
20
+ def each(&block)
21
+ return enum_for(:each) unless block
22
+
23
+ consume_tokens(&block)
24
+ emit_incomplete(&block)
25
+ end
26
+
27
+ alias each_entry each
28
+
29
+ private
30
+
31
+ attr_reader :cursor
32
+
33
+ def consume_tokens
34
+ while (item = cursor.next_item) != Cursor::EOF
35
+ feed(item)
36
+
37
+ if @current_entry.valid? || @current_entry.errors?
38
+ yield @current_entry
39
+ new_entry
40
+ end
41
+ end
42
+ end
43
+
44
+ def emit_incomplete
45
+ yield @current_entry if @current_entry&.incomplete? && @current_entry.header
46
+ end
47
+
48
+ def new_entry
49
+ @current_entry = Entry.new
50
+ end
51
+
52
+ def feed(item)
53
+ case item
54
+ in Header => header then @current_entry.header = header
55
+ in Body => body then @current_entry.body = body
56
+ in Footer => footer then @current_entry.footer = footer
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module Codeball
2
+ VERSION = "0.2.0".freeze
3
+ end
data/lib/codeball.rb ADDED
@@ -0,0 +1,23 @@
1
+ require "warning"
2
+ require "zeitwerk"
3
+
4
+ ##
5
+ # Bidirectional file packer for clipboard-friendly LLM workflows.
6
+ #
7
+ # Packs multiple source files into a single plaintext codeball and extracts
8
+ # them back to disk. Uses Zeitwerk for autoloading.
9
+ module Codeball
10
+ LOADER = Zeitwerk::Loader.for_gem
11
+ LOADER.inflector.inflect("cli" => "CLI")
12
+ LOADER.ignore("#{__dir__}/command_kit")
13
+ LOADER.setup
14
+
15
+ # CLI requires command_kit gem - only load if available
16
+ begin
17
+ require "command_kit"
18
+ require_relative "codeball/cli"
19
+ Warning.ignore(/FileMagic/)
20
+ rescue LoadError
21
+ # command_kit not installed, CLI unavailable
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ require "command_kit/open"
2
+
3
+ module CommandKit
4
+ # Opens readable arguments as IO streams, defaulting to stdin.
5
+ # Uses <tt>CommandKit::Open#open</tt> to handle filenames and +"-"+ for stdin.
6
+ module CombinedIO
7
+ include CommandKit::Open
8
+
9
+ def self.included(base)
10
+ base.prepend Prepended
11
+ end
12
+
13
+ # Prepends +run+ to open file arguments (or stdin) as IO streams.
14
+ module Prepended
15
+ def run(*args)
16
+ args << "-" if args.empty?
17
+
18
+ ios = args.map { |readable| self.open(readable) }
19
+
20
+ begin
21
+ super(*ios)
22
+ ensure
23
+ ios.each(&:close)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ module CommandKit
2
+ # Extends +CommandKit::Printing+ with color-aware table printing.
3
+ #
4
+ # Computes column widths from raw text, then applies ANSI color
5
+ # after padding so escape sequences don't break alignment.
6
+ module Printing
7
+ def print_table_color(rows, header: nil, color: :green, index: 0, **)
8
+ all_rows = header ? [header] + rows : rows
9
+ widths = column_widths(all_rows)
10
+ print_header(header, widths) if header
11
+ rows.each do |row|
12
+ line = format_row(row, widths, color, index)
13
+ puts line.join(" ")
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def print_header(header, widths)
20
+ line = header.each_with_index.map do |cell, i|
21
+ colors.bold(cell.to_s.ljust(widths[i]))
22
+ end
23
+ puts line.join(" ")
24
+ end
25
+
26
+ def format_row(row, widths, color, index)
27
+ row.each_with_index.map do |cell, i|
28
+ padded = cell.to_s.ljust(widths[i])
29
+ i == index ? colors.public_send(color, padded) : padded
30
+ end
31
+ end
32
+
33
+ def column_widths(rows)
34
+ rows.each_with_object(Hash.new(0)) do |row, widths|
35
+ row.each_with_index do |cell, i|
36
+ len = cell.to_s.length
37
+ widths[i] = len if len > widths[i]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env zsh
2
+
3
+ typeset -A opt_args
4
+
5
+ zparseopts \
6
+ -D \
7
+ -E \
8
+ -K \
9
+ -A \
10
+ opt_args \
11
+ -F \
12
+ - \
13
+ -envelope
14
+
15
+ (( ? != 0 )) && return 1
16
+
17
+ filter() {
18
+ local pattern1=${1:?}
19
+ {
20
+ if (( $+opt_args[--envelope] )); then
21
+ print -u2 envelope
22
+ noglob sed -n "/BEGIN ${(qqq)pattern1}/,/END/p"
23
+ else
24
+ noglob sed -n "/BEGIN ${(qqq)pattern1}/,/END/{//d;p}"
25
+ fi
26
+ } \
27
+ | sed '1d;$d'
28
+ }
29
+
30
+ codeball_xtract () {
31
+ emulate -L zsh
32
+ setopt pipefail
33
+ setopt errreturn
34
+ setopt warnnestedvar
35
+ setopt warncreateglobal
36
+ local matcher=${1:?}
37
+ local file=${2:-/dev/stdin}
38
+ local pattern1="[^\"]*${matcher}[^\"]*"
39
+ filter $pattern1 < $file
40
+ }
41
+
42
+ codeball_xtract ${@}
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: codeball
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - David Gillis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: command_kit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: zeitwerk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Pack multiple source files into a single plaintext codeball for pasting
41
+ into LLM context windows, then unpack the response back into files.
42
+ email:
43
+ - david@flipmine.com
44
+ executables:
45
+ - codeball
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".rubocop.yml"
50
+ - ".ruby-version"
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - data/wont_pack.md
55
+ - exe/codeball
56
+ - issues.rec
57
+ - lib/codeball.rb
58
+ - lib/codeball/ball.rb
59
+ - lib/codeball/body.rb
60
+ - lib/codeball/border.rb
61
+ - lib/codeball/cli.rb
62
+ - lib/codeball/commands/diff.rb
63
+ - lib/codeball/commands/list.rb
64
+ - lib/codeball/commands/pack.rb
65
+ - lib/codeball/commands/unpack.rb
66
+ - lib/codeball/cursor.rb
67
+ - lib/codeball/destination.rb
68
+ - lib/codeball/entry.rb
69
+ - lib/codeball/error.rb
70
+ - lib/codeball/extraction_result.rb
71
+ - lib/codeball/extraction_summary.rb
72
+ - lib/codeball/footer.rb
73
+ - lib/codeball/header.rb
74
+ - lib/codeball/malformed_ball_error.rb
75
+ - lib/codeball/stream.rb
76
+ - lib/codeball/version.rb
77
+ - lib/command_kit/combined_io.rb
78
+ - lib/command_kit/printing.rb
79
+ - scripts/codeball_xtract
80
+ homepage: https://github.com/gillisd/codeball
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ rubygems_mfa_required: 'true'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '3.4'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.9
100
+ specification_version: 4
101
+ summary: Bidirectional file packer for clipboard-friendly LLM workflows
102
+ test_files: []