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,54 @@
1
+ module Codeball
2
+ # A codeball -- the aggregate root.
3
+ #
4
+ # Ball starts empty and grows as entries are added, like a snowball.
5
+ # It does not touch the filesystem. Parse is a thin factory that
6
+ # wires Cursor -> Stream -> Ball.
7
+ #
8
+ class Ball
9
+ def self.parse(text)
10
+ raise MalformedBallError, "empty input, nothing to extract" if text.nil? || text.strip.empty?
11
+
12
+ ball = new
13
+ stream = Stream.new(cursor: Cursor.new(text))
14
+ stream.each_entry { |entry| ball.add_entry(entry) }
15
+ ball.validate!
16
+ ball
17
+ end
18
+
19
+ def initialize
20
+ @entries = []
21
+ @warnings = []
22
+ end
23
+
24
+ def add_entry(entry)
25
+ @entries << entry
26
+ @warnings << entry.error if entry.errors?
27
+ @warnings << "truncated entry for #{entry.path.inspect} - missing END marker" if entry.truncated?
28
+ end
29
+
30
+ def validate!
31
+ valid = entries.select(&:valid?)
32
+ if valid.empty? && warnings.any?
33
+ raise MalformedBallError, "no valid entries found (#{warnings.length} malformed)"
34
+ elsif valid.empty?
35
+ raise MalformedBallError, "no content found - is this a codeball?"
36
+ end
37
+ end
38
+
39
+ def each_entry(&) = entries.select(&:valid?).each(&)
40
+ def each_text_entry(&) = entries.select(&:valid?).select(&:text?).each(&)
41
+ def each_non_text_entry(&) = entries.select(&:valid?).reject(&:text?).each(&)
42
+ def each_warning(&) = warnings.each(&)
43
+ def all_text? = entries.select(&:valid?).all?(&:text?)
44
+ def warning_count = warnings.length
45
+
46
+ def serialize
47
+ entries.select(&:valid?).select(&:text?).map(&:serialize).join
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :entries, :warnings
53
+ end
54
+ end
@@ -0,0 +1,9 @@
1
+ require "delegate"
2
+
3
+ module Codeball
4
+ # File content extracted from between markers in a codeball.
5
+ #
6
+ # String wrapper providing identity for pattern matching.
7
+ #
8
+ class Body < SimpleDelegator; end
9
+ end
@@ -0,0 +1,47 @@
1
+ module Codeball
2
+ # Domain knowledge about the visual delimiter between sections in a codeball.
3
+ #
4
+ # Borders are repeated punctuation patterns that separate entries in
5
+ # serialized codeball text. The pattern is fixed, not configurable.
6
+ #
7
+ # During serialization, SEPARATOR is used as-is.
8
+ # During parsing, recognition is heuristic to tolerate mangling
9
+ # by browsers, editors, and clipboard transfer.
10
+ #
11
+ module Border
12
+ PATTERN = "---\t"
13
+ WIDTH = 10
14
+ SEPARATOR = (PATTERN * WIDTH).freeze
15
+ SUFFIX = /[-#=~*_|+][-#=~*_|+\s]{8,}\s*\z/
16
+ MIN_LENGTH = 6
17
+ MIN_PUNCTUATION_LENGTH = 9
18
+
19
+ module_function
20
+
21
+ def recognize?(line)
22
+ return false if line.empty?
23
+ return false if line.start_with?("BEGIN ", "END ")
24
+
25
+ stripped = line.gsub(/\s+/, "")
26
+ return false if stripped.empty?
27
+ return false if stripped.length < MIN_LENGTH
28
+
29
+ single_char?(stripped) || punctuation_run?(stripped)
30
+ end
31
+
32
+ def strip_suffix(text)
33
+ text.match?(SUFFIX) ? text.sub(SUFFIX, "").chomp : text
34
+ end
35
+
36
+ def single_char?(stripped)
37
+ chars = stripped.chars.uniq
38
+ chars.length == 1 && !chars.first.match?(/[a-zA-Z0-9]/)
39
+ end
40
+
41
+ def punctuation_run?(stripped)
42
+ stripped.match?(/\A[-#=~*_|+]+\z/) && stripped.length >= MIN_PUNCTUATION_LENGTH
43
+ end
44
+
45
+ private_class_method :single_char?, :punctuation_run?
46
+ end
47
+ end
@@ -0,0 +1,24 @@
1
+ require "command_kit/commands"
2
+ require "command_kit/commands/auto_load"
3
+ require "command_kit/options/version"
4
+
5
+ module Codeball
6
+ # Main CLI entry point for Codeball.
7
+ #
8
+ class CLI
9
+ include CommandKit::Commands
10
+ include CommandKit::Description
11
+ include CommandKit::Options::Version
12
+
13
+ version Codeball::VERSION
14
+
15
+ # Auto-load subcommands from lib/codeball/commands/*.rb
16
+ include CommandKit::Commands::AutoLoad.new(
17
+ dir: File.join(__dir__, "commands"),
18
+ namespace: "Codeball::Commands",
19
+ )
20
+
21
+ command_name "codeball"
22
+ description "Bidirectional file bundler for clipboard-friendly LLM workflows"
23
+ end
24
+ end
@@ -0,0 +1,51 @@
1
+ require "command_kit"
2
+ require "command_kit/command"
3
+ require "command_kit/colors"
4
+
5
+ module Codeball
6
+ module Commands
7
+ # Diff extracted files against local copies.
8
+ #
9
+ # Incomplete -- diff output is not yet implemented.
10
+ #
11
+ class Diff < CommandKit::Command
12
+ include CommandKit::Colors
13
+
14
+ usage "[options] [FILE]"
15
+ description "Diff codeball entries against local files"
16
+
17
+ option :output_dir, short: "-o",
18
+ value: { type: String, default: "." },
19
+ desc: "Directory to compare against"
20
+
21
+ argument :file, required: false,
22
+ desc: "Codeball file (or stdin if omitted)"
23
+
24
+ examples [
25
+ "bundle.txt",
26
+ "< bundle.txt",
27
+ ]
28
+
29
+ def run(file = nil)
30
+ input = read_input(file)
31
+ ball = Ball.parse(input)
32
+
33
+ ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
34
+
35
+ # Diff output not yet implemented
36
+ end
37
+
38
+ private
39
+
40
+ def read_input(file)
41
+ ARGV.replace(file ? [file] : [])
42
+ input = ARGF.read
43
+
44
+ return input unless input.nil? || input.strip.empty?
45
+
46
+ print_error "no input"
47
+ exit 1
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,49 @@
1
+ require "command_kit/commands/command"
2
+ require "command_kit/printing/tables"
3
+ require "command_kit/colors"
4
+ require_relative "../../command_kit/printing"
5
+ require_relative "../../command_kit/combined_io"
6
+
7
+ module Codeball
8
+ module Commands
9
+ # List files contained in a codeball.
10
+ class List < CommandKit::Commands::Command
11
+ include CommandKit::CombinedIO
12
+ include CommandKit::Colors
13
+ include CommandKit::Printing::Tables
14
+
15
+ usage "[options] [FILE]"
16
+ description "List files in a codeball"
17
+
18
+ argument :file, required: false, desc: "Codeball file (or stdin if omitted)"
19
+
20
+ examples ["bundle.txt", "< bundle.txt"]
21
+
22
+ def env
23
+ (super || {}).merge("TERM" => "1")
24
+ end
25
+
26
+ def run(io)
27
+ input = io.read
28
+ abort_if_empty(input)
29
+
30
+ ball = Ball.parse(input)
31
+
32
+ ball.each_warning { |msg| stderr.puts colors.yellow("warning: #{msg}") }
33
+
34
+ rows = []
35
+ ball.each_entry { |e| rows << [e.path, "#{e.line_count} lines"] }
36
+ print_table_color(rows, header: %w[File Lines], color: :green, index: 0)
37
+ end
38
+
39
+ private
40
+
41
+ def abort_if_empty(input)
42
+ return unless input.nil? || input.strip.empty?
43
+
44
+ print_error "no input"
45
+ exit 1
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ require "command_kit/commands/command"
2
+
3
+ module Codeball
4
+ module Commands
5
+ # Pack files into a codeball for clipboard transfer.
6
+ #
7
+ class Pack < CommandKit::Commands::Command
8
+ usage "[options] FILE..."
9
+
10
+ description "Pack files into a codeball for clipboard transfer"
11
+
12
+ option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
13
+
14
+ argument :files, required: true,
15
+ repeats: true,
16
+ desc: "Files to pack"
17
+
18
+ examples [
19
+ "lib/*.rb",
20
+ "src/**/*.py",
21
+ "README.md lib/*.rb",
22
+ ]
23
+
24
+ def run(*files)
25
+ readable, unreadable = validate_files(files)
26
+ ball = Ball.new
27
+ readable.each do |path|
28
+ entry = Entry.from_file(path)
29
+ ball.add_entry(entry) if entry
30
+ end
31
+
32
+ warn_skipped(unreadable, ball)
33
+ puts ball.serialize
34
+
35
+ exit 1 if unreadable.any? || !ball.all_text?
36
+ end
37
+
38
+ private
39
+
40
+ def validate_files(files)
41
+ files
42
+ .map { Pathname(it) }
43
+ .partition { it.exist? && it.readable? }
44
+ end
45
+
46
+ def warn_skipped(unreadable, ball)
47
+ return if options[:quiet]
48
+
49
+ unreadable.each { print_error "cannot read file: #{it}" }
50
+ ball.each_non_text_entry { |entry| print_error "skipping non-text file: #{entry.path} (#{entry.mime_type})" }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,123 @@
1
+ require "command_kit/commands/command"
2
+ require "command_kit/colors"
3
+
4
+ module Codeball
5
+ module Commands
6
+ # Extract files from a codeball.
7
+ #
8
+ class Unpack < CommandKit::Commands::Command
9
+ include CommandKit::Colors
10
+
11
+ usage "[options] [FILE]"
12
+ description "Extract files from a codeball"
13
+
14
+ option :output_dir, short: "-o",
15
+ value: { type: String, default: "." },
16
+ desc: "Output directory"
17
+
18
+ option :dry_run, short: "-n",
19
+ desc: "Preview extraction without writing files"
20
+
21
+ option :quiet, short: "-q", long: "--quiet", desc: "Suppress non-error output"
22
+
23
+ argument :file, required: false,
24
+ desc: "Codeball file (or stdin if omitted)"
25
+
26
+ examples [
27
+ "bundle.txt",
28
+ "-n bundle.txt",
29
+ "-o extracted/ bundle.txt",
30
+ "< bundle.txt",
31
+ ]
32
+
33
+ def run(file = nil)
34
+ ball = Ball.parse(read_input(file))
35
+ dest = build_destination
36
+
37
+ ball.each_warning { |msg| warn colors.yellow("warning: #{msg}") }
38
+ ball.each_entry { |entry| dest.write(entry) { |outcome| print_outcome(outcome) } }
39
+
40
+ print_summary(dest.summary(malformed: ball.warning_count))
41
+ end
42
+
43
+ private
44
+
45
+ def build_destination
46
+ Destination.new(options[:output_dir], dry_run: options[:dry_run])
47
+ end
48
+
49
+ def read_input(file)
50
+ ARGV.replace(file ? [file] : [])
51
+ input = ARGF.read
52
+
53
+ abort_on_empty(input)
54
+ input
55
+ end
56
+
57
+ def abort_on_empty(input)
58
+ return unless input.nil? || input.strip.empty?
59
+
60
+ print_error "no input"
61
+ exit 1
62
+ end
63
+
64
+ def puts(...)
65
+ return if options[:quiet]
66
+
67
+ stdout.puts(...)
68
+ end
69
+
70
+ def warn(...)
71
+ return if options[:quiet]
72
+
73
+ stderr.puts(...)
74
+ end
75
+
76
+ def print_outcome(outcome)
77
+ case outcome.status
78
+ when :written then print_written(outcome)
79
+ when :dry_run then print_dry_run(outcome)
80
+ when :unsafe then print_unsafe(outcome)
81
+ when :failed then print_failed(outcome)
82
+ end
83
+ end
84
+
85
+ def print_written(outcome)
86
+ puts "#{colors.green("wrote")}: #{outcome.path} (#{outcome.line_count} lines)"
87
+ end
88
+
89
+ def print_dry_run(outcome)
90
+ puts "#{colors.cyan("[dry-run]")} would write: #{outcome.path} (#{outcome.line_count} lines)"
91
+ end
92
+
93
+ def print_unsafe(outcome)
94
+ warn colors.yellow("warning: skipping unsafe path #{outcome.path.inspect}")
95
+ end
96
+
97
+ def print_failed(outcome)
98
+ warn colors.red("error: #{outcome.path}: #{outcome.error}")
99
+ end
100
+
101
+ def print_summary(summary)
102
+ prefix = summary.dry_run? ? "#{colors.cyan("[dry-run]")} " : ""
103
+ puts "---"
104
+ puts "#{prefix}#{summary_parts(summary).join(", ")}"
105
+ end
106
+
107
+ def summary_parts(summary)
108
+ parts = [colors.green("extracted: #{summary.extracted}").to_s]
109
+ parts << skipped_part(summary)
110
+ parts << colors.yellow("malformed: #{summary.malformed}") if summary.malformed.positive?
111
+ parts
112
+ end
113
+
114
+ def skipped_part(summary)
115
+ if summary.skipped.positive?
116
+ colors.yellow("skipped: #{summary.skipped}")
117
+ else
118
+ "skipped: 0"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,115 @@
1
+ module Codeball
2
+ # A lexer for codeball-formatted text.
3
+ #
4
+ # Cursor walks text line by line and classifies each meaningful
5
+ # element as a typed token: Header, Body, or Footer. Borders are
6
+ # delimiters consumed internally -- they are never yielded.
7
+ #
8
+ # Cursor does not correlate tokens or enforce sequencing.
9
+ # Stream handles assembly; Entry enforces invariants.
10
+ #
11
+ class Cursor
12
+ BEGIN_PATTERN = /\ABEGIN\s+["']?(.+?)["']?\s*\z/
13
+ END_PATTERN = /\AEND\s+["']?(.+?)["']?\s*\z/
14
+
15
+ # Sentinel returned when all tokens have been consumed.
16
+ module EOF; end
17
+
18
+ def initialize(text)
19
+ @lines = text.lines
20
+ @position = 0
21
+ @pending_footer = nil
22
+ @body_lines = nil
23
+ end
24
+
25
+ def next_item
26
+ return emit_footer if @pending_footer
27
+
28
+ skip_borders
29
+ return EOF if finished?
30
+
31
+ if @body_lines
32
+ read_body
33
+ else
34
+ read_header_or_eof
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :lines, :position
41
+
42
+ def finished? = position >= lines.length
43
+ def current_line = lines[position]&.strip
44
+ def raw_line = lines[position]
45
+
46
+ def advance
47
+ @position += 1
48
+ end
49
+
50
+ def peek_line
51
+ lines[position + 1]&.strip
52
+ end
53
+
54
+ def skip_borders
55
+ advance while !finished? && Border.recognize?(current_line)
56
+ end
57
+
58
+ def read_header_or_eof
59
+ match = current_line&.match(BEGIN_PATTERN)
60
+ return EOF unless match
61
+
62
+ advance
63
+ skip_borders
64
+ @body_lines = []
65
+ Header.new(match[1])
66
+ end
67
+
68
+ def read_body
69
+ collect_body_lines
70
+ body = Body.new(Border.strip_suffix(@body_lines.join))
71
+ @body_lines = nil
72
+ body
73
+ end
74
+
75
+ def collect_body_lines
76
+ until finished?
77
+ return found_end_after_border if border_before_end?
78
+ return found_end_inline if inline_end_marker?
79
+
80
+ @body_lines << raw_line
81
+ advance
82
+ end
83
+ end
84
+
85
+ def border_before_end?
86
+ Border.recognize?(current_line) &&
87
+ peek_line&.match?(END_PATTERN)
88
+ end
89
+
90
+ def inline_end_marker?
91
+ return false unless current_line&.match?(END_PATTERN)
92
+
93
+ @body_lines.empty? || @body_lines.last&.match?(Border::SUFFIX)
94
+ end
95
+
96
+ def found_end_inline
97
+ match = current_line.match(END_PATTERN)
98
+ @pending_footer = match[1]
99
+ advance
100
+ end
101
+
102
+ def found_end_after_border
103
+ advance
104
+ end_match = current_line.match(END_PATTERN)
105
+ @pending_footer = end_match[1]
106
+ advance
107
+ end
108
+
109
+ def emit_footer
110
+ path = @pending_footer
111
+ @pending_footer = nil
112
+ Footer.new(path)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,76 @@
1
+ require "pathname"
2
+
3
+ module Codeball
4
+ # A filesystem context that writes entries to an output directory.
5
+ #
6
+ # Destination decorates a directory path with the ability to receive
7
+ # codeball entries. It owns path safety validation, parent directory
8
+ # creation, and dry-run simulation.
9
+ #
10
+ # Tracks outcomes internally and provides a summary when asked.
11
+ #
12
+ class Destination
13
+ DANGEROUS_PATTERNS = [
14
+ /\A\.\./,
15
+ %r{/\.\.},
16
+ %r{\A/},
17
+ /\A~/,
18
+ ].freeze
19
+
20
+ attr_reader :output_dir
21
+
22
+ def initialize(output_dir = ".", dry_run: false)
23
+ @output_dir = Pathname.new(output_dir).expand_path
24
+ @dry_run = dry_run ? true : false
25
+ @results = []
26
+ end
27
+
28
+ def dry_run? = @dry_run
29
+
30
+ def write(entry)
31
+ outcome = write_entry(entry)
32
+ @results << outcome
33
+ yield outcome if block_given?
34
+ outcome
35
+ end
36
+
37
+ def summary(malformed: 0)
38
+ ExtractionSummary.new(@results, malformed: malformed)
39
+ end
40
+
41
+ private
42
+
43
+ def write_entry(entry)
44
+ return unsafe_result(entry) unless safe_path?(entry.path)
45
+
46
+ resolved = resolve(entry.path)
47
+ dry_run? ? dry_run_result(entry, resolved) : persist(entry, resolved)
48
+ rescue SystemCallError => e
49
+ ExtractionResult.new(path: entry.path, error: e.message, status: :failed)
50
+ end
51
+
52
+ def safe_path?(path)
53
+ return false if DANGEROUS_PATTERNS.any? { |pattern| path.match?(pattern) }
54
+
55
+ resolve(path).to_s.start_with?(output_dir.to_s)
56
+ end
57
+
58
+ def resolve(path)
59
+ (output_dir / path).expand_path
60
+ end
61
+
62
+ def unsafe_result(entry)
63
+ ExtractionResult.new(path: entry.path, status: :unsafe)
64
+ end
65
+
66
+ def dry_run_result(entry, resolved)
67
+ ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :dry_run)
68
+ end
69
+
70
+ def persist(entry, resolved)
71
+ resolved.parent.mkpath
72
+ resolved.write(entry.contents)
73
+ ExtractionResult.new(path: resolved, line_count: entry.line_count, status: :written)
74
+ end
75
+ end
76
+ end