claude-inbox 0.5.0 → 0.6.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -3
  3. data/exe/claude-inbox +2 -91
  4. data/lib/claude_inbox/actions.rb +7 -4
  5. data/lib/claude_inbox/agents_client.rb +17 -12
  6. data/lib/claude_inbox/app.rb +64 -6
  7. data/lib/claude_inbox/cli.rb +201 -0
  8. data/lib/claude_inbox/dialog.rb +40 -0
  9. data/lib/claude_inbox/headless.rb +20 -3
  10. data/lib/claude_inbox/images.rb +1 -1
  11. data/lib/claude_inbox/job_state.rb +26 -4
  12. data/lib/claude_inbox/keymap.rb +1 -1
  13. data/lib/claude_inbox/logs.rb +0 -1
  14. data/lib/claude_inbox/mod.rb +36 -1
  15. data/lib/claude_inbox/mouse.rb +1 -1
  16. data/lib/claude_inbox/new_session_form.rb +8 -3
  17. data/lib/claude_inbox/palette.rb +1 -1
  18. data/lib/claude_inbox/paste.rb +1 -1
  19. data/lib/claude_inbox/peek.rb +5 -9
  20. data/lib/claude_inbox/poller.rb +4 -6
  21. data/lib/claude_inbox/pull_requests.rb +5 -5
  22. data/lib/claude_inbox/reaper.rb +1 -1
  23. data/lib/claude_inbox/remote/http.rb +1 -1
  24. data/lib/claude_inbox/remote/listener.rb +6 -7
  25. data/lib/claude_inbox/remote/page.html +7 -4
  26. data/lib/claude_inbox/remote/pairing.rb +2 -1
  27. data/lib/claude_inbox/remote/start.rb +1 -0
  28. data/lib/claude_inbox/renderer.rb +1 -1
  29. data/lib/claude_inbox/session.rb +4 -0
  30. data/lib/claude_inbox/session_request.rb +6 -4
  31. data/lib/claude_inbox/slash_commands.rb +3 -3
  32. data/lib/claude_inbox/snapshot.rb +8 -3
  33. data/lib/claude_inbox/store/entry.rb +4 -5
  34. data/lib/claude_inbox/store/row.rb +1 -1
  35. data/lib/claude_inbox/store/sections.rb +3 -3
  36. data/lib/claude_inbox/store.rb +1 -2
  37. data/lib/claude_inbox/text_buffer.rb +4 -9
  38. data/lib/claude_inbox/theme.rb +1 -1
  39. data/lib/claude_inbox/updater.rb +163 -0
  40. data/lib/claude_inbox.rb +1 -1
  41. data/mod/hooks/register.tsx +37 -7
  42. data/mod/tests/contract.test.tsx +2 -1
  43. data/mod/tests/pane.test.tsx +95 -2
  44. data/mod/tests/snapshot.fixture.ts +4 -3
  45. data/mod/types/index.d.ts +3 -1
  46. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
4
- data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
3
+ metadata.gz: cea52c058efc847eede80d9633f507fb0bcc88352351d2e8df98d57d531cbd57
4
+ data.tar.gz: 73953113b090f01dfd646bbcb9295be96d2f6616ecccab116b9f75c2f43f69ed
5
5
  SHA512:
6
- metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
7
- data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
6
+ metadata.gz: '080712923012ed2b164ce10dc6e4fbbf511319b7065ee54da07ffac2a6561deb62888f73012d8b7bf1ed3a78ae2d8fae078680fd4102a962bb4a05e345346c77'
7
+ data.tar.gz: dd9a7ca33067f30d0eb8cb98742bcc229cb981acfdfa0697966d59b43be9cf4c2451a6683378f6389b3226410261d615b9c5fb711df9c1019e659863ca76796c
data/README.md CHANGED
@@ -18,6 +18,8 @@ gem install claude-inbox
18
18
  claude-inbox
19
19
  ```
20
20
 
21
+ `claude-inbox update` installs the latest release.
22
+
21
23
  Put arguments you want on every launch in `~/.config/claude-inbox/config`.
22
24
 
23
25
  To add the pane to every Claude Code session:
@@ -26,6 +28,7 @@ To add the pane to every Claude Code session:
26
28
  claude-inbox install
27
29
  ```
