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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +27 -0
- data/README.md +126 -0
- data/exe/hunk-review-changes +6 -0
- data/lib/hunk_review_changes/app.rb +138 -0
- data/lib/hunk_review_changes/assets.rb +34 -0
- data/lib/hunk_review_changes/bundle.rb +122 -0
- data/lib/hunk_review_changes/cli.rb +106 -0
- data/lib/hunk_review_changes/diff.rb +205 -0
- data/lib/hunk_review_changes/export.rb +84 -0
- data/lib/hunk_review_changes/installer/base.rb +39 -0
- data/lib/hunk_review_changes/installer/claude_code.rb +44 -0
- data/lib/hunk_review_changes/installer/codex.rb +21 -0
- data/lib/hunk_review_changes/installer/cursor.rb +22 -0
- data/lib/hunk_review_changes/installer/directory_installer.rb +36 -0
- data/lib/hunk_review_changes/installer/opencode.rb +27 -0
- data/lib/hunk_review_changes/installer/runner.rb +122 -0
- data/lib/hunk_review_changes/installer/skill_source.rb +53 -0
- data/lib/hunk_review_changes/lifecycle.rb +42 -0
- data/lib/hunk_review_changes/markdown.rb +69 -0
- data/lib/hunk_review_changes/public/app.css +237 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleMono.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext-Italic.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/OFL.txt +98 -0
- data/lib/hunk_review_changes/server.rb +99 -0
- data/lib/hunk_review_changes/state.rb +99 -0
- data/lib/hunk_review_changes/version.rb +5 -0
- data/lib/hunk_review_changes/views/index.erb +395 -0
- data/lib/hunk_review_changes.rb +35 -0
- 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
|