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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/diffbroker +10 -0
  4. data/lib/diffbroker/agent_projects.rb +35 -0
  5. data/lib/diffbroker/agents/base.rb +48 -0
  6. data/lib/diffbroker/agents/claude.rb +100 -0
  7. data/lib/diffbroker/agents/codex.rb +72 -0
  8. data/lib/diffbroker/agents.rb +18 -0
  9. data/lib/diffbroker/api.rb +59 -0
  10. data/lib/diffbroker/branch_publisher.rb +66 -0
  11. data/lib/diffbroker/cable_client.rb +213 -0
  12. data/lib/diffbroker/cli.rb +347 -0
  13. data/lib/diffbroker/config.rb +126 -0
  14. data/lib/diffbroker/daemon.rb +277 -0
  15. data/lib/diffbroker/explain_verifier.rb +55 -0
  16. data/lib/diffbroker/gh_check.rb +22 -0
  17. data/lib/diffbroker/git_context.rb +115 -0
  18. data/lib/diffbroker/hook.rb +50 -0
  19. data/lib/diffbroker/hooks_installer.rb +115 -0
  20. data/lib/diffbroker/launcher.rb +62 -0
  21. data/lib/diffbroker/lifecycle.rb +127 -0
  22. data/lib/diffbroker/local_relay.rb +100 -0
  23. data/lib/diffbroker/login.rb +68 -0
  24. data/lib/diffbroker/maintenance.rb +65 -0
  25. data/lib/diffbroker/neutralizer.rb +80 -0
  26. data/lib/diffbroker/process_scanner.rb +104 -0
  27. data/lib/diffbroker/reaper.rb +111 -0
  28. data/lib/diffbroker/repo_scanner.rb +88 -0
  29. data/lib/diffbroker/run_script.rb +62 -0
  30. data/lib/diffbroker/runner.rb +41 -0
  31. data/lib/diffbroker/service_installer.rb +179 -0
  32. data/lib/diffbroker/skill_installer.rb +30 -0
  33. data/lib/diffbroker/task_files.rb +90 -0
  34. data/lib/diffbroker/task_runner.rb +286 -0
  35. data/lib/diffbroker/url_handler_installer.rb +111 -0
  36. data/lib/diffbroker/version.rb +5 -0
  37. data/lib/diffbroker/worktree.rb +115 -0
  38. data/lib/diffbroker.rb +46 -0
  39. data/plugin/.claude-plugin/plugin.json +5 -0
  40. data/plugin/skills/diffbroker/SKILL.md +36 -0
  41. data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
  42. 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