28
30
 
31
+ The inbox offers to do this once, the first time it starts without the pane.
29
32
  `claude-inbox install --force` also replaces any other copy of the pane.
30
33
 
31
34
  ## Claude Code pane
@@ -37,7 +40,7 @@ a fullscreen terminal.
37
40
 
38
41
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
39
42
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
40
- `P`, `X`, `Ctrl-x`, `/` or `R`. `Enter`, or a click on a row, switches to the
43
+ `P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
41
44
  session when you attached through `claude-inbox`.
42
45
 
43
46
  It opens when a session starts. To stop that, set
@@ -88,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
88
91
  | `Ctrl-x` | delete the session, transcript and worktree for good |
89
92
  | `/` | filter; `Enter` keeps it, `Esc` clears |
90
93
  | `R` | poll now |
94
+ | `U` | update claude-inbox when the header says a newer one is out |
91
95
  | `q` | quit |
92
96
 
93
97
  `X` and `Ctrl-x` both ask for `y` first.
@@ -95,8 +99,9 @@ Vim bindings. Arrows and the mouse work too.
95
99
  ### New session
96
100
 
97
101
  `n` opens a form for the prompt, name, directory, model, effort, permissions,
98
- worktree and Remote Control. `Tab` moves between fields, `Ctrl-S` starts the
99
- session and `Ctrl-O` starts it and attaches.
102
+ worktree, Remote Control and color, which tints the row as `/color` would.
103
+ `Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
104
+ it and attaches.
100
105
 
101
106
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
102
107
  `[Image #1]`. Type `/` for a menu of your skills.
data/exe/claude-inbox CHANGED
@@ -2,95 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "claude_inbox"
5
+ require "claude_inbox/cli"
5
6
 
6
- if ARGV.include?("--version")
7
- puts ClaudeInbox::VERSION
8
- exit
9
- end
10
-
11
- require "claude_inbox/mod"
12
-
13
- # The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
14
- if ARGV.include?("--mod-dir")
15
- puts ClaudeInbox::Mod.link(force: true)
16
- exit
17
- end
18
-
19
- if ARGV.first == "install"
20
- begin
21
- installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
22
- rescue JSON::ParserError
23
- abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
24
- end
25
- installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
26
- puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
27
- puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
28
- "The pane is already in ~/.claude/settings.json."
29
- exit
30
- end
31
-
32
- require "claude_inbox/app"
33
- require "claude_inbox/config"
34
- require "claude_inbox/writer_lock"
35
- require "tmpdir"
36
-
37
- fixture = ARGV.include?("--fixture")
38
- # A fixture run takes the listen flags, for trying the API with curl, but
39
- # not the environment or the config file, which are for the real inbox.
40
- begin
41
- argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
42
- listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
43
- rescue ArgumentError => e
44
- abort "claude-inbox: #{e.message}"
45
- end
46
-
47
- # Crash traces go here instead of the alt screen when set.
48
- $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
49
-
50
- opts = {}
51
- if fixture
52
- agents = argv[argv.index("--fixture") + 1]
53
- fixtures = File.dirname(agents)
54
- opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
55
- opts[:store] = ClaudeInbox::Store.new(path: nil)
56
- opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
57
- # Its own token, lock and images, so a test inbox can neither rotate the
58
- # token phones are paired with nor hold the real inbox's lock.
59
- dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
60
- opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
61
- lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
62
- else
63
- # The only place the reaper is armed, and only with --reap: deleting
64
- # sessions is opt-in. Everything else — tests, --fixture, anything
65
- # embedding App — gets one that deletes nothing. The listener, likewise,
66
- # comes only from this file, and only when asked for.
67
- opts[:client] = ClaudeInbox::AgentsClient.new
68
- opts[:store] = ClaudeInbox::Store.new
69
- opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
70
- opts[:snapshot] = ClaudeInbox::Snapshot.new
71
- opts[:switch] = ClaudeInbox::Switch.new
72
- opts[:actions] = ClaudeInbox::Actions.new
73
- opts[:listen] = listen
74
- end
75
- opts[:color] = false if argv.include?("--no-color")
76
-
77
- if argv.include?("--headless")
78
- abort "claude-inbox: --headless takes no --fixture" if fixture
79
- require "claude_inbox/headless"
80
- exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
81
- end
82
-
83
- # The first inbox you can see is the snapshot's writer: a headless one gives
84
- # way to it, and a second inbox reads along, writing and deleting nothing.
85
- # Held for the life of this process by this reference.
86
- writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
87
- unless writer_lock.take(role: "inbox")
88
- opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
89
- opts[:reaper] = ClaudeInbox::Reaper.disabled
90
- opts[:snapshot] = ClaudeInbox::Snapshot.disabled
91
- opts[:actions] = ClaudeInbox::Actions.disabled
92
- end
93
- # So the pane settings.json names is the installed version's, after a gem update too.
94
- ClaudeInbox::Mod.link unless fixture
95
-
96
- ClaudeInbox::App.new(**opts).run
7
+ exit ClaudeInbox::CLI.new(ARGV).run
@@ -11,7 +11,6 @@ module ClaudeInbox
11
11
  # the store cannot honour after that, or a verb it lacks, is dropped.
