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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f6091eebcab16183733cb278161b9d3087150f938521ab7b281e7418cf2a96bc
4
+ data.tar.gz: 707a53f52a4cb6b0aa347d2526a3fc87ea525eedd61a54ce67b533e50078530e
5
+ SHA512:
6
+ metadata.gz: 125e911c129ac684d690d6a44e1f9de6bd0c9355cbb8a188cef9e93204cc1d66ecad2cc1dbd13cdb449442dd0baeed3a21cc74748846f92f8334c0de563144b2
7
+ data.tar.gz: 139a39da05e7972c9c32f63cbd6cc50bf65b4746e57ce6ded335b113a068dac557e188d8f7316b045da841f5a85425eeb4fda2ccddd9d52b34d70c76e9278361
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-08-03)
4
+
5
+ - Battle review: a bundle piece may now carry a `challenge` — an adversary's argument
6
+ against the change — and the bundle a top-level `adversary` naming who made it. The
7
+ viewer renders the challenge as its own voice, distinct from the author's what/why,
8
+ with a stance badge (`nitpick`, `concern`, `blocking`); a `concede` stance renders as
9
+ a quiet "no objection" line so you know the adversary looked.
10
+ - `export.md` carries the challenge for every piece you comment on or flag, so the agent
11
+ implementing your comment also sees the argument against it.
12
+ - Both fields are optional. A bundle written without them renders and exports exactly as
13
+ before.
14
+
15
+ ## 0.1.0 (2026-07-20)
16
+
17
+ - First release as a gem. Packages the browser review UI, all Ruby code, prebuilt CSS,
18
+ and the bundled Atkinson Hyperlegible fonts.
19
+ - `hunk-review-changes <bundle.json>` serves the review UI; `--port` and `--no-open`
20
+ flags supported.
21
+ - `hunk-review-changes install` installs the companion skill into Claude Code (via its
22
+ plugin marketplace CLI) and Codex, Cursor, and OpenCode (by copying the skill into
23
+ the directory each scans).
24
+ - UI overhaul: Atkinson Hyperlegible Next / Mono fonts, wider layout with 120-column
25
+ diffs, dark mode that follows the OS setting, word-level diff highlighting, an
26
+ explicit per-hunk "Looks good" reviewed state, and `j`/`k`/`c`/`g`/`?` keyboard
27
+ navigation.
28
+ - Bundles are validated on launch with actionable error messages.
29
+ - Fix comment loss on exit: a comment typed within the 500ms autosave window is now
30
+ flushed before Done writes the export, and persisted with `navigator.sendBeacon`
31
+ when the tab is closed.
32
+ - Open the browser on Linux and Windows too (`xdg-open` / `start`), not just macOS,
33
+ and warn with the review URL instead of failing silently when no browser launches.
data/LICENSE.txt ADDED
@@ -0,0 +1,27 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Lucian Ghinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
23
+ ---
24
+
25
+ The bundled fonts in lib/hunk_review_changes/public/fonts (Atkinson Hyperlegible
26
+ Next and Atkinson Hyperlegible Mono) are licensed under the SIL Open Font License,
27
+ Version 1.1. See lib/hunk_review_changes/public/fonts/OFL.txt for the full text.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # hunk_review_changes
2
+
3
+ Review a diff hunk-by-hunk in your browser, then hand the comments back to your AI coding agent.
4
+
5
+ Your agent groups a diff into pieces — each a hunk with a plain-language what/why — and launches a local web app. You read the highlighted hunks and comment on each at your own pace. No model tokens are spent while you review. Click **Done** and the agent picks up your comments and implements them.
6
+
7
+ Works with Claude Code, Codex, Cursor, and OpenCode.
8
+
9
+ [![CI](https://github.com/lucianghinda/hunk-review-changes/actions/workflows/ci.yml/badge.svg)](https://github.com/lucianghinda/hunk-review-changes/actions/workflows/ci.yml)
10
+
11
+ ## Installation
12
+
13
+ Install the gem:
14
+
15
+ ```sh
16
+ gem install hunk_review_changes
17
+ ```
18
+
19
+ Then install the companion skill into your agents:
20
+
21
+ ```sh
22
+ hunk-review-changes install
23
+ ```
24
+
25
+ It asks which agents to set up and installs the skill for each.
26
+
27
+ ## How it works
28
+
29
+ 1. You ask your agent to review a change — a branch, PR, commit, or your working tree.
30
+ 2. The skill resolves the target, groups it into hunks, and writes a `bundle.json`.
31
+ 3. It runs `hunk-review-changes bundle.json`, which opens the review UI in your browser.
32
+ 4. For each hunk you leave a comment, mark it **Looks good**, or **Flag for discussion**.
33
+ 5. You click **Done**. The app writes `export.md` and exits.
34
+ 6. Your agent reads the export and implements every requested change.
35
+
36
+ ## Usage
37
+
38
+ Serve a bundle:
39
+
40
+ ```sh
41
+ hunk-review-changes bundle.json
42
+ ```
43
+
44
+ Options:
45
+
46
+ ```sh
47
+ hunk-review-changes bundle.json --port 4321 # bind a specific port
48
+ hunk-review-changes bundle.json --no-open # do not open the browser
49
+ ```
50
+
51
+ Install the skill for specific agents without the prompt:
52
+
53
+ ```sh
54
+ hunk-review-changes install --agent claude,codex,cursor,opencode
55
+ ```
56
+
57
+ ## The review UI
58
+
59
+ - **120-column diffs** with syntax highlighting, in [Atkinson Hyperlegible](https://www.brailleinstitute.org/freefont/) fonts.
60
+ - **Word-level highlighting** shows exactly what changed within a modified line.
61
+ - **Dark mode** follows your operating system setting.
62
+ - **Keyboard navigation** — `j`/`k` to move between hunks, `c` to comment, `g` to mark Looks good, `?` for help.
63
+ - **Reviewed state** — every hunk tracks whether you have seen it, so you know what is left.
64
+
65
+ Your review persists as you go, so closing the tab loses nothing. Re-running the same bundle resumes it.
66
+
67
+ ## Battle review
68
+
69
+ Every explanation on a hunk is written by the agent that wrote the change, so it argues
70
+ its own case. Ask for a **battle review** and a second agent argues the other side.
71
+
72
+ Say "battle review this branch" (or "challenge these changes") instead of "review this
73
+ branch". The skill runs one pass of Codex over the whole diff, asking it to make the
74
+ strongest case for why each hunk should not be changed at all — or not this way. Each
75
+ piece then shows two voices: the author's what/why, and the adversary's pushback marked
76
+ `nitpick`, `concern`, or `blocking`. Where the adversary has nothing to say, it concedes,
77
+ and the hunk says so in one line — so you always know it looked.
78
+
79
+ The pushback travels into `export.md` for the pieces you comment on, so the agent
80
+ implementing your comment sees the argument against it too.
81
+
82
+ Battle review is opt-in and needs the [Codex CLI](https://github.com/openai/codex)
83
+ installed. If Codex is missing or fails, you get the plain review and an explanation —
84
+ it never blocks. Since the adversary has to be independent of the author, the mode is
85
+ unavailable when Codex is the agent running the review.
86
+
87
+ The gem itself never calls Codex: your agent does the adversarial pass before launching,
88
+ so no model tokens are spent while you read.
89
+
90
+ ## Skills marketplace
91
+
92
+ The skill lives in its own repo, [hunk-review-changes-skills](https://github.com/lucianghinda/hunk-review-changes-skills), a marketplace that serves every supported agent. `hunk-review-changes install` uses it: it calls the Claude Code plugin CLI, and copies the skill into the directory Codex, Cursor, and OpenCode scan.
93
+
94
+ ## Development
95
+
96
+ Get the dependencies:
97
+
98
+ ```sh
99
+ bundle install
100
+ ```
101
+
102
+ Run the tests and linter:
103
+
104
+ ```sh
105
+ bundle exec rake
106
+ ```
107
+
108
+ Rebuild the stylesheet after changing `assets/tailwind.css` or the fonts:
109
+
110
+ ```sh
111
+ bundle exec rake css
112
+ ```
113
+
114
+ Release a new version with:
115
+
116
+ ```sh
117
+ bundle exec rake release
118
+ ```
119
+
120
+ ## Contributing
121
+
122
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/lucianghinda/hunk-review-changes).
123
+
124
+ ## License
125
+
126
+ The gem is available as open source under the [MIT License](LICENSE.txt). The bundled Atkinson Hyperlegible fonts are licensed under the [SIL Open Font License](lib/hunk_review_changes/public/fonts/OFL.txt).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/hunk_review_changes"
5
+
6
+ exit HunkReviewChanges::CLI.start(ARGV)
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "ipaddr"
5
+ require "json"
6
+ require "cgi"
7
+
8
+ require_relative "markdown"
9
+ require_relative "diff"
10
+ require_relative "state"
11
+ require_relative "export"
12
+ require_relative "lifecycle"
13
+
14
+ module HunkReviewChanges
15
+ # The Sinatra app that serves the review UI and collects per-piece comments. It is
16
+ # configured per run through Sinatra settings so tests can drive it with rack-test
17
+ # against a fixture bundle and a shutdown stub, instead of a real port and exit!.
18
+ class App < Sinatra::Base
19
+ set :views, File.expand_path("views", __dir__)
20
+ set :public_folder, File.expand_path("public", __dir__)
21
+ set :bind, "127.0.0.1"
22
+ set :logging, false
23
+ enable :static
24
+
25
+ # The server only ever binds to loopback, so restrict host authorization to
26
+ # loopback hosts (guards against DNS-rebinding without depending on the Sinatra
27
+ # environment, whose default only applies the check in development).
28
+ set :host_authorization, {
29
+ permitted_hosts: ["localhost", ".localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")]
30
+ }
31
+
32
+ # Configured by the server (or a test) before the app handles a request.
33
+ set :bundle, nil
34
+ set :state_path, nil
35
+ set :export_path, nil
36
+ set :lifecycle, nil
37
+
38
+ class << self
39
+ # The shutdown callback is stored outside Sinatra settings on purpose: a setting
40
+ # whose value responds to #call is auto-invoked when read, which would fire the
41
+ # shutdown the moment a route touched it. A plain accessor holds the callable.
42
+ attr_accessor :on_done
43
+ end
44
+
45
+ helpers do
46
+ def bundle = settings.bundle
47
+ def state = State.new(settings.state_path, bundle_id: bundle.fingerprint)
48
+ # Full HTML-entity escaping (incl. " and ') so values stay safe inside
49
+ # double-quoted attributes like title="..." and data-nav="...", not only
50
+ # in text nodes.
51
+ def esc(str) = CGI.escapeHTML(str.to_s)
52
+
53
+ # The adversary's rebuttal, ready for the view, or nil when the piece was never
54
+ # challenged. Its argument goes through the same Markdown pipeline as what/why,
55
+ # so it gets the same URL sanitizing — the adversary's prose is bundle input like
56
+ # any other, not a trusted source.
57
+ def challenge_view(challenge)
58
+ return nil unless challenge.is_a?(Hash)
59
+
60
+ { stance: challenge["stance"].to_s, argument: Markdown.inline(challenge["argument"]) }
61
+ end
62
+ end
63
+
64
+ get "/" do
65
+ review_state = state
66
+ total = bundle.pieces.size
67
+ @target = bundle.target
68
+ @resolved_by = bundle.resolved_by
69
+ @framing = Markdown.inline(bundle.framing)
70
+ @adversary = bundle.adversary
71
+ @pieces = bundle.pieces.map do |piece|
72
+ entry = review_state[piece["id"]]
73
+ {
74
+ id: piece["id"],
75
+ n: "#{piece["id"]} of #{total}",
76
+ file: piece["file"],
77
+ label: piece["label"],
78
+ kind: piece["kind"] || "code",
79
+ what: Markdown.inline(piece["what"]),
80
+ why: Markdown.inline(piece["why"]),
81
+ flags: Array(piece["flags"]).map { |flag| Markdown.inline(flag) },
82
+ comment: entry["comment"].to_s,
83
+ flagged: !!entry["flag"],
84
+ reviewed: !!entry["reviewed"],
85
+ status: State.status_for(entry),
86
+ challenge: challenge_view(piece["challenge"]),
87
+ diff: Diff.to_html(piece["diff"], piece["file"])
88
+ }
89
+ end
90
+ # Provenance only earns its line when a rebuttal actually survived to the page:
91
+ # naming the adversary above a page with no challenges would overstate what ran.
92
+ @adversary = nil unless @pieces.any? { |piece| piece[:challenge] }
93
+ erb :index
94
+ end
95
+
96
+ post "/pieces/:id" do
97
+ content_type :json
98
+ body = parse_body
99
+ status = state.update(
100
+ params["id"],
101
+ comment: body["comment"],
102
+ flag: body["flag"],
103
+ reviewed: body["reviewed"]
104
+ )
105
+ { ok: true, status: status }.to_json
106
+ end
107
+
108
+ get "/export.md" do
109
+ content_type "text/plain"
110
+ Export.new(bundle, state).to_markdown
111
+ end
112
+
113
+ post "/done" do
114
+ content_type :json
115
+ File.write(settings.export_path, Export.new(bundle, state).to_markdown)
116
+ self.class.on_done&.call
117
+ { ok: true, export: settings.export_path }.to_json
118
+ end
119
+
120
+ post "/heartbeat" do
121
+ settings.lifecycle&.heartbeat!
122
+ 204
123
+ end
124
+
125
+ post "/bye" do
126
+ settings.lifecycle&.bye!
127
+ 204
128
+ end
129
+
130
+ private
131
+
132
+ def parse_body
133
+ JSON.parse(request.body.read)
134
+ rescue StandardError
135
+ {}
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module HunkReviewChanges
6
+ # Generates the syntax-highlighting CSS shipped in app.css. Kept out of the request
7
+ # path: `rake css` renders it once at build time so the running server only reads a
8
+ # static file. Two themes are emitted under the same `.highlight` scope; the dark one
9
+ # is wrapped in a prefers-color-scheme media query so the page follows the OS setting
10
+ # with no toggle and no JavaScript.
11
+ module Assets
12
+ LIGHT_THEME = "github"
13
+ DARK_THEME = "github.dark"
14
+
15
+ module_function
16
+
17
+ def rouge_css
18
+ light = Rouge::Theme.find(LIGHT_THEME).new(scope: ".highlight").render
19
+ dark = Rouge::Theme.find(DARK_THEME).new(scope: ".highlight").render
20
+ <<~CSS
21
+ /* Syntax highlighting — generated by `rake css` from Rouge themes. */
22
+ #{light}
23
+ @media (prefers-color-scheme: dark) {
24
+ #{indent(dark)}
25
+ }
26
+ CSS
27
+ end
28
+
29
+ def indent(text, spaces = 2)
30
+ pad = " " * spaces
31
+ text.each_line.map { |line| line.strip.empty? ? line : "#{pad}#{line}" }.join
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module HunkReviewChanges
7
+ # Loads and validates a bundle.json written by the companion skill. Validation
8
+ # exists to catch the exact failure modes the skill warns about (a malformed diff,
9
+ # missing pieces, non-sequential ids) and report them with a message the agent that
10
+ # wrote the bundle can act on, instead of a blank or broken page.
11
+ class Bundle
12
+ class Error < StandardError
13
+ end
14
+
15
+ # The adversarial stances a piece's challenge may take, weakest to strongest.
16
+ # Shared so the validator, the viewer's badge map, and the export all agree on
17
+ # the same four strings instead of each carrying its own copy.
18
+ STANCES = %w[concede nitpick concern blocking].freeze
19
+
20
+ attr_reader :path, :data
21
+
22
+ def self.load(path)
23
+ new(path).tap(&:validate!)
24
+ end
25
+
26
+ def initialize(path)
27
+ @path = path
28
+ raise Error, "no such bundle: #{path}" unless File.exist?(path)
29
+
30
+ @data = JSON.parse(File.read(path))
31
+ rescue JSON::ParserError => e
32
+ raise Error, "bundle is not valid JSON (#{e.message}). " \
33
+ "Write it with real JSON — no trailing commas or comments."
34
+ end
35
+
36
+ def target = data["target"]
37
+ def resolved_by = data["resolved_by"]
38
+ def framing = data["framing"]
39
+ def pieces = data["pieces"]
40
+ def adversary = data["adversary"]
41
+ def dir = File.dirname(File.expand_path(path))
42
+
43
+ # Stable identity for this bundle's content, used to scope persisted review
44
+ # state so a reused directory never replays another bundle's comments.
45
+ def fingerprint = Digest::SHA256.hexdigest(JSON.generate(data))
46
+
47
+ def validate!
48
+ raise Error, "bundle must be a JSON object with a \"pieces\" array" unless data.is_a?(Hash)
49
+
50
+ list = data["pieces"]
51
+ raise Error, "bundle has no \"pieces\" array" unless list.is_a?(Array)
52
+ raise Error, "bundle \"pieces\" array is empty — nothing to review" if list.empty?
53
+
54
+ list.each_with_index { |piece, index| validate_piece!(piece, index) }
55
+ self
56
+ end
57
+
58
+ private
59
+
60
+ def validate_piece!(piece, index)
61
+ position = index + 1
62
+ raise Error, "piece ##{position} must be a JSON object" unless piece.is_a?(Hash)
63
+
64
+ expected_id = index + 1
65
+ unless piece["id"] == expected_id
66
+ raise Error, "piece ##{position} has id #{piece["id"].inspect}; ids must be " \
67
+ "1-based and sequential (expected #{expected_id})"
68
+ end
69
+
70
+ %w[file label].each do |field|
71
+ if piece[field].to_s.strip.empty?
72
+ raise Error, "piece #{expected_id} is missing a non-empty \"#{field}\""
73
+ end
74
+ end
75
+
76
+ validate_diff!(piece, expected_id)
77
+ validate_challenge!(piece, expected_id)
78
+ end
79
+
80
+ # A challenge is optional: a bundle written without an adversarial pass has none,
81
+ # and must stay valid. When one is present it has to be usable by the viewer, so
82
+ # the stance must be a known one (it selects a badge) and a piece that claims an
83
+ # objection must actually state it.
84
+ def validate_challenge!(piece, id)
85
+ challenge = piece["challenge"]
86
+ return if challenge.nil?
87
+
88
+ unless challenge.is_a?(Hash)
89
+ raise Error, "piece #{id} \"challenge\" must be a JSON object with \"stance\" and " \
90
+ "\"argument\" keys"
91
+ end
92
+
93
+ stance = challenge["stance"]
94
+ unless STANCES.include?(stance)
95
+ raise Error, "piece #{id} \"challenge\" has stance #{stance.inspect}; expected one of " \
96
+ "#{STANCES.join(", ")}"
97
+ end
98
+
99
+ return if stance == "concede" || !challenge["argument"].to_s.strip.empty?
100
+
101
+ raise Error, "piece #{id} \"challenge\" has stance \"#{stance}\" but an empty " \
102
+ "\"argument\" — state the objection, or use stance \"concede\""
103
+ end
104
+
105
+ def validate_diff!(piece, id)
106
+ diff = piece["diff"].to_s
107
+ if diff.strip.empty?
108
+ raise Error, "piece #{id} has an empty \"diff\" — copy a real unified-diff hunk from git"
109
+ end
110
+
111
+ unless diff.include?("@@")
112
+ raise Error, "piece #{id} \"diff\" has no @@ hunk header — copy the whole hunk " \
113
+ "including the @@ ... @@ line so line numbers can render"
114
+ end
115
+
116
+ return unless diff.include?('\n') && !diff.include?("\n")
117
+
118
+ raise Error, "piece #{id} \"diff\" looks escaped (literal \\n, no real newlines) — " \
119
+ "the diff must contain actual newlines, not \\n sequences"
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "version"
6
+ require_relative "bundle"
7
+
8
+ module HunkReviewChanges
9
+ # Command-line entry point. Two commands:
10
+ #
11
+ # hunk-review-changes <bundle.json> [--port N] [--no-open] serve the review UI
12
+ # hunk-review-changes install [--agent ...] [--marketplace-repo ...]
13
+ #
14
+ # `serve` is the default, so a bare bundle path just works (that is how the skill
15
+ # launches it).
16
+ class CLI
17
+ class Error < StandardError
18
+ end
19
+
20
+ def self.start(argv)
21
+ new.run(argv)
22
+ rescue Bundle::Error, Error, OptionParser::ParseError => e
23
+ warn "hunk-review-changes: #{e.message}"
24
+ 1
25
+ end
26
+
27
+ def run(argv)
28
+ argv = argv.dup
29
+ case argv.first
30
+ when "install" then install(argv.drop(1))
31
+ when "-v", "--version" then print_version
32
+ when "-h", "--help", nil then print_help
33
+ else serve(argv)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def serve(argv)
40
+ options = { open: open_by_default? }
41
+ parser = OptionParser.new do |o|
42
+ o.banner = "Usage: hunk-review-changes <bundle.json> [--port N] [--no-open]"
43
+ o.on("--port N", Integer, "Port to bind (default: a free port)") { |n| options[:port] = n }
44
+ o.on("--no-open", "Do not open the browser automatically") { options[:open] = false }
45
+ end
46
+ rest = parser.parse(argv)
47
+ path = rest.first
48
+ raise Error, "no bundle given\n#{parser}" unless path
49
+
50
+ require_relative "server"
51
+ bundle = Bundle.load(path)
52
+ Server.new(bundle, port: options[:port], open: options[:open]).run
53
+ 0
54
+ end
55
+
56
+ def install(argv)
57
+ options = { repo: MARKETPLACE_REPO }
58
+ parser = OptionParser.new do |o|
59
+ o.banner = "Usage: hunk-review-changes install [--agent claude,codex,...] " \
60
+ "[--marketplace-repo URL_OR_PATH]"
61
+ o.on("--agent LIST", Array, "Agents to install for (skips the prompt); or 'all'") do |list|
62
+ options[:only] = list
63
+ end
64
+ o.on("--marketplace-repo REPO", "Marketplace repo URL or local path") do |repo|
65
+ options[:repo] = repo
66
+ end
67
+ end
68
+ parser.parse(argv)
69
+
70
+ require_relative "installer/runner"
71
+ results = Installer::Runner.new(repo: options[:repo], only: options[:only]).run
72
+ results.all?(&:ok) ? 0 : 1
73
+ end
74
+
75
+ def open_by_default?
76
+ ENV["HUNK_REVIEW_CHANGES_NO_OPEN"] != "1"
77
+ end
78
+
79
+ def print_version
80
+ puts "hunk-review-changes #{VERSION}"
81
+ 0
82
+ end
83
+
84
+ def print_help
85
+ puts <<~HELP
86
+ hunk-review-changes #{VERSION}
87
+
88
+ Review a diff hunk-by-hunk in your browser, then hand the comments back to
89
+ your AI coding agent.
90
+
91
+ Usage:
92
+ hunk-review-changes <bundle.json> [--port N] [--no-open]
93
+ Serve the review UI for a bundle written by the companion skill.
94
+
95
+ hunk-review-changes install [--agent claude,codex,cursor,opencode]
96
+ [--marketplace-repo URL_OR_PATH]
97
+ Install the companion skill into your AI agents. With no --agent flag it
98
+ asks which agents to install for.
99
+
100
+ hunk-review-changes --version
101
+ hunk-review-changes --help
102
+ HELP
103
+ 0
104
+ end
105
+ end
106
+ end