hunk_review_changes 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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/LICENSE.txt +27 -0
  4. data/README.md +126 -0
  5. data/exe/hunk-review-changes +6 -0
  6. data/lib/hunk_review_changes/app.rb +138 -0
  7. data/lib/hunk_review_changes/assets.rb +34 -0
  8. data/lib/hunk_review_changes/bundle.rb +122 -0
  9. data/lib/hunk_review_changes/cli.rb +106 -0
  10. data/lib/hunk_review_changes/diff.rb +205 -0
  11. data/lib/hunk_review_changes/export.rb +84 -0
  12. data/lib/hunk_review_changes/installer/base.rb +39 -0
  13. data/lib/hunk_review_changes/installer/claude_code.rb +44 -0
  14. data/lib/hunk_review_changes/installer/codex.rb +21 -0
  15. data/lib/hunk_review_changes/installer/cursor.rb +22 -0
  16. data/lib/hunk_review_changes/installer/directory_installer.rb +36 -0
  17. data/lib/hunk_review_changes/installer/opencode.rb +27 -0
  18. data/lib/hunk_review_changes/installer/runner.rb +122 -0
  19. data/lib/hunk_review_changes/installer/skill_source.rb +53 -0
  20. data/lib/hunk_review_changes/lifecycle.rb +42 -0
  21. data/lib/hunk_review_changes/markdown.rb +69 -0
  22. data/lib/hunk_review_changes/public/app.css +237 -0
  23. data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleMono.woff2 +0 -0
  24. data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext-Italic.woff2 +0 -0
  25. data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext.woff2 +0 -0
  26. data/lib/hunk_review_changes/public/fonts/OFL.txt +98 -0
  27. data/lib/hunk_review_changes/server.rb +99 -0
  28. data/lib/hunk_review_changes/state.rb +99 -0
  29. data/lib/hunk_review_changes/version.rb +5 -0
  30. data/lib/hunk_review_changes/views/index.erb +395 -0
  31. data/lib/hunk_review_changes.rb +35 -0
  32. metadata +185 -0
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+ require "diff/lcs"
5
+ require "cgi"
6
+
7
+ module HunkReviewChanges
8
+ # Parses a unified-diff hunk and renders it as an HTML table with line-number
9
+ # gutters. Two layers of highlighting:
10
+ #
11
+ # * Rouge syntax highlighting on context lines and unpaired add/del lines. The
12
+ # class-based HTML formatter emits <span class="k"> tokens whose colours come
13
+ # from CSS (app.css), which is what lets the same markup render light or dark.
14
+ # * Word-level diff on lines that were modified in place: a run of deletions
15
+ # immediately followed by a run of additions is paired line-by-line, and the
16
+ # changed words are wrapped in dw-del / dw-add spans so the eye lands on exactly
17
+ # what moved.
18
+ module Diff
19
+ module_function
20
+
21
+ FORMATTER = Rouge::Formatters::HTML.new
22
+
23
+ # Metadata lines that carry no reviewable content; always dropped. None of these
24
+ # collide with an in-hunk content line, whose raw form always begins with +, -,
25
+ # or a space.
26
+ SKIP_PREFIXES = [
27
+ "diff --git", "index ", "new file", "deleted file",
28
+ "similarity", "rename ", "old mode", "new mode", "\\ No newline"
29
+ ].freeze
30
+
31
+ # File header markers that DO collide with content once the line marker is added:
32
+ # a deleted "-- x" reads "--- x" and an added "++ x" reads "+++ x". Drop them only
33
+ # outside a hunk, where they are genuinely headers.
34
+ FILE_HEADER_PREFIXES = ["--- ", "+++ "].freeze
35
+
36
+ # A single rendered diff line. :html is filled in lazily: word-diff for paired
37
+ # modifications, Rouge highlighting for everything else.
38
+ Row = Struct.new(:kind, :old_ln, :new_ln, :text, :html, keyword_init: true)
39
+
40
+ def to_html(diff_text, file)
41
+ rows = parse(diff_text)
42
+ pair_modifications!(rows)
43
+ lexer = lexer_for(file)
44
+ render(rows, lexer)
45
+ end
46
+
47
+ def parse(diff_text)
48
+ old_ln = new_ln = nil
49
+ in_hunk = false
50
+ rows = []
51
+ diff_text.to_s.each_line do |raw|
52
+ line = raw.chomp
53
+ if line.start_with?("@@")
54
+ old_ln, new_ln = hunk_bounds(line, old_ln, new_ln)
55
+ in_hunk = true
56
+ rows << Row.new(kind: :hunk, text: line)
57
+ next
58
+ end
59
+
60
+ # A new file section ends the current hunk, so its ---/+++ lines are headers.
61
+ in_hunk = false if line.start_with?("diff --git")
62
+ next if skip_metadata?(line, in_hunk)
63
+
64
+ old_ln, new_ln = push_content(rows, line, old_ln, new_ln)
65
+ end
66
+ rows
67
+ end
68
+
69
+ # Line numbers the next content line starts from, read off the @@ header.
70
+ def hunk_bounds(line, old_ln, new_ln)
71
+ return [old_ln, new_ln] unless (m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
72
+
73
+ [m[1].to_i, m[2].to_i]
74
+ end
75
+
76
+ # File-metadata lines are dropped; the ---/+++ pair only outside a hunk, where it
77
+ # is a real header rather than an added/deleted line that happens to start with +/-.
78
+ def skip_metadata?(line, in_hunk)
79
+ return true if SKIP_PREFIXES.any? { |prefix| line.start_with?(prefix) }
80
+
81
+ !in_hunk && FILE_HEADER_PREFIXES.any? { |prefix| line.start_with?(prefix) }
82
+ end
83
+
84
+ # Append the add/del/context row for a content line and return the advanced
85
+ # [old_ln, new_ln] cursor (nil bounds stay nil).
86
+ def push_content(rows, line, old_ln, new_ln)
87
+ content = line.length > 1 ? line[1..] : ""
88
+ case line[0]
89
+ when "+"
90
+ rows << Row.new(kind: :add, new_ln: new_ln, text: content)
91
+ [old_ln, new_ln && (new_ln + 1)]
92
+ when "-"
93
+ rows << Row.new(kind: :del, old_ln: old_ln, text: content)
94
+ [old_ln && (old_ln + 1), new_ln]
95
+ else # context (space marker or blank line)
96
+ rows << Row.new(kind: :ctx, old_ln: old_ln, new_ln: new_ln, text: content)
97
+ [old_ln && (old_ln + 1), new_ln && (new_ln + 1)]
98
+ end
99
+ end
100
+
101
+ # Find each run of deletions followed immediately by a run of additions and pair
102
+ # them line-by-line, filling :html with word-level highlighting on both sides.
103
+ def pair_modifications!(rows)
104
+ index = 0
105
+ while index < rows.length
106
+ unless rows[index].kind == :del
107
+ index += 1
108
+ next
109
+ end
110
+
111
+ del_start = index
112
+ index += 1 while index < rows.length && rows[index].kind == :del
113
+ add_start = index
114
+ index += 1 while index < rows.length && rows[index].kind == :add
115
+
116
+ dels = rows[del_start...add_start]
117
+ adds = rows[add_start...index]
118
+ [dels.length, adds.length].min.times do |offset|
119
+ del_html, add_html = word_diff(dels[offset].text, adds[offset].text)
120
+ dels[offset].html = del_html
121
+ adds[offset].html = add_html
122
+ end
123
+ end
124
+ end
125
+
126
+ # Split a line into an alternating stream of identifiers, whitespace runs, and
127
+ # single punctuation characters so the word diff aligns on meaningful units.
128
+ def tokenize(text)
129
+ text.scan(/\w+|\s+|[^\w\s]/)
130
+ end
131
+
132
+ # Returns [del_html, add_html] with changed tokens wrapped in dw-del / dw-add.
133
+ def word_diff(old_text, new_text)
134
+ changes = ::Diff::LCS.sdiff(tokenize(old_text), tokenize(new_text))
135
+ del = +""
136
+ add = +""
137
+ changes.each do |change|
138
+ case change.action
139
+ when "=" # unchanged on both sides
140
+ del << esc(change.old_element)
141
+ add << esc(change.new_element)
142
+ when "-" # only in the old line
143
+ del << %(<span class="dw-del">#{esc(change.old_element)}</span>)
144
+ when "+" # only in the new line
145
+ add << %(<span class="dw-add">#{esc(change.new_element)}</span>)
146
+ when "!" # replaced
147
+ del << %(<span class="dw-del">#{esc(change.old_element)}</span>)
148
+ add << %(<span class="dw-add">#{esc(change.new_element)}</span>)
149
+ end
150
+ end
151
+ [del, add]
152
+ end
153
+
154
+ def render(rows, lexer)
155
+ out = +%(<table class="diff highlight">)
156
+ rows.each do |row|
157
+ out << render_row(row, lexer)
158
+ end
159
+ out << "</table>"
160
+ out
161
+ end
162
+
163
+ def render_row(row, lexer)
164
+ return %(<tr class="diff-hunk"><td colspan="3">#{esc(row.text)}</td></tr>) if row.kind == :hunk
165
+
166
+ sign =
167
+ case row.kind
168
+ when :add then "+"
169
+ when :del then "-"
170
+ else " "
171
+ end
172
+ code = row.html || highlight(lexer, row.text)
173
+ %(<tr class="diff-row diff-#{row.kind}">) <<
174
+ gutter(row.old_ln) <<
175
+ gutter(row.new_ln) <<
176
+ %(<td class="diff-code"><span class="diff-sign">#{sign}</span>#{code}</td></tr>)
177
+ end
178
+
179
+ def gutter(num)
180
+ %(<td class="diff-gutter">#{num}</td>)
181
+ end
182
+
183
+ def highlight(lexer, content)
184
+ return "" if content.empty?
185
+
186
+ FORMATTER.format(lexer.lex(content)).chomp
187
+ end
188
+
189
+ def lexer_for(file)
190
+ lexer =
191
+ begin
192
+ Rouge::Lexer.guess_by_filename(file.to_s)
193
+ rescue Rouge::Guesser::Ambiguous => e
194
+ e.alternatives.first
195
+ rescue StandardError
196
+ nil
197
+ end
198
+ (lexer || Rouge::Lexers::PlainText).new
199
+ end
200
+
201
+ def esc(str)
202
+ CGI.escapeHTML(str.to_s)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HunkReviewChanges
4
+ # Builds export.md: the pieces that need action, as a paste-ready markdown block the
5
+ # launching agent reads to implement each requested change.
6
+ class Export
7
+ def initialize(bundle, state)
8
+ @bundle = bundle
9
+ @state = state
10
+ end
11
+
12
+ def to_markdown
13
+ lines = header
14
+ actionable = @bundle.pieces.select { |piece| State.actionable?(@state[piece["id"]]) }
15
+
16
+ if actionable.empty?
17
+ lines << no_changes_note
18
+ return "#{lines.join("\n").strip}\n"
19
+ end
20
+
21
+ lines << "The pieces below have comments to act on. Implement each `change`; " \
22
+ "list each `flag` for the user to decide before touching it."
23
+ lines << ""
24
+ actionable.each { |piece| lines.concat(piece_lines(piece)) }
25
+ "#{lines.join("\n").strip}\n"
26
+ end
27
+
28
+ private
29
+
30
+ # No piece needs action, but "reviewed and left as-is" and "never looked at" are
31
+ # different signals to the agent — the UI lets the user finish with pieces still
32
+ # unreviewed, and those must not read as approval.
33
+ def no_changes_note
34
+ unreviewed = @bundle.pieces.select { |piece| State.status_for(@state[piece["id"]]) == "unreviewed" }
35
+ return "_No changes requested — every piece was reviewed and left as-is._" if unreviewed.empty?
36
+
37
+ ids = unreviewed.map { |piece| piece["id"] }.join(", ")
38
+ "_No changes requested, but #{unreviewed.size} of #{@bundle.pieces.size} piece(s) were left " \
39
+ "unreviewed (skipped, not approved): #{ids}. Treat them as pending, not accepted._"
40
+ end
41
+
42
+ def header
43
+ lines = ["# Hunk review — #{@bundle.target}"]
44
+ lines << "Resolved by: #{@bundle.resolved_by}" if @bundle.resolved_by
45
+ lines << ""
46
+ lines << @bundle.framing.to_s.strip
47
+ lines << ""
48
+ lines
49
+ end
50
+
51
+ def piece_lines(piece)
52
+ entry = @state[piece["id"]]
53
+ status = State.status_for(entry)
54
+ comment = entry["comment"].to_s.strip
55
+ lines = [
56
+ "## Piece #{piece["id"]} of #{@bundle.pieces.size} — #{piece["file"]}: #{piece["label"]}",
57
+ "Status: #{status}",
58
+ "",
59
+ "```diff",
60
+ piece["diff"].to_s.rstrip,
61
+ "```",
62
+ "Comment: #{comment.empty? ? "—" : comment}"
63
+ ]
64
+ lines.concat(challenge_lines(piece["challenge"]))
65
+ lines << ""
66
+ lines
67
+ end
68
+
69
+ # The adversary's case travels with the comment so the implementing agent weighs
70
+ # both, rather than acting on the request without ever seeing the argument against
71
+ # it. A concession still earns its line: knowing the adversary looked and passed is
72
+ # different from it never having run.
73
+ def challenge_lines(challenge)
74
+ return [] unless challenge.is_a?(Hash)
75
+
76
+ stance = challenge["stance"].to_s
77
+ argument = challenge["argument"].to_s.strip
78
+ return ["Adversary (#{stance}): #{argument}"] unless stance == "concede"
79
+
80
+ note = argument.empty? ? nil : " (#{argument})"
81
+ ["Adversary: conceded — no objection#{note}"]
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HunkReviewChanges
4
+ module Installer
5
+ # Common interface for a per-agent install adapter. An adapter knows how to detect
6
+ # its agent and how to register the skill for it — either by calling the agent's
7
+ # own CLI (Claude Code) or by copying the skill out of the marketplace checkout
8
+ # into the directory that agent scans (Codex, Cursor, OpenCode).
9
+ class Base
10
+ # Outcome of one adapter run, rendered in the install summary.
11
+ Result = Struct.new(:key, :label, :ok, :message, keyword_init: true)
12
+
13
+ def key = raise NotImplementedError
14
+ def label = raise NotImplementedError
15
+
16
+ # True when the agent is present on this machine (CLI on PATH or config dir).
17
+ def detected? = raise NotImplementedError
18
+
19
+ # Perform the install against a SkillSource; return a Result.
20
+ def install!(_source) = raise NotImplementedError
21
+
22
+ protected
23
+
24
+ def ok(message) = Result.new(key: key, label: label, ok: true, message: message)
25
+ def failure(message) = Result.new(key: key, label: label, ok: false, message: message)
26
+
27
+ def command_on_path?(name)
28
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
29
+ path = File.join(dir, name)
30
+ File.executable?(path) && !File.directory?(path)
31
+ end
32
+ end
33
+
34
+ def home(*parts)
35
+ File.join(Dir.home, *parts)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require_relative "base"
6
+
7
+ module HunkReviewChanges
8
+ module Installer
9
+ # Claude Code is the one agent with a real, non-interactive plugin install CLI, so
10
+ # this adapter uses it: register the marketplace repo, then install the plugin from
11
+ # it. Skills inside the plugin are auto-discovered once it is installed.
12
+ class ClaudeCode < Base
13
+ def key = :claude
14
+ def label = "Claude Code"
15
+
16
+ def detected?
17
+ command_on_path?("claude") || File.directory?(home(".claude"))
18
+ end
19
+
20
+ def install!(source)
21
+ return failure("`claude` CLI not found on PATH") unless command_on_path?("claude")
22
+
23
+ added, add_out = run("claude", "plugin", "marketplace", "add", source.repo)
24
+ return failure("`claude plugin marketplace add` failed: #{add_out}") unless added
25
+
26
+ installed, install_out = run(
27
+ "claude", "plugin", "install", "#{PLUGIN_NAME}@#{MARKETPLACE_NAME}"
28
+ )
29
+ return failure("`claude plugin install` failed: #{install_out}") unless installed
30
+
31
+ ok("installed plugin #{PLUGIN_NAME}@#{MARKETPLACE_NAME} (user scope)")
32
+ end
33
+
34
+ private
35
+
36
+ def run(*command)
37
+ out, status = Open3.capture2e(*command)
38
+ [status.success?, out.strip]
39
+ rescue StandardError => e
40
+ [false, e.message]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "directory_installer"
4
+
5
+ module HunkReviewChanges
6
+ module Installer
7
+ # Codex reads user-level skills from the shared, tool-agnostic ~/.agents/skills.
8
+ class Codex < DirectoryInstaller
9
+ def key = :codex
10
+ def label = "Codex"
11
+
12
+ def detected?
13
+ command_on_path?("codex") || File.directory?(home(".codex")) || File.directory?(home(".agents"))
14
+ end
15
+
16
+ def skills_root
17
+ home(".agents", "skills")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "directory_installer"
4
+
5
+ module HunkReviewChanges
6
+ module Installer
7
+ # Cursor reads user-level skills from ~/.cursor/skills.
8
+ class Cursor < DirectoryInstaller
9
+ def key = :cursor
10
+ def label = "Cursor"
11
+
12
+ def detected?
13
+ command_on_path?("cursor-agent") || command_on_path?("cursor") ||
14
+ File.directory?(home(".cursor"))
15
+ end
16
+
17
+ def skills_root
18
+ home(".cursor", "skills")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "base"
6
+
7
+ module HunkReviewChanges
8
+ module Installer
9
+ # Base for agents that have no install CLI (Codex, Cursor, OpenCode). They all scan
10
+ # a skills directory for `<name>/SKILL.md`, so installing means copying the skill
11
+ # out of the marketplace checkout into that directory. Subclasses supply the
12
+ # directory and how the agent is detected.
13
+ class DirectoryInstaller < Base
14
+ # Absolute path to the agent's user-level skills directory.
15
+ def skills_root = raise NotImplementedError
16
+
17
+ def install!(source)
18
+ target = File.join(skills_root, SKILL_NAME)
19
+ FileUtils.mkdir_p(target)
20
+ FileUtils.cp_r(File.join(source.skill_dir, "."), target)
21
+ ok("copied skill to #{pretty(target)} (restart #{label} to pick it up)")
22
+ rescue SkillSource::Error => e
23
+ failure(e.message)
24
+ rescue StandardError => e
25
+ failure("could not copy skill: #{e.message}")
26
+ end
27
+
28
+ private
29
+
30
+ def pretty(path)
31
+ home = Dir.home
32
+ path.start_with?(home) ? path.sub(home, "~") : path
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "directory_installer"
4
+
5
+ module HunkReviewChanges
6
+ module Installer
7
+ # OpenCode reads user-level skills from ~/.config/opencode/skills.
8
+ class OpenCode < DirectoryInstaller
9
+ def key = :opencode
10
+ def label = "OpenCode"
11
+
12
+ def detected?
13
+ command_on_path?("opencode") || File.directory?(config_dir)
14
+ end
15
+
16
+ def skills_root
17
+ File.join(config_dir, "skills")
18
+ end
19
+
20
+ private
21
+
22
+ def config_dir
23
+ File.join(ENV.fetch("XDG_CONFIG_HOME", home(".config")), "opencode")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill_source"
4
+ require_relative "claude_code"
5
+ require_relative "codex"
6
+ require_relative "cursor"
7
+ require_relative "opencode"
8
+
9
+ module HunkReviewChanges
10
+ module Installer
11
+ # Drives the `install` command: detect which agents are present, let the user pick
12
+ # (or take an explicit list), install the skill into each, and print a summary.
13
+ class Runner
14
+ ADAPTERS = [ClaudeCode, Codex, Cursor, OpenCode].freeze
15
+
16
+ # Accepts agent keys plus a few friendly aliases from --agent.
17
+ ALIASES = {
18
+ "claude-code" => :claude, "claudecode" => :claude,
19
+ "cursor-agent" => :cursor, "open-code" => :opencode
20
+ }.freeze
21
+
22
+ def initialize(repo: MARKETPLACE_REPO, only: nil, input: $stdin, output: $stdout)
23
+ @source = SkillSource.new(repo)
24
+ @only = only
25
+ @input = input
26
+ @output = output
27
+ @adapters = ADAPTERS.map(&:new)
28
+ end
29
+
30
+ def run
31
+ if @only
32
+ ensure_known!(@only)
33
+ selected = by_keys(@only)
34
+ else
35
+ selected = prompt
36
+ end
37
+ if selected.empty?
38
+ @output.puts "Nothing selected — no changes made."
39
+ return []
40
+ end
41
+
42
+ @output.puts "\nInstalling from #{@source.repo}"
43
+ results = selected.map do |adapter|
44
+ @output.puts " → #{adapter.label}…"
45
+ adapter.install!(@source)
46
+ end
47
+ report(results)
48
+ results
49
+ end
50
+
51
+ private
52
+
53
+ # An explicit --agent list naming an unknown agent must fail loudly: otherwise
54
+ # it selects nothing, "installs" nothing, and still exits 0 as if it worked.
55
+ def ensure_known!(keys)
56
+ known = @adapters.map(&:key)
57
+ unknown = Array(keys).flat_map { |k| expand(k) }.uniq - known
58
+ return if unknown.empty?
59
+
60
+ raise CLI::Error, "unknown agent#{"s" if unknown.size > 1}: #{unknown.join(", ")}. " \
61
+ "Valid agents: #{known.join(", ")} (or 'all')."
62
+ end
63
+
64
+ def by_keys(keys)
65
+ wanted = Array(keys).flat_map { |k| expand(k) }.uniq
66
+ @adapters.select { |adapter| wanted.include?(adapter.key) }
67
+ end
68
+
69
+ def expand(key)
70
+ normalized = key.to_s.strip.downcase
71
+ return @adapters.map(&:key) if %w[all every].include?(normalized)
72
+
73
+ [ALIASES.fetch(normalized, normalized.to_sym)]
74
+ end
75
+
76
+ def prompt
77
+ @output.puts "Install the hunk-review-changes skill for which agents?\n\n"
78
+ @adapters.each_with_index do |adapter, index|
79
+ mark = adapter.detected? ? "detected" : "not detected"
80
+ @output.puts " #{index + 1}. #{adapter.label} (#{mark})"
81
+ end
82
+ detected = @adapters.each_index.select { |i| @adapters[i].detected? }
83
+ default = detected.empty? ? "none" : detected.map { |i| i + 1 }.join(",")
84
+ @output.print "\nEnter numbers (comma-separated), 'all', or Enter for detected [#{default}]: "
85
+
86
+ answer = read_line
87
+ resolve_selection(answer, detected)
88
+ end
89
+
90
+ def resolve_selection(answer, detected)
91
+ answer = answer.to_s.strip.downcase
92
+ return @adapters if answer == "all"
93
+ return detected.map { |i| @adapters[i] } if answer.empty?
94
+
95
+ indexes = answer.split(/[,\s]+/).filter_map do |token|
96
+ num = Integer(token, exception: false)
97
+ num - 1 if num&.between?(1, @adapters.length)
98
+ end
99
+ indexes.uniq.map { |i| @adapters[i] }
100
+ end
101
+
102
+ def read_line
103
+ @input.gets
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
108
+ def report(results)
109
+ @output.puts "\nSummary:"
110
+ results.each do |result|
111
+ icon = result.ok ? "✓" : "✗"
112
+ @output.puts " #{icon} #{result.label}: #{result.message}"
113
+ end
114
+ failures = results.reject(&:ok)
115
+ return if failures.empty?
116
+
117
+ @output.puts "\n#{failures.length} of #{results.length} did not complete. " \
118
+ "See messages above."
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module HunkReviewChanges
7
+ module Installer
8
+ # Provides a local checkout of the marketplace repo so adapters can read the skill
9
+ # from it. The marketplace repo is the single source of truth for the skill across
10
+ # agents; the gem never ships its own copy. A local path is used as-is (handy for
11
+ # testing); a remote URL is shallow-cloned to a temp dir on first use.
12
+ class SkillSource
13
+ class Error < StandardError
14
+ end
15
+
16
+ attr_reader :repo
17
+
18
+ def initialize(repo)
19
+ @repo = repo
20
+ end
21
+
22
+ def local?
23
+ File.directory?(@repo)
24
+ end
25
+
26
+ # Path to a local checkout of the marketplace repo.
27
+ def checkout
28
+ @checkout ||= local? ? File.expand_path(@repo) : clone
29
+ end
30
+
31
+ # Directory holding the skill (SKILL.md and any support files) in the checkout.
32
+ def skill_dir
33
+ dir = File.join(checkout, "plugins", PLUGIN_NAME, "skills", SKILL_NAME)
34
+ unless File.exist?(File.join(dir, "SKILL.md"))
35
+ raise Error, "no SKILL.md at #{dir} — is #{@repo} the marketplace repo?"
36
+ end
37
+
38
+ dir
39
+ end
40
+
41
+ private
42
+
43
+ def clone
44
+ target = Dir.mktmpdir("hunk-review-changes-skill-")
45
+ ok = system("git", "clone", "--depth", "1", @repo, target,
46
+ out: File::NULL, err: File::NULL)
47
+ raise Error, "could not clone #{@repo} (is git installed and the URL reachable?)" unless ok
48
+
49
+ target
50
+ end
51
+ end
52
+ end
53
+ end