12
12
  class Actions
13
13
  DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "actions")
14
- VERBS = %w[settle wake pin snooze acknowledge].freeze
15
14
  SNOOZES = %w[m15 h1 tomorrow_9am until_woken].freeze
16
15
  # A file still being written parses as nothing for milliseconds; a
17
16
  # session not yet polled appears within one poll.
@@ -62,12 +61,14 @@ module ClaudeInbox
62
61
  def parse(path)
63
62
  request = JSON.parse(File.read(path))
64
63
  request = {} unless request.is_a?(Hash)
65
- return request if File.mtime(path) < @clock.call - GRACE
64
+ return request if stale?(path)
66
65
  (request.key?("id") && request.key?("action")) ? request : nil
67
66
  rescue JSON::ParserError
68
- (File.mtime(path) < @clock.call - GRACE) ? {} : nil
67
+ stale?(path) ? {} : nil
69
68
  end
70
69
 
70
+ def stale?(path) = File.mtime(path) < @clock.call - GRACE
71
+
71
72
  def young?(request)
72
73
  at = request["at"].to_i / 1000
73
74
  at.positive? && Time.at(at) >= @clock.call - GRACE
@@ -75,7 +76,7 @@ module ClaudeInbox
75
76
 
76
77
  def apply(store, request, keys)
77
78
  id = request["id"]
78
- return false unless keys.include?(id) && VERBS.include?(request["action"])
79
+ return false unless keys.include?(id)
79
80
  case request["action"]
80
81
  when "settle" then store.settle(id)
81
82
  when "wake" then store.wake(id)
@@ -84,6 +85,8 @@ module ClaudeInbox
84
85
  when "snooze"
85
86
  return false unless SNOOZES.include?(request["choice"])
86
87
  store.snooze(id, request["choice"].to_sym)
88
+ else
89
+ return false
87
90
  end
88
91
  true
89
92
  end
@@ -59,17 +59,23 @@ module ClaudeInbox
59
59
  watchdog&.kill
60
60
  end
61
61
 
62
- def stop(id) = run(@bin, "stop", id)
62
+ def stop(id) = run("stop", id)
63
63
 
64
- def rm(id) = run(@bin, "rm", id)
64
+ def rm(id) = run("rm", id)
65
65
 
66
66
  MODELS = %w[default fable opus sonnet haiku].freeze
67
67
  EFFORTS = %w[default low medium high xhigh max].freeze
68
68
  PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
69
-
70
- # Start a background session. Returns its short id.
71
- def spawn(prompt:, cwd:, **opts)
72
- start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
69
+ # The colors `/color` offers, in its order.
70
+ COLORS = %w[default red blue green yellow purple orange pink cyan].freeze
71
+
72
+ # Start a background session. Returns its short id. `claude --bg` takes
73
+ # no color, and `/color` refuses a prompt after the color, so the color
74
+ # goes into the job file the way `/color` would put it there.
75
+ def spawn(prompt:, cwd:, color: nil, **opts)
76
+ id = start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
77
+ JobState.write_color(id, color, jobs_dir: @jobs_dir) if color
78
+ id
73
79
  end
74
80
 
75
81
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -239,10 +245,9 @@ module ClaudeInbox
239
245
  nil
