claude-inbox 0.12.0 → 0.12.2
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 +4 -4
- data/README.md +2 -1
- data/lib/claude_inbox/app/attaching.rb +4 -3
- data/lib/claude_inbox/app/navigating.rb +3 -0
- data/lib/claude_inbox/app.rb +5 -0
- data/lib/claude_inbox/dialog.rb +14 -3
- data/lib/claude_inbox/input.rb +11 -1
- data/lib/claude_inbox/new_session_form.rb +5 -0
- data/lib/claude_inbox/remote/listener.rb +5 -3
- data/lib/claude_inbox/remote/pairing_dialog.rb +1 -1
- data/lib/claude_inbox/session_request.rb +5 -3
- data/lib/claude_inbox/settings.rb +23 -8
- data/lib/claude_inbox/slash_commands.rb +9 -7
- data/lib/claude_inbox/subprocess.rb +16 -4
- data/lib/claude_inbox.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39ef2bafdac9f7b1c8ba1d4e6f28f5105a5fd5c42320f627caf61fd07f9c314d
|
|
4
|
+
data.tar.gz: 0ed5aadb2d02b61b1eabca62a7de74820f7e884f5ffdc6f0bbb68382ba26cc13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 393709c2bc4fc083ded424de49ca3ad102bcf41d75fd72b5c445d8b1f23f7e80f7df0ac1623d4081994e14699168db7c58a2bc64f486b00dd83941c2d691600f
|
|
7
|
+
data.tar.gz: fb06b9a05422bff8e3a5474169a11574041219ae700e30a5178a1e507e74df95cd0c069a1c395a070a7f7856bbe6bcdd6380c2a6011a03ff36a1210c05cdae2e
|
data/README.md
CHANGED
|
@@ -105,7 +105,8 @@ Vim bindings. Arrows and the mouse work too.
|
|
|
105
105
|
`n` opens a form for the prompt, name, directory, model, effort, permissions,
|
|
106
106
|
worktree, Remote Control and color, which tints the row as `/color` would.
|
|
107
107
|
`Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
|
|
108
|
-
it and attaches.
|
|
108
|
+
it and attaches. `Option-S` or `Option-O` by mistake only says which key you
|
|
109
|
+
meant; it no longer reads as `Esc` and asks to discard the prompt.
|
|
109
110
|
|
|
110
111
|
Paste an image on macOS, or drop a file into the prompt, and refer to it as
|
|
111
112
|
`[Image #1]`. Type `/` for a menu of your skills.
|
|
@@ -53,7 +53,7 @@ module ClaudeInbox
|
|
|
53
53
|
|
|
54
54
|
def open_confirm(kind)
|
|
55
55
|
return unless require_actionable
|
|
56
|
-
@modal = Dialog::Confirm.new(kind, @selected.key)
|
|
56
|
+
@modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key))
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def stop_session(id)
|
|
@@ -64,11 +64,12 @@ module ClaudeInbox
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def delete_session(id)
|
|
67
|
-
|
|
67
|
+
label = label_for(id)
|
|
68
|
+
notice("deleting #{label}…")
|
|
68
69
|
in_background do
|
|
69
70
|
@client.rm(id)
|
|
70
71
|
@store.forget(id)
|
|
71
|
-
@queue << [:notice, "deleted #{
|
|
72
|
+
@queue << [:notice, "deleted #{label}"]
|
|
72
73
|
@poller.soon
|
|
73
74
|
end
|
|
74
75
|
end
|
|
@@ -38,6 +38,9 @@ module ClaudeInbox
|
|
|
38
38
|
|
|
39
39
|
def selected_session = session_for(@selected&.key)
|
|
40
40
|
|
|
41
|
+
# The alias if one is set, as the row shows it, rather than the bare id.
|
|
42
|
+
def label_for(key) = session_for(key)&.then { |s| @store.row(s).label } || key
|
|
43
|
+
|
|
41
44
|
def page = [Renderer.body_height(@terminal.size[1]), 1].max
|
|
42
45
|
|
|
43
46
|
def move(delta)
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -173,7 +173,12 @@ module ClaudeInbox
|
|
|
173
173
|
@input.typed(text).each { |name, c| handle_key(name, c) } if typing
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
# Only the form gives an Option chord a meaning; anywhere else it is the
|
|
177
|
+
# Esc and letter it was before Input named it.
|
|
176
178
|
def handle_key(name, key)
|
|
179
|
+
if Input.alt?(name) && !(@modal.is_a?(NewSessionForm) && NewSessionForm::SLIPS.key?(name))
|
|
180
|
+
return Input.unglued(key).each { |n, k| handle_key(n, k) }
|
|
181
|
+
end
|
|
177
182
|
return handle_modal_key(name, key) if @modal
|
|
178
183
|
return handle_line_key(name, key) if filter_editing?
|
|
179
184
|
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -116,13 +116,19 @@ module ClaudeInbox
|
|
|
116
116
|
class Confirm < Dialog
|
|
117
117
|
TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
|
|
118
118
|
|
|
119
|
+
# `label` is the name the list shows; the id is what App acts on.
|
|
120
|
+
def initialize(kind, id, label = id)
|
|
121
|
+
super(kind, id)
|
|
122
|
+
@label = label
|
|
123
|
+
end
|
|
124
|
+
|
|
119
125
|
def title = TITLES.fetch(kind)
|
|
120
126
|
|
|
121
|
-
def lines(
|
|
127
|
+
def lines(width, _caret)
|
|
122
128
|
case kind
|
|
123
|
-
when :stop then ["
|
|
129
|
+
when :stop then [ask("Stop", width), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
|
|
124
130
|
when :delete
|
|
125
|
-
["
|
|
131
|
+
[ask("Delete", width), " Its worktree and conversation", " go with it.",
|
|
126
132
|
"", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
|
|
127
133
|
when :adopt
|
|
128
134
|
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
@@ -135,6 +141,11 @@ module ClaudeInbox
|
|
|
135
141
|
return :cancel if dismiss?(name, key) || key == "n"
|
|
136
142
|
nil
|
|
137
143
|
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# One line, so a long name is cut rather than wrapped mid-word.
|
|
148
|
+
def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
|
|
138
149
|
end
|
|
139
150
|
|
|
140
151
|
# The first launch without the Claude Code pane asks once whether to add
|
data/lib/claude_inbox/input.rb
CHANGED
|
@@ -9,6 +9,15 @@ module ClaudeInbox
|
|
|
9
9
|
# keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
|
|
10
10
|
# events and not the raw bytes.
|
|
11
11
|
class Input
|
|
12
|
+
# macOS sends Option-s as "\es", so an Esc glued to one lowercase letter
|
|
13
|
+
# is taken as Option held with it.
|
|
14
|
+
ALT = /\A\e([a-z])\z/
|
|
15
|
+
|
|
16
|
+
def self.alt?(name) = name.to_s.start_with?("alt_")
|
|
17
|
+
|
|
18
|
+
# An Option chord as the quick Esc-then-letter it may also have been.
|
|
19
|
+
def self.unglued(raw) = [[:escape, "\e"], [raw[1], raw[1]]]
|
|
20
|
+
|
|
12
21
|
def initialize(input, out)
|
|
13
22
|
@input = input
|
|
14
23
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
@@ -51,7 +60,7 @@ module ClaudeInbox
|
|
|
51
60
|
out
|
|
52
61
|
end
|
|
53
62
|
|
|
54
|
-
def key_name(key) = @reader.console.keys[key] || key
|
|
63
|
+
def key_name(key) = @reader.console.keys[key] || key[ALT, 1]&.then { |c| :"alt_#{c}" } || key
|
|
55
64
|
|
|
56
65
|
# tty-reader glues ESC to whatever arrives within 100ms, so a fast
|
|
57
66
|
# "esc gg" comes in as one unknown key "\egg". Vim hands make that
|
|
@@ -59,6 +68,7 @@ module ClaudeInbox
|
|
|
59
68
|
def split_keys(key)
|
|
60
69
|
return [key] if key.size <= 1 || @reader.console.keys.key?(key)
|
|
61
70
|
return [key] unless key.start_with?("\e")
|
|
71
|
+
return [key] if key.match?(ALT)
|
|
62
72
|
["\e"] + key[1..].chars
|
|
63
73
|
end
|
|
64
74
|
end
|
|
@@ -35,6 +35,10 @@ module ClaudeInbox
|
|
|
35
35
|
# editable field spends these on its own text instead.
|
|
36
36
|
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
37
37
|
|
|
38
|
+
# Option slips for Ctrl on the two submit keys. Option-s arrives as Esc
|
|
39
|
+
# then s, and that Esc asked to discard the prompt.
|
|
40
|
+
SLIPS = {alt_s: "that's option-s — ctrl-s starts it", alt_o: "that's option-o — ctrl-o starts and attaches"}.freeze
|
|
41
|
+
|
|
38
42
|
def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
39
43
|
@p = Pastel.new(enabled: true)
|
|
40
44
|
@theme = Theme.new
|
|
@@ -83,6 +87,7 @@ module ClaudeInbox
|
|
|
83
87
|
when :escape then return escape_pressed
|
|
84
88
|
when :ctrl_s then return submit(attach: false)
|
|
85
89
|
when :ctrl_o then return submit(attach: true)
|
|
90
|
+
when *SLIPS.keys then @error = SLIPS[name]
|
|
86
91
|
when :ctrl_v then paste_clipboard
|
|
87
92
|
when :tab then complete_dir || move(1)
|
|
88
93
|
when :down then move(1)
|
|
@@ -200,11 +200,11 @@ module ClaudeInbox
|
|
|
200
200
|
authenticated(slot) if slot
|
|
201
201
|
Http.write(io, *answer(request, io, via))
|
|
202
202
|
rescue Http::Error => e
|
|
203
|
-
|
|
203
|
+
refuse(io, e)
|
|
204
204
|
rescue => e
|
|
205
205
|
# A message can name a path under the home directory, which is
|
|
206
206
|
# nobody's business before the token checks out.
|
|
207
|
-
|
|
207
|
+
refuse(io, Http::Error.new(500, authorized ? e.message : "internal error"))
|
|
208
208
|
end
|
|
209
209
|
|
|
210
210
|
private
|
|
@@ -336,11 +336,13 @@ module ClaudeInbox
|
|
|
336
336
|
@unauthenticated.delete(via) if @unauthenticated[via].zero?
|
|
337
337
|
end
|
|
338
338
|
|
|
339
|
+
def refuse(io, error) = Http.write(io, error.status, Http::JSON_TYPE.merge(error.headers), JSON.generate(error.body))
|
|
340
|
+
|
|
339
341
|
# `handle` answers every failure it can; what is left is the socket
|
|
340
342
|
# itself going away, and a thread must not report that on stderr.
|
|
341
343
|
def serve(sock, slot)
|
|
342
344
|
if slot[:busy]
|
|
343
|
-
|
|
345
|
+
refuse(sock, Http::Error.new(503, "busy: try again in a moment", headers: {"Retry-After" => "2"}))
|
|
344
346
|
else
|
|
345
347
|
handle(sock, via: slot[:via], slot: slot)
|
|
346
348
|
end
|
|
@@ -87,7 +87,7 @@ module ClaudeInbox
|
|
|
87
87
|
def key_lines(s)
|
|
88
88
|
return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
|
|
89
89
|
keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
|
|
90
|
-
[" " +
|
|
90
|
+
[" " + @theme.hints(keys)]
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
93
|
end
|
|
@@ -134,11 +134,13 @@ module ClaudeInbox
|
|
|
134
134
|
parts = path.split("/").reject(&:empty?)
|
|
135
135
|
(1..parts.size).each do |n|
|
|
136
136
|
label = parts.last(n).join("/")
|
|
137
|
-
return label if dirs.one?
|
|
137
|
+
return label if dirs_called(dirs, label).one?
|
|
138
138
|
end
|
|
139
139
|
path
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
def self.dirs_called(dirs, label) = dirs.select { |dir| dir.end_with?("/#{label}") }
|
|
143
|
+
|
|
142
144
|
# A path is taken as given. Anything else names one of `dirs` by its
|
|
143
145
|
# `label`.
|
|
144
146
|
def self.cwd_param(value, dirs)
|
|
@@ -146,7 +148,7 @@ module ClaudeInbox
|
|
|
146
148
|
raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
|
|
147
149
|
raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
|
|
148
150
|
return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
|
|
149
|
-
found = dirs
|
|
151
|
+
found = dirs_called(dirs, cwd)
|
|
150
152
|
raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
|
|
151
153
|
raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
|
|
152
154
|
found.first
|
|
@@ -174,6 +176,6 @@ module ClaudeInbox
|
|
|
174
176
|
text
|
|
175
177
|
end
|
|
176
178
|
|
|
177
|
-
private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
|
|
179
|
+
private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param, :dirs_called
|
|
178
180
|
end
|
|
179
181
|
end
|
|
@@ -3,17 +3,15 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module ClaudeInbox
|
|
6
|
-
# What `claude`
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# What a `claude` session in a directory gets from the settings files it
|
|
7
|
+
# reads, most specific wins: the defaults for flags left off, and the
|
|
8
|
+
# plugins it loads.
|
|
9
9
|
module Settings
|
|
10
|
+
# Strings, or nil when nothing sets them: then the CLI decides at launch.
|
|
10
11
|
Defaults = Struct.new(:model, :effort, :permission_mode, :remote)
|
|
11
12
|
|
|
12
13
|
def self.defaults(cwd, home: Dir.home)
|
|
13
|
-
user =
|
|
14
|
-
project = read(File.join(cwd, ".claude", "settings.json"))
|
|
15
|
-
local = read(File.join(cwd, ".claude", "settings.local.json"))
|
|
16
|
-
layers = [user, project, local]
|
|
14
|
+
user, project, local = layers = layers(cwd, home)
|
|
17
15
|
Defaults.new(
|
|
18
16
|
model: pick(layers, "model"),
|
|
19
17
|
effort: pick(layers, "effortLevel"),
|
|
@@ -22,6 +20,23 @@ module ClaudeInbox
|
|
|
22
20
|
)
|
|
23
21
|
end
|
|
24
22
|
|
|
23
|
+
# The "name@marketplace" keys of the plugins a session in cwd loads.
|
|
24
|
+
# Its install scope plays no part; see docs/cli-quirks.md.
|
|
25
|
+
def self.enabled_plugins(cwd, home: Dir.home)
|
|
26
|
+
layers(cwd, home).each_with_object({}) do |h, on|
|
|
27
|
+
plugins = h["enabledPlugins"]
|
|
28
|
+
on.merge!(plugins) if plugins.is_a?(Hash)
|
|
29
|
+
end.select { |_, v| v == true }.keys
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.layers(cwd, home)
|
|
33
|
+
[
|
|
34
|
+
read(File.join(home, ".claude", "settings.json")),
|
|
35
|
+
read(File.join(cwd, ".claude", "settings.json")),
|
|
36
|
+
read(File.join(cwd, ".claude", "settings.local.json"))
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
25
40
|
# "Enable Remote Control for all sessions" in /config. The CLI lets a
|
|
26
41
|
# repo turn it off but only user scope turn it on, and still honours the
|
|
27
42
|
# copy older versions kept in ~/.claude.json.
|
|
@@ -47,6 +62,6 @@ module ClaudeInbox
|
|
|
47
62
|
end
|
|
48
63
|
nil
|
|
49
64
|
end
|
|
50
|
-
private_class_method :remote_control, :pick
|
|
65
|
+
private_class_method :layers, :remote_control, :pick
|
|
51
66
|
end
|
|
52
67
|
end
|
|
@@ -6,10 +6,11 @@ module ClaudeInbox
|
|
|
6
6
|
# The slash commands `claude` would offer at its own prompt, read from the
|
|
7
7
|
# same places it reads them: `.claude/commands/*.md` and
|
|
8
8
|
# `.claude/skills/*/SKILL.md` under the project and the home directory,
|
|
9
|
-
# the skills and commands of
|
|
10
|
-
# the skills claude.ai syncs down
|
|
11
|
-
# live inside the CLI and are not
|
|
12
|
-
# the prompt goes to `claude --bg`
|
|
9
|
+
# the skills and commands of the plugins enabled there (named
|
|
10
|
+
# `plugin:name`), and the skills claude.ai syncs down
|
|
11
|
+
# (`anthropic-skills:name`). Built-ins live inside the CLI and are not
|
|
12
|
+
# listed; typing one still works, since the prompt goes to `claude --bg`
|
|
13
|
+
# verbatim.
|
|
13
14
|
#
|
|
14
15
|
# Pure: filesystem in, plain values out. Nothing here runs `claude`.
|
|
15
16
|
module SlashCommands
|
|
@@ -27,7 +28,7 @@ module ClaudeInbox
|
|
|
27
28
|
[
|
|
28
29
|
*from_dir(File.join(cwd, ".claude")),
|
|
29
30
|
*from_dir(File.join(home, ".claude")),
|
|
30
|
-
*plugins(home),
|
|
31
|
+
*plugins(cwd, home),
|
|
31
32
|
*synced(home)
|
|
32
33
|
].uniq(&:name).sort_by(&:name)
|
|
33
34
|
end
|
|
@@ -65,9 +66,10 @@ module ClaudeInbox
|
|
|
65
66
|
|
|
66
67
|
# `installed_plugins.json` maps "name@marketplace" to where the plugin
|
|
67
68
|
# was unpacked: one entry, or a list of them, one per install scope.
|
|
68
|
-
def plugins(home)
|
|
69
|
+
def plugins(cwd, home)
|
|
69
70
|
installed = Settings.read(File.join(home, ".claude", "plugins", "installed_plugins.json"))
|
|
70
|
-
|
|
71
|
+
enabled = Settings.enabled_plugins(cwd, home:)
|
|
72
|
+
(installed["plugins"] || {}).slice(*enabled).flat_map do |key, entries|
|
|
71
73
|
plugin = key.split("@").first
|
|
72
74
|
Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
|
|
73
75
|
from_dir(root, prefix: plugin)
|
|
@@ -10,15 +10,27 @@ module ClaudeInbox
|
|
|
10
10
|
def success? = status.success?
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# `asdf exec`, which the ruby shim runs us through, puts the ruby it
|
|
14
|
+
# picked ahead of every shim on PATH and names it in ASDF_INSTALL_PATH.
|
|
15
|
+
# Left in place, every session runs our ruby whatever its project's
|
|
16
|
+
# .tool-versions says.
|
|
17
|
+
def self.unpinned(env)
|
|
18
|
+
install = env["ASDF_INSTALL_PATH"] or return env
|
|
19
|
+
prefix = File.join(install, "")
|
|
20
|
+
path = env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject { |dir| dir.start_with?(prefix) }
|
|
21
|
+
env.reject { |k, _| k.start_with?("ASDF_INSTALL_") }.merge("PATH" => path.join(File::PATH_SEPARATOR))
|
|
22
|
+
end
|
|
23
|
+
|
|
13
24
|
# Children start in the environment from before Bundler, or every ruby a
|
|
14
|
-
# session runs, in any project, loads our Gemfile
|
|
15
|
-
# than set on ENV, which the poll
|
|
16
|
-
|
|
25
|
+
# session runs, in any project, loads our Gemfile, and from before asdf
|
|
26
|
+
# pinned our ruby. Handed to exec rather than set on ENV, which the poll
|
|
27
|
+
# and background threads share.
|
|
28
|
+
CHILD_ENV = unpinned(defined?(Bundler.unbundled_env) ? Bundler.unbundled_env : ENV.to_h).freeze
|
|
17
29
|
|
|
18
30
|
module_function
|
|
19
31
|
|
|
20
32
|
# argv as exec and Process.spawn take it, with CHILD_ENV in place of ours.
|
|
21
|
-
def command(*argv) =
|
|
33
|
+
def command(*argv) = [CHILD_ENV, *argv, {unsetenv_others: true}]
|
|
22
34
|
|
|
23
35
|
# `input` is written before any output is read, so it is for the few
|
|
24
36
|
# bytes a clipboard takes, not a stream.
|
data/lib/claude_inbox.rb
CHANGED