diffbroker 0.9.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/LICENSE.txt +21 -0
- data/exe/diffbroker +10 -0
- data/lib/diffbroker/agent_projects.rb +35 -0
- data/lib/diffbroker/agents/base.rb +48 -0
- data/lib/diffbroker/agents/claude.rb +100 -0
- data/lib/diffbroker/agents/codex.rb +72 -0
- data/lib/diffbroker/agents.rb +18 -0
- data/lib/diffbroker/api.rb +59 -0
- data/lib/diffbroker/branch_publisher.rb +66 -0
- data/lib/diffbroker/cable_client.rb +213 -0
- data/lib/diffbroker/cli.rb +347 -0
- data/lib/diffbroker/config.rb +126 -0
- data/lib/diffbroker/daemon.rb +277 -0
- data/lib/diffbroker/explain_verifier.rb +55 -0
- data/lib/diffbroker/gh_check.rb +22 -0
- data/lib/diffbroker/git_context.rb +115 -0
- data/lib/diffbroker/hook.rb +50 -0
- data/lib/diffbroker/hooks_installer.rb +115 -0
- data/lib/diffbroker/launcher.rb +62 -0
- data/lib/diffbroker/lifecycle.rb +127 -0
- data/lib/diffbroker/local_relay.rb +100 -0
- data/lib/diffbroker/login.rb +68 -0
- data/lib/diffbroker/maintenance.rb +65 -0
- data/lib/diffbroker/neutralizer.rb +80 -0
- data/lib/diffbroker/process_scanner.rb +104 -0
- data/lib/diffbroker/reaper.rb +111 -0
- data/lib/diffbroker/repo_scanner.rb +88 -0
- data/lib/diffbroker/run_script.rb +62 -0
- data/lib/diffbroker/runner.rb +41 -0
- data/lib/diffbroker/service_installer.rb +179 -0
- data/lib/diffbroker/skill_installer.rb +30 -0
- data/lib/diffbroker/task_files.rb +90 -0
- data/lib/diffbroker/task_runner.rb +286 -0
- data/lib/diffbroker/url_handler_installer.rb +111 -0
- data/lib/diffbroker/version.rb +5 -0
- data/lib/diffbroker/worktree.rb +115 -0
- data/lib/diffbroker.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/diffbroker/SKILL.md +36 -0
- data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
- metadata +99 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "socket"
|
|
8
|
+
|
|
9
|
+
module Diffbroker
|
|
10
|
+
class CLI
|
|
11
|
+
HELP = <<~TEXT
|
|
12
|
+
Usage: diffbroker <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
start [--server URL] [--name NAME] Set this machine up and keep it connected: pairs it (paste the code
|
|
16
|
+
it prints at diff.broker/devices), installs the background service and the
|
|
17
|
+
"Start companion" button, then runs your agent when you ask from a PR card.
|
|
18
|
+
Run it again any time; it only does what is still missing.
|
|
19
|
+
launch Start the companion in the background if it is not running
|
|
20
|
+
stop Stop the companion (and its background service, so it stays stopped)
|
|
21
|
+
restart Restart the companion, e.g. after `gem update diffbroker`
|
|
22
|
+
upgrade Install the newest gem, remove old versions, restart the companion
|
|
23
|
+
uninstall Remove the service, the start link, the Claude Code hooks and the gem (keeps your pairing)
|
|
24
|
+
login [--server URL] [--name NAME] Only pair this machine (`start` does this when needed)
|
|
25
|
+
install-service Only (re)install the background service (launchd / systemd --user) and the
|
|
26
|
+
"Start companion" handler (`start` does this when needed)
|
|
27
|
+
install-skill Install the diffbroker skills into ~/.claude/skills
|
|
28
|
+
status Show pairing, configuration and the repositories found
|
|
29
|
+
clean Remove finished agent worktrees and stop their leftover processes (keeps worktrees with uncommitted work)
|
|
30
|
+
install-hooks [--yes] Show your own Claude Code sessions on your cards (asks first)
|
|
31
|
+
uninstall-hooks Remove those hooks
|
|
32
|
+
help Show this help
|
|
33
|
+
|
|
34
|
+
Config: ~/.config/diffbroker/config.yml (server, roots, scan_depth, terminal, worktree_root, agent,
|
|
35
|
+
claude_args, codex_args). Your agent runs as you, with your own login and credentials.
|
|
36
|
+
Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
|
|
37
|
+
TEXT
|
|
38
|
+
|
|
39
|
+
def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin, maintenance: nil, lifecycle: nil)
|
|
40
|
+
@argv = argv.dup
|
|
41
|
+
@maintenance = maintenance
|
|
42
|
+
@lifecycle = lifecycle
|
|
43
|
+
@out = out
|
|
44
|
+
@err = err
|
|
45
|
+
@env = env
|
|
46
|
+
@home = home
|
|
47
|
+
@input = input
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
command = @argv.shift || "help"
|
|
52
|
+
return task_ended if command == "task-ended"
|
|
53
|
+
return publish_branch if command == "publish-branch"
|
|
54
|
+
return hook if command == "hook"
|
|
55
|
+
|
|
56
|
+
case command
|
|
57
|
+
when "login" then login
|
|
58
|
+
when "start" then start
|
|
59
|
+
when "launch" then launch
|
|
60
|
+
when "stop" then stop
|
|
61
|
+
when "restart" then restart
|
|
62
|
+
when "upgrade" then upgrade
|
|
63
|
+
when "uninstall" then uninstall
|
|
64
|
+
when "install-service" then install_service
|
|
65
|
+
when "install-skill" then install_skill
|
|
66
|
+
when "status" then status
|
|
67
|
+
when "clean" then clean
|
|
68
|
+
when "install-hooks" then install_hooks
|
|
69
|
+
when "uninstall-hooks" then uninstall_hooks
|
|
70
|
+
when "help", "-h", "--help" then @out.puts(HELP)
|
|
71
|
+
else
|
|
72
|
+
@err.puts "Unknown command: #{command}"
|
|
73
|
+
@err.puts HELP
|
|
74
|
+
return 1
|
|
75
|
+
end
|
|
76
|
+
0
|
|
77
|
+
rescue Error, OptionParser::ParseError => e
|
|
78
|
+
@err.puts "diffbroker: #{e.message}"
|
|
79
|
+
1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def config = @config ||= Config.load(dir: Config.default_dir(@env), env: @env, home: @home)
|
|
85
|
+
|
|
86
|
+
def login
|
|
87
|
+
options = {}
|
|
88
|
+
OptionParser.new do |parser|
|
|
89
|
+
parser.on("--server URL") { options[:server] = _1 }
|
|
90
|
+
parser.on("--name NAME") { options[:name] = _1 }
|
|
91
|
+
end.parse!(@argv)
|
|
92
|
+
@config = config.save!("server" => options[:server]) if options[:server]
|
|
93
|
+
Login.new(config:, out: @out, name: options[:name] || Socket.gethostname).run
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# In a terminal: whatever is still missing (pairing, the background service) is set up, then the companion is left
|
|
97
|
+
# running in the background. Without a terminal (the service, `diffbroker launch`) or with --foreground: the daemon
|
|
98
|
+
# itself, in this process.
|
|
99
|
+
def start
|
|
100
|
+
options = {}
|
|
101
|
+
OptionParser.new do |parser|
|
|
102
|
+
parser.on("--server URL") { options[:server] = _1 }
|
|
103
|
+
parser.on("--name NAME") { options[:name] = _1 }
|
|
104
|
+
parser.on("--foreground") { options[:foreground] = true }
|
|
105
|
+
end.parse!(@argv)
|
|
106
|
+
foreground = options[:foreground] || !@input.tty?
|
|
107
|
+
if options[:server]
|
|
108
|
+
# Setting a machine up remembers its server (the background service must use it too). A daemon run by hand only
|
|
109
|
+
# uses it for this run: it must not rewrite the settings the service reads.
|
|
110
|
+
@config = foreground ? Config.load(dir: Config.default_dir(@env), env: @env.merge("DIFFBROKER_SERVER" => options[:server]), home: @home) : config.save!("server" => options[:server])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return run_daemon if foreground
|
|
114
|
+
return set_up_separate_instance(options[:name] || Socket.gethostname) if separate_instance?
|
|
115
|
+
|
|
116
|
+
set_up(options[:name] || Socket.gethostname)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A DIFFBROKER_CONFIG_DIR that is not the default is another companion (say, one for a local dev server). The background
|
|
120
|
+
# service belongs to the default one, so this one is paired and then kept in this window; it never touches the service.
|
|
121
|
+
def separate_instance?
|
|
122
|
+
custom = @env["DIFFBROKER_CONFIG_DIR"]
|
|
123
|
+
custom && File.expand_path(custom) != File.expand_path(Config.default_dir(@env.reject { |key, _| key == "DIFFBROKER_CONFIG_DIR" }))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def set_up_separate_instance(name)
|
|
127
|
+
Login.new(config:, out: @out, name:).run unless config.paired?
|
|
128
|
+
@out.puts "This companion uses its own settings (#{config.dir}), so the background service is left alone. Keeping it in this " \
|
|
129
|
+
"window, connected to #{config.server}; press Ctrl-C to stop it."
|
|
130
|
+
run_daemon
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def run_daemon
|
|
134
|
+
raise Error, "This machine is not paired. Run `diffbroker start` in a terminal to pair it." unless config.paired?
|
|
135
|
+
|
|
136
|
+
@out.puts "Diff Broker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
|
|
137
|
+
Daemon.build(config, out: @out).run
|
|
138
|
+
rescue Daemon::Rejected
|
|
139
|
+
# The pairing is dead. Forget it and remove the service, so nothing restarts a daemon that can never connect.
|
|
140
|
+
config.clear_credentials!
|
|
141
|
+
ServiceInstaller.new(home: @home, env: @env).uninstall!
|
|
142
|
+
raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set_up(name)
|
|
146
|
+
forget_dead_pairing if config.paired? && pairing_rejected?
|
|
147
|
+
Login.new(config:, out: @out, name:).run unless config.paired?
|
|
148
|
+
install_background_service
|
|
149
|
+
@out.puts "Diff Broker is running in the background. Open #{config.server} to see it connected."
|
|
150
|
+
rescue Error => e
|
|
151
|
+
raise unless service_unavailable?(e)
|
|
152
|
+
|
|
153
|
+
@out.puts "No background service is available here (#{e.message}). Keeping the companion in this window; run " \
|
|
154
|
+
"`diffbroker start` again after a restart. Press Ctrl-C to stop it."
|
|
155
|
+
run_daemon
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# True only when the server answered "unauthorized". An unreachable server proves nothing, so the pairing is kept.
|
|
159
|
+
def pairing_rejected?
|
|
160
|
+
Api.new(server: config.server, token: config.device_token).get("/device/ping")
|
|
161
|
+
false
|
|
162
|
+
rescue Api::HttpError => e
|
|
163
|
+
e.status == 401
|
|
164
|
+
rescue SystemCallError, IOError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def forget_dead_pairing
|
|
169
|
+
@out.puts "Diff Broker no longer knows this machine, so it needs pairing again."
|
|
170
|
+
config.clear_credentials!
|
|
171
|
+
ServiceInstaller.new(home: @home, env: @env).uninstall!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Installs what is missing and starts it. A service that is already installed is only started (installing again
|
|
175
|
+
# would restart a daemon that may be running a task).
|
|
176
|
+
def install_background_service
|
|
177
|
+
service = ServiceInstaller.new(home: @home, env: @env)
|
|
178
|
+
return lifecycle.launch if service.installed?
|
|
179
|
+
|
|
180
|
+
install_service
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def service_unavailable?(error) = error.message.include?("not found on PATH") || error.message.include?("systemctl")
|
|
184
|
+
|
|
185
|
+
def install_service
|
|
186
|
+
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
187
|
+
|
|
188
|
+
path = ServiceInstaller.new(home: @home, env: @env).install!
|
|
189
|
+
@out.puts "Installed and started #{path}."
|
|
190
|
+
if ServiceInstaller.pinned?
|
|
191
|
+
@out.puts "No RubyGems `diffbroker` wrapper was found, so it runs this version-pinned gem executable: run " \
|
|
192
|
+
"`diffbroker install-service` again after upgrading the gem."
|
|
193
|
+
end
|
|
194
|
+
refresh_hooks
|
|
195
|
+
handler = UrlHandlerInstaller.new(home: @home).install!
|
|
196
|
+
@out.puts "Registered #{handler} so the website's \"Start companion\" button can wake it."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The target of the website's diffbroker://start link and of `diffbroker launch` by hand: start the background
|
|
200
|
+
# service, or the daemon itself when none is installed. Safe to run when it is already running.
|
|
201
|
+
def launch
|
|
202
|
+
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
203
|
+
lifecycle.launch
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def lifecycle = @lifecycle ||= Lifecycle.new(dir: config.dir, home: @home, service: ServiceInstaller.new(home: @home, env: @env))
|
|
207
|
+
|
|
208
|
+
# Works on this machine only. With a background service installed it is stopped through launchd / systemd, so it
|
|
209
|
+
# stays stopped until `diffbroker launch` (or the website's "Start companion" button).
|
|
210
|
+
def stop
|
|
211
|
+
@out.puts(lifecycle.stop ? "Stopped the companion." : "The companion is not running.")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def restart
|
|
215
|
+
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
216
|
+
|
|
217
|
+
lifecycle.restart
|
|
218
|
+
@out.puts "Restarting the companion."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def maintenance = @maintenance ||= Maintenance.new(hooks: hooks_installer)
|
|
222
|
+
|
|
223
|
+
# The restart runs this (old) process's code, which only asks the service manager or spawns RubyGems' wrapper, so
|
|
224
|
+
# the companion comes back on the new version. An unpaired machine has no companion to restart.
|
|
225
|
+
def upgrade
|
|
226
|
+
maintenance.upgrade!
|
|
227
|
+
return @out.puts("Upgraded. Run `diffbroker start` to pair this machine.") unless config.paired?
|
|
228
|
+
|
|
229
|
+
lifecycle.restart
|
|
230
|
+
@out.puts "Upgraded. Restarting the companion."
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def uninstall
|
|
234
|
+
lifecycle.stop # a companion started by hand too, not only the service
|
|
235
|
+
error = nil
|
|
236
|
+
removed = maintenance.uninstall!(service: ServiceInstaller.new(home: @home, env: @env), url_handler: UrlHandlerInstaller.new(home: @home)) { error = _1 }
|
|
237
|
+
raise Error, "Removed the service, the start link and the hooks, but `gem uninstall diffbroker` failed: #{error}" unless removed
|
|
238
|
+
|
|
239
|
+
@out.puts "Uninstalled the service, the start link, the Claude Code hooks and the gem. Your pairing in #{config.dir} is kept."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def install_skill
|
|
243
|
+
SkillInstaller.new(home: @home, env: @env).install!.each { @out.puts "Installed the #{File.basename(_1)} skill to #{_1}." }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def status
|
|
247
|
+
@out.puts "Diff Broker companion #{VERSION}"
|
|
248
|
+
@out.puts "Server: #{config.server}"
|
|
249
|
+
@out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `diffbroker start`)"}"
|
|
250
|
+
@out.puts "Agent: #{config.agent}"
|
|
251
|
+
@out.puts "Terminal: #{config.terminal}"
|
|
252
|
+
@out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (diffbroker install-hooks)"}"
|
|
253
|
+
@out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
|
|
254
|
+
@out.puts "Worktrees: #{config.worktree_root}"
|
|
255
|
+
@out.puts "Task worktrees: #{Worktree.new(root: config.worktree_root).list.size} (running or kept; `diffbroker clean` removes finished ones)"
|
|
256
|
+
repositories = config.repo_scanner.scan
|
|
257
|
+
@out.puts "Repositories found: #{repositories.size}"
|
|
258
|
+
repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
|
|
262
|
+
def clean
|
|
263
|
+
worktree = Worktree.new(root: config.worktree_root)
|
|
264
|
+
stopped = Reaper.new(root: config.worktree_root).sweep!
|
|
265
|
+
@out.puts "Stopped #{stopped} leftover process#{"es" unless stopped == 1} (rubocop --server and the like)." if stopped.positive?
|
|
266
|
+
worktree.list.each do |dir|
|
|
267
|
+
git_dir = GitContext.git_dir(dir)
|
|
268
|
+
repo_path = git_dir && File.dirname(GitContext.common_dir(git_dir))
|
|
269
|
+
removed = repo_path && File.directory?(repo_path) && worktree.remove!(dir, repo_path:)
|
|
270
|
+
@out.puts(removed ? "Removed #{dir}" : "Kept #{dir} (uncommitted work)")
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def hooks_installer = HooksInstaller.default(env: @env, home: @home)
|
|
275
|
+
|
|
276
|
+
# Hooks written by an older version name its versioned exe; point them at the current launcher.
|
|
277
|
+
def refresh_hooks
|
|
278
|
+
installer = hooks_installer
|
|
279
|
+
installer.install! if installer.installed?
|
|
280
|
+
rescue Error
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Called by Claude Code on every hook event: silent, always exit 0.
|
|
285
|
+
def hook
|
|
286
|
+
Hook.new(event: @argv.shift, input: @input, socket_path: File.join(Config.default_dir(@env), "daemon.sock"), env: @env).run
|
|
287
|
+
0
|
|
288
|
+
rescue StandardError
|
|
289
|
+
0
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def install_hooks
|
|
293
|
+
yes = @argv.delete("--yes")
|
|
294
|
+
installer = hooks_installer
|
|
295
|
+
@out.puts "This adds Claude Code command hooks (#{HooksInstaller::EVENTS.join(", ")}) to #{installer.settings_path}."
|
|
296
|
+
@out.puts "Each runs `diffbroker hook <event>`, which tells your running companion the session id, repository, branch " \
|
|
297
|
+
"and head SHA. It never sends prompts, file contents or tool input, and only you see the result."
|
|
298
|
+
unless yes
|
|
299
|
+
@out.print "Add the hooks? [y/N] "
|
|
300
|
+
return @out.puts("Nothing changed.") unless %w[y yes].include?(@input.gets.to_s.strip.downcase)
|
|
301
|
+
end
|
|
302
|
+
backup = installer.install!
|
|
303
|
+
@out.puts "Hooks installed#{" (previous settings saved to #{backup})" if backup}. New Claude Code sessions pick them up."
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def uninstall_hooks
|
|
307
|
+
if hooks_installer.installed? || File.exist?(hooks_installer.settings_path)
|
|
308
|
+
backup = hooks_installer.uninstall!
|
|
309
|
+
@out.puts(backup ? "Hooks removed (previous settings saved to #{backup})." : "No Diff Broker hooks found; nothing changed.")
|
|
310
|
+
else
|
|
311
|
+
@out.puts "No Claude Code settings file; nothing to remove."
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Called by the terminal run script after an update_branch agent exited successfully (not listed in the help: the
|
|
316
|
+
# companion's own script is the only caller): pushes the finished rebase (BranchPublisher). Exit 1 when it could not.
|
|
317
|
+
def publish_branch
|
|
318
|
+
options = {}
|
|
319
|
+
OptionParser.new do |parser|
|
|
320
|
+
parser.on("--dir DIR") { options[:dir] = _1 }
|
|
321
|
+
parser.on("--head-ref REF") { options[:head_ref] = _1 }
|
|
322
|
+
parser.on("--expected SHA") { options[:expected_sha] = _1 }
|
|
323
|
+
parser.on("--base REF") { options[:base_ref] = _1 }
|
|
324
|
+
parser.on("--remote NAME") { options[:remote] = _1 }
|
|
325
|
+
end.parse!(@argv)
|
|
326
|
+
result = BranchPublisher.new(**options).publish!
|
|
327
|
+
@out.puts result.message
|
|
328
|
+
result.failed? ? 1 : 0
|
|
329
|
+
rescue ArgumentError => e
|
|
330
|
+
@err.puts "diffbroker: #{e.message}"
|
|
331
|
+
1
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Called by the terminal run script after the agent exits: silent, always exit 0.
|
|
335
|
+
def task_ended
|
|
336
|
+
id = @env["DIFFBROKER_TASK_ID"].to_s
|
|
337
|
+
token = @env["DIFFBROKER_TASK_TOKEN"].to_s
|
|
338
|
+
return 0 if id.empty? || token.empty?
|
|
339
|
+
|
|
340
|
+
Api.new(server: @env["DIFFBROKER_SERVER"] || config.server, token:)
|
|
341
|
+
.post("/agent_tasks/#{id}/events", { hook_event_name: "TaskEnded", exit_status: @argv.first.to_s })
|
|
342
|
+
0
|
|
343
|
+
rescue StandardError # never break the user's terminal session over a status report
|
|
344
|
+
0
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Diffbroker
|
|
7
|
+
# ~/.config/diffbroker/config.yml (settings) and credentials.yml (0600, device token). Spec §4.1: roots, terminal,
|
|
8
|
+
# worktree location, preferred agent, and the agents' command-line args for autonomous runs (spec §4.2 step 5).
|
|
9
|
+
class Config
|
|
10
|
+
TERMINALS = %w[terminal iterm2 ghostty warp tmux].freeze
|
|
11
|
+
AGENTS = %w[claude codex].freeze
|
|
12
|
+
DEFAULT_SERVER = "https://diff.broker"
|
|
13
|
+
DEFAULT_CLAUDE_ARGS = %w[--permission-mode auto --permission-prompts none].freeze
|
|
14
|
+
DEFAULT_CODEX_ARGS = %w[--sandbox workspace-write -c sandbox_workspace_write.network_access=true].freeze
|
|
15
|
+
# Diff Broker sets these itself for every run; user args may not override them.
|
|
16
|
+
# Where people usually keep code; the ones that exist are always scanned (with `roots` and the agents' project lists).
|
|
17
|
+
COMMON_ROOTS = %w[~/Code ~/code ~/Projects ~/projects ~/Developer ~/dev ~/src ~/repos ~/workspace ~/work ~/git ~/github ~/Sites].freeze
|
|
18
|
+
RESERVED_ARGS = %w[-p --print --output-format --mcp-config --strict-mcp-config --plugin-dir --json].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :dir, :data, :home
|
|
21
|
+
|
|
22
|
+
def self.default_dir(env = ENV)
|
|
23
|
+
env["DIFFBROKER_CONFIG_DIR"] || File.join(env["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config"), "diffbroker")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.common_roots(home = Dir.home)
|
|
27
|
+
COMMON_ROOTS.map { File.expand_path(_1.sub("~", home)) }.select { File.directory?(_1) }.uniq { _1.downcase }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
MAX_DEVICE_ROOTS = 20
|
|
31
|
+
|
|
32
|
+
# The folders the user added in Diff Broker (a "roots" message from the server). The server is not trusted with more
|
|
33
|
+
# than a list of existing-looking absolute folders: no home folder, no "/", a bounded count.
|
|
34
|
+
def self.clean_device_roots(list, home: Dir.home)
|
|
35
|
+
home = File.expand_path(home)
|
|
36
|
+
Array(list).first(MAX_DEVICE_ROOTS * 2).filter_map { |entry|
|
|
37
|
+
next unless entry.is_a?(String) && entry.length <= 500 && !entry.include?("\0")
|
|
38
|
+
|
|
39
|
+
entry = entry.strip
|
|
40
|
+
next unless entry.start_with?("/", "~/")
|
|
41
|
+
|
|
42
|
+
path = File.expand_path(entry.sub(/\A~/, home))
|
|
43
|
+
path unless path == home || path == "/"
|
|
44
|
+
}.uniq.first(MAX_DEVICE_ROOTS)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.load(dir: default_dir, env: ENV, platform: Diffbroker.platform, home: Dir.home)
|
|
48
|
+
path = File.join(dir, "config.yml")
|
|
49
|
+
data = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
|
|
50
|
+
new(dir:, data:, env:, platform:, home:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(dir:, data: {}, env: ENV, platform: Diffbroker.platform, home: Dir.home)
|
|
54
|
+
@dir = dir
|
|
55
|
+
@home = home
|
|
56
|
+
@data = data.to_h.transform_keys(&:to_s)
|
|
57
|
+
@env = env
|
|
58
|
+
@platform = platform
|
|
59
|
+
validate!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def server = (@env["DIFFBROKER_SERVER"] || data["server"] || DEFAULT_SERVER).to_s.chomp("/")
|
|
63
|
+
def cable_url = "#{server.sub(/\Ahttp/, "ws")}/cable"
|
|
64
|
+
# `roots` in config.yml and the folders added in Diff Broker (`device_roots`) add to the usual folders; the checkouts Claude Code and Codex know come in through `repo_scanner`.
|
|
65
|
+
def device_roots = self.class.clean_device_roots(data["device_roots"], home:)
|
|
66
|
+
def ignored_roots = self.class.clean_device_roots(data["ignored_roots"], home:)
|
|
67
|
+
def roots = (self.class.common_roots(home) + Array(data["roots"]).map { File.expand_path(_1.to_s.sub(/\A~/, home)) } + device_roots).uniq
|
|
68
|
+
# Folders the user removed in Diff Broker (`ignored_roots`) are skipped everywhere except inside a folder they added.
|
|
69
|
+
def repo_scanner
|
|
70
|
+
RepoScanner.new(roots: roots - device_roots, explicit_roots: device_roots, depth: scan_depth, exclude: [ worktree_root ],
|
|
71
|
+
ignored: ignored_roots, hints: AgentProjects.paths(home:), home:)
|
|
72
|
+
end
|
|
73
|
+
def scan_depth = Integer(data["scan_depth"] || 3)
|
|
74
|
+
def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
|
|
75
|
+
def worktree_root = File.expand_path(data["worktree_root"] || File.join(home, ".local/share/diffbroker/worktrees"))
|
|
76
|
+
def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/diffbroker")
|
|
77
|
+
def agent = (data["agent"] || "claude").to_s
|
|
78
|
+
def claude_command = (data["claude_command"] || "claude").to_s
|
|
79
|
+
def codex_command = (data["codex_command"] || "codex").to_s
|
|
80
|
+
def claude_args = data.key?("claude_args") ? data["claude_args"] : DEFAULT_CLAUDE_ARGS
|
|
81
|
+
def codex_args = data.key?("codex_args") ? data["codex_args"] : DEFAULT_CODEX_ARGS
|
|
82
|
+
|
|
83
|
+
def credentials_path = File.join(dir, "credentials.yml")
|
|
84
|
+
def device_token = credentials["device_token"]
|
|
85
|
+
def device_id = credentials["device_id"]
|
|
86
|
+
def paired? = !device_token.to_s.empty?
|
|
87
|
+
|
|
88
|
+
def save!(updates)
|
|
89
|
+
merged = data.merge(updates.transform_keys(&:to_s)).compact
|
|
90
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
91
|
+
File.write(File.join(dir, "config.yml"), merged.to_yaml)
|
|
92
|
+
self.class.new(dir:, data: merged, env: @env, platform: @platform, home:)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def save_credentials!(device_token:, device_id:)
|
|
96
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
97
|
+
File.write(credentials_path, { "device_token" => device_token, "device_id" => device_id, "server" => server }.to_yaml, perm: 0o600)
|
|
98
|
+
File.chmod(0o600, credentials_path)
|
|
99
|
+
@credentials = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Forgets the pairing (the server no longer knows this device); the next `diffbroker start` pairs again.
|
|
103
|
+
def clear_credentials!
|
|
104
|
+
FileUtils.rm_f(credentials_path)
|
|
105
|
+
@credentials = nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def credentials
|
|
111
|
+
@credentials ||= File.exist?(credentials_path) ? YAML.safe_load_file(credentials_path) || {} : {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate!
|
|
115
|
+
raise Error, "Unknown terminal #{terminal.inspect}. Use one of: #{TERMINALS.join(", ")}" unless TERMINALS.include?(terminal)
|
|
116
|
+
raise Error, "Unknown agent #{agent.inspect}. Use one of: #{AGENTS.join(", ")}" unless AGENTS.include?(agent)
|
|
117
|
+
|
|
118
|
+
{ "claude_args" => claude_args, "codex_args" => codex_args }.each do |key, args|
|
|
119
|
+
raise Error, "#{key} must be a list of strings" unless args.is_a?(Array) && args.all?(String)
|
|
120
|
+
|
|
121
|
+
reserved = args.map { _1.split("=").first } & RESERVED_ARGS
|
|
122
|
+
raise Error, "#{key} may not set #{reserved.join(", ")}; Diff Broker sets those itself" if reserved.any?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|