240
246
  end
241
247
 
242
- def run(*argv)
243
- r = Subprocess.capture(*argv)
244
- raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
245
- true
248
+ def run(verb, id)
249
+ r = Subprocess.capture(@bin, verb, id)
250
+ raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
246
251
  end
247
252
 
248
253
  def start_bg(argv, cwd, what)
@@ -275,7 +280,7 @@ module ClaudeInbox
275
280
  # terminal round-trips through cooked mode and back.
276
281
  def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
277
282
 
278
- def stop(_id) = true
283
+ def stop(_id) = nil
279
284
 
280
285
  def spawn(prompt:, cwd:, **)
281
286
  sleep 0.5
@@ -284,6 +289,6 @@ module ClaudeInbox
284
289
 
285
290
  def adopt(session_id:, cwd:, pid:) = "adop7ed0"
286
291
 
287
- def rm(_id) = true
292
+ def rm(_id) = nil
288
293
  end
289
294
  end
@@ -24,6 +24,8 @@ require_relative "actions"
24
24
  require_relative "snapshot"
25
25
  require_relative "switch"
26
26
  require_relative "trust"
27
+ require_relative "mod"
28
+ require_relative "updater"
27
29
 
28
30
  module ClaudeInbox
29
31
  # Owns the terminal and the key loop. The only class allowed to spawn a
@@ -33,14 +35,17 @@ module ClaudeInbox
33
35
  # so both are off unless `bin/claude-inbox` switches them on.
34
36
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
35
37
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
36
- actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true, trust: Trust.method(:projects),
37
- terminal: Terminal.new(out, input), queue: Queue.new)
38
+ actions: Actions.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
39
+ trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled)
38
40
  @client = client
39
41
  @store = store
40
42
  @snapshot = snapshot
41
43
  @switch = switch
42
44
  @rate_limits = rate_limits
43
45
  @terminal = terminal
46
+ @updater = updater
47
+ @argv = argv
48
+ @out = out
44
49
  @color = color
45
50
  @trust = trust
46
51
  @renderer = Renderer.new(color: color)
@@ -65,6 +70,8 @@ module ClaudeInbox
65
70
  @last_poll = nil
66
71
  @quit = false
67
72
  @resize = false
73
+ @pane_offer = pane_offer
74
+ @modal = Dialog::PaneOffer.new if pane_offer.take
68
75
  end
69
76
 
70
77
  def run
@@ -83,6 +90,7 @@ module ClaudeInbox
83
90
  end
84
91
 
85
92
  def step(input = nil)
93
+ check_for_update unless @update_status
86
94
  drain_queue
87
95
  @logs.tick
88
96
  if @resize
@@ -124,6 +132,7 @@ module ClaudeInbox
124
132
  @error = nil
125
133
  when :error then @error = rest[0]
126
134
  when :notice then notice(rest[0])
135
+ when :update then update_checked(*rest)
127
136
  when :select then @pending_select = rest[0]
128
137
  when :attach then attach(rest[0])
129
138
  when :form_started then @modal = nil if @modal.equal?(rest[0])
@@ -131,8 +140,6 @@ module ClaudeInbox
131
140
  when :remote_started then remote_started(*rest)
132
141
  end
133
142
  end
134
- rescue ThreadError
135
- nil
136
143
  end
137
144
 
138
145
  # ----- main loop --------------------------------------------------------
@@ -208,7 +215,49 @@ module ClaudeInbox
208
215
  def status_text(now)
209
216
  return @notice[0] if @notice && now < @notice[1]
210
217
  return "⚠ #{@error}" if @error
