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.
- checksums.yaml +7 -0
- data/.rubocop.yml +342 -0
- data/.ruby-version +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +25 -0
- data/Rakefile +23 -0
- data/data/wont_pack.md +177 -0
- data/exe/codeball +5 -0
- data/issues.rec +61 -0
- data/lib/codeball/ball.rb +54 -0
- data/lib/codeball/body.rb +9 -0
- data/lib/codeball/border.rb +47 -0
- data/lib/codeball/cli.rb +24 -0
- data/lib/codeball/commands/diff.rb +51 -0
- data/lib/codeball/commands/list.rb +49 -0
- data/lib/codeball/commands/pack.rb +54 -0
- data/lib/codeball/commands/unpack.rb +123 -0
- data/lib/codeball/cursor.rb +115 -0
- data/lib/codeball/destination.rb +76 -0
- data/lib/codeball/entry.rb +107 -0
- data/lib/codeball/error.rb +4 -0
- data/lib/codeball/extraction_result.rb +16 -0
- data/lib/codeball/extraction_summary.rb +17 -0
- data/lib/codeball/footer.rb +9 -0
- data/lib/codeball/header.rb +9 -0
- data/lib/codeball/malformed_ball_error.rb +3 -0
- data/lib/codeball/stream.rb +60 -0
- data/lib/codeball/version.rb +3 -0
- data/lib/codeball.rb +23 -0
- data/lib/command_kit/combined_io.rb +28 -0
- data/lib/command_kit/printing.rb +42 -0
- data/scripts/codeball_xtract +42 -0
- metadata +102 -0
|
@@ -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,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,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
|
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: []
|