211
- "polling…" unless @last_poll
218
+ return "polling…" unless @last_poll
219
+ "#{@update.notice} · U updates" if @update
220
+ end
221
+
222
+ # ----- updates ----------------------------------------------------------
223
+
224
+ # Once, on the first step: a stale cache means a network round trip, so
225
+ # it runs off the main thread. A laptop that is offline stays quiet
226
+ # rather than show an error at every launch; U says what went wrong.
227
+ def check_for_update
228
+ @update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
229
+ return unless @updater
230
+ in_background do
231
+ @queue << [:update, @updater.check]
232
+ rescue Updater::Error => e
233
+ @queue << [:update, nil, e.message]
234
+ end
235
+ end
236
+
237
+ def update_checked(update, failure = nil)
238
+ @update = update
239
+ @snapshot.update = update&.notice
240
+ @update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
241
+ end
242
+
243
+ # The install prints on the real screen, and the relaunch replaces this
244
+ # process. A failed install relaunches this version instead, once its
245
+ # error has been read, rather than leave the shell showing.
246
+ def update_inbox
247
+ return notice(@update_status) unless @update
248
+ @terminal.release do
249
+ @out.puts @update.installing
250
+ applied = @updater.apply
251
+ @out.puts applied.output unless applied.output.empty?
252
+ @terminal.pause unless applied.success?
253
+ relaunch
254
+ end
255
+ end
256
+
257
+ def relaunch
258
+ @updater.relaunch(@argv)
259
+ rescue SystemCallError => e
260
+ @error = "relaunch failed: #{e.message}"
212
261
  end
213
262
 
214
263
  def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
@@ -227,7 +276,7 @@ module ClaudeInbox
227
276
 
228
277
  def select(selection)
229
278
  @selected = selection
230
- @peek.select(selection, selected_session)
279
+ @peek.select(selected_session)
231
280
  end
232
281
 
233
282
  def session_for(key) = @store.sessions.find { |s| s.key == key }
@@ -349,6 +398,7 @@ module ClaudeInbox
349
398
  when :stop then open_confirm(:stop)
350
399
  when :delete then open_confirm(:delete)
351
400
  when :refresh then @poller.soon
401
+ when :update then update_inbox
352
402
  when :toggle_peek then toggle_peek
353
403
  when :new_session then open_new_session
354
404
  when :remote_pairing then @modal = @listener.pairing_dialog
@@ -538,6 +588,7 @@ module ClaudeInbox
538
588
  when :stop then stop_session(id)
539
589
  when :delete then delete_session(id)
540
590
  when :adopt then adopt_session(id)
591
+ when :install then install_pane
541
592
  end
542
593
  when :save then save_prompt
543
594
  when :copy then @listener.copy_pairing_url
@@ -576,6 +627,13 @@ module ClaudeInbox
576
627
  end
577
628
  end
578
629
 
630
+ def install_pane
631
+ @pane_offer.install
632
+ notice("added the pane — new Claude Code sessions load it; /inbox opens it")
633
+ rescue JSON::ParserError
634
+ notice("~/.claude/settings.json is not valid JSON, so it was left alone")
635
+ end
636
+
579
637
  def delete_session(id)
580
638
  notice("deleting #{id}…")
581
639
  in_background do
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tmpdir"
5
+ require_relative "app"
6
+ require_relative "config"
7
+ require_relative "headless"
8
+ require_relative "mod"
9
+ require_relative "updater"
10
+ require_relative "writer_lock"
11
+
12
+ module ClaudeInbox
13
+ # What `claude-inbox` does with its arguments: install the pane, update
14
+ # claude-inbox, or launch the inbox. `run` is true when it succeeded.
15
+ class CLI
16
+ USAGE = <<~USAGE
17
+ Usage: claude-inbox [options]
18
+ claude-inbox install [--force]
19
+ claude-inbox update
20
+ USAGE
21
+
22
+ # `lock` stands in for the writer lock a live launch takes.
23
+ def initialize(argv, env: ENV, config: Config::PATH, lock: nil, out: $stdout)
24
+ @argv = argv
25
+ @env = env
26
+ @config = config
27
+ @lock = lock
28
+ @out = out
29
+ end
30
+
31
+ def run
32
+ case @argv.first
33
+ when "install" then install
34
+ when "update" then update
35
+ else launch
36
+ end
37
+ end
38
+
39
+ # The launch flags, the config file's before the typed ones. A fixture
40
+ # run takes the listen flags, for trying the API with curl, but not the
41
+ # environment or the config file, which are for the real inbox.
42
+ def options
43
+ @options ||= begin
44
+ typed = parse(@argv)
45
+ options = typed[:fixture] ? typed : parse(Config.argv(@argv, path: @config))
46
+ env = typed[:fixture] ? {} : @env
47
+ options.merge(listen: Remote::Listener.options(env, flag: options[:listen], modes: options[:allow_modes]))
48
+ end
49
+ rescue OptionParser::ParseError, ArgumentError => e
50
+ abort "claude-inbox: #{e.message}"
51
+ end
52
+
53
+ # The keywords App is built with. A live launch takes the writer lock
54
+ # here: the first inbox you can see is the snapshot's writer, and a
55
+ # second one reads along, writing and deleting nothing.
56
+ def app_options
57
+ opts = options[:fixture] ? fixture_options : live_options
58
+ opts[:color] = options[:color]
59
+ return opts if options[:fixture] || writer_lock.take(role: "inbox")
60
+ opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
61
+ snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
62
+ end
63
+
64
+ private
65
+
66
+ def install
67
+ force = false
68
+ subcommand("install") { |p| p.on("--force", "Replace any other copy of the pane") { force = true } }
69
+ begin
70
+ installed = Mod.install(force: force)
71
+ rescue JSON::ParserError
72
+ abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
73
+ end
74
+ installed.removed.each { |dir| @out.puts "Removed #{dir} from ~/.claude/settings.json." }
75
+ @out.puts "The pane now runs #{Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
76
+ @out.puts(installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
77
+ "The pane is already in ~/.claude/settings.json.")
78
+ true
79
+ end
80
+
81
+ def update
82
+ subcommand("update")
83
+ updater = Updater.new
84
+ begin
85
+ update = updater.check(force: true)
86
+ rescue Updater::Error => e
87
+ abort "claude-inbox: #{e.message}"
88
+ end
89
+ unless update
90
+ @out.puts((updater.kind == :gem) ? "claude-inbox #{VERSION} is the latest release." : "main is up to date.")
91
+ return true
92
+ end
93
+ @out.puts update.installing
94
+ applied = updater.apply
95
+ abort "claude-inbox: #{applied.output.strip}" unless applied.success?
96
+ @out.puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
97
+ true
98
+ end
99
+
100
+ def launch
101
+ options
102
+ # Crash traces go here instead of the alt screen when set.
103
+ $stderr.reopen(@env["CLAUDE_INBOX_STDERR"], "a") if @env["CLAUDE_INBOX_STDERR"]
104
+ return headless if options[:headless]
105
+ auto_update if options[:auto_update]
106
+ # So the pane settings.json names is the installed version's, after a gem update too.
107
+ Mod.link unless options[:fixture]
108
+ App.new(**app_options).run
109
+ true
110
+ end
111
+
112
+ def headless
113
+ abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
114
+ Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
115
+ end
116
+
117
+ # Only what a check within the day already found: a cold launch never waits
118
+ # on the network. The relaunch is the new version's launch; a failure leaves
119
+ # this one to start as usual.
120
+ def auto_update
121
+ return unless (update = updater&.cached)
122
+ @out.puts update.installing
123
+ applied = updater.apply
124
+ applied.success? ? updater.relaunch(@argv) : warn("claude-inbox: #{applied.output.strip}")
125
+ end
126
+
127
+ def fixture_options
128
+ fixtures = File.dirname(options[:fixture])
129
+ # Its own token, lock and images, so a test inbox can neither rotate the
130
+ # token phones are paired with nor hold the real inbox's lock.
131
+ dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
132
+ {client: FixtureClient.new(options[:fixture], logs: File.binread(File.join(fixtures, "logs_raw.txt"))),
133
+ store: Store.new(path: nil),
134
+ pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
135
+ listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
136
+ lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
137
+ end
138
+
139
+ # The only place the reaper is armed, and only with --reap: deleting
140
+ # sessions is opt-in. Everything else — tests, --fixture, anything
141
+ # embedding App — gets one that deletes nothing. The listener, likewise,
142
+ # comes only from here, and only when asked for.
143
+ def live_options
144
+ client = AgentsClient.new
145
+ store = Store.new
146
+ {client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
147
+ snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
148
+ listen: options[:listen], updater: updater,
149
+ # The typed arguments, not the config's: a relaunch reads the config again.
150
+ argv: @argv}
151
+ end
152
+
153
+ def updater
154
+ @updater ||= Updater.new if options[:update_check] && !options[:fixture]
155
+ end
156
+
157
+ # Held for the life of this process by this reference.
158
+ def writer_lock = (@lock ||= WriterLock.new)
159
+
160
+ def parse(argv)
161
+ opts = {color: true, update_check: true}
162
+ parser = OptionParser.new(USAGE) do |p|
163
+ p.on("--version", "Print the version") {
164
+ @out.puts VERSION
165
+ exit
166
+ }
167
+ p.on("--mod-dir", "Print the pane's directory, for CLAUDE_CODE_PLUGIN_DIRS") {
168
+ @out.puts Mod.link(force: true)
169
+ exit
170
+ }
171
+ p.on("--fixture PATH", "Read sessions from an agents.json instead of the daemon") { |path| opts[:fixture] = path }
172
+ p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
173
+ p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
174
+ p.on("--no-color", "Draw without color") { opts[:color] = false }
175
+ p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
176
+ p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
177
+ # --listen-lan wins over --listen, whichever came last.
178
+ p.on("--listen[=PORT]", "Let a phone on this Mac start sessions") { |port| opts[:listen] = {lan: false, port: port} unless opts.dig(:listen, :lan) }
179
+ p.on("--listen-lan[=PORT]", "Let a phone on your network start sessions") { |port| opts[:listen] = {lan: true, port: port} }
180
+ p.on("--listen-allow-modes=MODES", "The permission modes a phone may start in") { |modes| opts[:allow_modes] = modes }
181
+ end
182
+ strictly(parser, argv)
183
+ opts
184
+ end
185
+
186
+ def subcommand(name)
187
+ parser = OptionParser.new(USAGE)
188
+ yield parser if block_given?
189
+ strictly(parser, @argv.drop(1))
190
+ rescue OptionParser::ParseError => e
191
+ abort "claude-inbox #{name}: #{e.message}"
192
+ end
193
+
194
+ # Exact names only, so a typo is an error rather than the flag it abbreviates.
195
+ def strictly(parser, argv)
196
+ parser.require_exact = true
197
+ rest = parser.parse(argv)
198
+ raise OptionParser::InvalidArgument, rest.first if rest.any?
199
+ end
200
+ end
201
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-box"
4
+ require_relative "text"
4
5
  require_relative "text_buffer"
5
6
 
6
7
  module ClaudeInbox
@@ -78,6 +79,45 @@ module ClaudeInbox
78
79
  end
79
80
  end
80
81
 
82
+ # The first launch without the Claude Code pane asks once whether to add
83
+ # it, and has to say what the pane is to someone who has never seen it.
84
+ class PaneOffer < Confirm
85
+ WIDTH = 60
86
+ SKETCH = [
87
+ " ┌──────────────────────────┬─────────────────────┐",
88
+ " │ > fix the flaky spec │ 📥 Inbox │",
89
+ " │ │ ● needs you docs │",
90
+ " │ Reading app.rb… │ ⠋ working api │",
91
+ " │ │ ✓ done auth │",
92
+ " └──────────────────────────┴─────────────────────┘"
93
+ ].freeze
94
+
95
+ def initialize = super(:install, nil)
96
+
97
+ def title = " ✨ The inbox, inside Claude Code "
98
+
99
+ # Drawn by hand: TTY::Box measures an emoji as one column, which
100
+ # pushes the right border out on every line that has one.
101
+ def frame(width, _caret = nil)
102
+ inner = [width - 4, WIDTH].min - 2
103
+ ["┌#{title}#{"─" * (inner - Text.width(title))}┐",
104
+ *lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
105
+ "└#{"─" * inner}┘"]
106
+ end
107
+
108
+ def lines(_width, _caret)
109
+ ["", *SKETCH, "",
110
+ " • Every session, docked beside the transcript",
111
+ " • Switch sessions without leaving the one you're in",
112
+ " • Snooze, pin and settle with the same keys",
113
+ " • /inbox opens it, ctrl+x tab focuses it",
114
+ "",
115
+ " You can install this later with claude-inbox install",
116
+ "",
117
+ " y add it to Claude Code esc not now"]
118
+ end
119
+ end
120
+
81
121
  # `a` alias and `P` pull request: one line of text, read from `value`
82
122
  # once :save comes back. Empty means clear.
83
123
  class Prompt < Dialog