claude-inbox 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. metadata +163 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # Splits bracketed pastes (what the terminal sends once App turns on
5
+ # \e[?2004h) out of the raw keypress stream. Pure: no terminal, no IO.
6
+ #
7
+ # A paste can straddle several non-blocking reads, so this keeps the open
8
+ # one between calls and only hands it over once its closing bracket has
9
+ # arrived. Everything outside the brackets comes back as keys to handle
10
+ # as before.
11
+ #
12
+ # p = Paste.new
13
+ # p.feed("j\e[200~hel") # => [[:key, "j"]]
14
+ # p.feed("lo\e[201~k") # => [[:paste, "hello"], [:key, "k"]]
15
+ class Paste
16
+ OPEN = "\e[200~"
17
+ CLOSE = "\e[201~"
18
+
19
+ def initialize
20
+ @open = nil
21
+ end
22
+
23
+ def feed(raw)
24
+ out = []
25
+ rest = raw.to_s
26
+ until rest.empty?
27
+ if @open
28
+ body, close, rest = rest.partition(CLOSE)
29
+ @open << body
30
+ break if close.empty?
31
+ out << [:paste, @open]
32
+ @open = nil
33
+ else
34
+ keys, open, rest = rest.partition(OPEN)
35
+ out << [:key, keys] unless keys.empty?
36
+ @open = +"" unless open.empty?
37
+ end
38
+ end
39
+ out
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ # The peek pane: whether it is open, which row it is on, how far back it
5
+ # is scrolled, and what to paint for that. The lines themselves come from
6
+ # Logs, which it asks on every selection change so that opening the pane
7
+ # lands on a warm cache. Main-thread state only.
8
+ class Peek
9
+ TERMINAL_NOTE = "This is a claude you opened in a terminal yourself. The daemon can't attach to it, read its output, or stop it from outside. Switch to that window."
10
+ REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Open it in the web or mobile app instead."
11
+
12
+ # What Renderer paints: the body lines, the title bar, the dim line under it.
13
+ View = Struct.new(:lines, :title, :subtitle)
14
+
15
+ def initialize(logs)
16
+ @logs = logs
17
+ @open = false
18
+ @offset = 0
19
+ @selected = nil
20
+ @session = nil
21
+ end
22
+
23
+ def open? = @open
24
+
25
+ def toggle
26
+ @open = !@open
27
+ @logs.want(@session.id) if @open && @session&.actionable?
28
+ end
29
+
30
+ def close = @open = false
31
+
32
+ # Called on every selection change; a fold has no session.
33
+ def select(selection, session)
34
+ @selected = selection
35
+ @session = session
36
+ @offset = 0
37
+ @logs.want(session.id) if session&.actionable?
38
+ end
39
+
40
+ # Positive scrolls back into history, negative towards the tail.
41
+ def scroll(delta)
42
+ @offset = [@offset + delta, 0].max
43
+ end
44
+
45
+ # `row` is the selected row as the frame shows it, or nil when nothing is
46
+ # selected. Answers nil when there is no pane to paint.
47
+ def view(row, height)
48
+ return nil unless @open && @selected&.row?
49
+ View.new(scrolled(body(row), height - 2), row&.label || @selected.key, subtitle(row))
50
+ end
51
+
52
+ private
53
+
54
+ def body(row)
55
+ return ["(nothing selected)"] unless row
56
+ return interactive_note(row.session) if row.session.interactive?
57
+ @logs.cached(row.session.id) || ["(loading…)"]
58
+ end
59
+
60
+ def interactive_note(s)
61
+ [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}"]
62
+ end
63
+
64
+ def subtitle(row)
65
+ return nil unless row
66
+ s = row.session
67
+ parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
68
+ parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
69
+ parts.join(" · ")
70
+ end
71
+
72
+ # Lines are shown tail-first; the offset scrolls back into history and is
73
+ # clamped to however much history there is.
74
+ def scrolled(lines, view_h)
75
+ return lines if @offset.zero?
76
+ max_off = [lines.size - view_h, 0].max
77
+ @offset = [@offset, max_off].min
78
+ lines[0, lines.size - @offset]
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reaper"
4
+ require_relative "sessions"
5
+ require_relative "store"
6
+
7
+ module ClaudeInbox
8
+ # Asks `claude agents` for the list off the main thread, every INTERVAL
9
+ # seconds and on demand, and hands what it finds to App over the shared
10
+ # queue: [:sessions, list] for each hand-over, [:error, msg] when a poll
11
+ # fails, [:notice, text] when the reaper took something. Nothing here
12
+ # touches App's state directly; the queue is the whole of the interface.
13
+ # One worker runs every poll, so two can never overlap and publish the
14
+ # list out of order.
15
+ class Poller
16
+ INTERVAL = 4
17
+
18
+ def initialize(client:, store:, pull_requests:, jobs_dir:, reaper:, queue:, interval: INTERVAL)
19
+ @client = client
20
+ @store = store
21
+ @pull_requests = pull_requests
22
+ @jobs_dir = jobs_dir
23
+ @reaper = reaper
24
+ @queue = queue
25
+ @interval = interval
26
+ @wake = Queue.new
27
+ @paused = false
28
+ @lock = Mutex.new
29
+ end
30
+
31
+ def start
32
+ return if @thread&.alive?
33
+ soon
34
+ @thread = Thread.new { worker }
35
+ end
36
+
37
+ def stop = @thread&.kill
38
+
39
+ # Skips the poll rather than the timer, so nothing forks `claude` while
40
+ # another process holds the terminal. A poll already under way finishes.
41
+ def pause = @lock.synchronize { @paused = true }
42
+
43
+ def resume
44
+ @lock.synchronize { @paused = false }
45
+ soon
46
+ end
47
+
48
+ def soon = @wake << true
49
+
50
+ # The rows go up as soon as `claude agents` answers, and the slow calls
51
+ # (`claude rm`, a dozen serial `gh pr view`s) follow: that is the
52
+ # difference between the inbox appearing at once and five seconds later.
53
+ # Every hand-over is the whole list; the store hides the rows the reaper
54
+ # is about to take, so a list missing a key can only mean the daemon
55
+ # dropped it. The gh refresh comes last and publishes again only if a PR
56
+ # state moved.
57
+ def once
58
+ now = Time.now
59
+ sessions = Sessions.load(client: @client, jobs_dir: @jobs_dir, pull_requests: @pull_requests, overrides: @store.pr_overrides)
60
+ doomed = @reaper.due(sessions, now)
61
+ @store.hide(doomed)
62
+ @queue << [:sessions, sessions]
63
+ reaped = []
64
+ begin
65
+ reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
66
+ ensure
67
+ @store.release(doomed - reaped)
68
+ end
69
+ @queue << [:sessions, sessions] if reaped != doomed
70
+ notice_reaped(reaped) if reaped.any?
71
+ fresh, moved = @pull_requests.refresh(sessions)
72
+ @queue << [:sessions, fresh] if moved
73
+ rescue => e
74
+ @queue << [:error, e.message]
75
+ end
76
+
77
+ private
78
+
79
+ # Past StandardError `once` does not catch, and a dead worker would end
80
+ # polling with nothing on screen to say so.
81
+ def worker
82
+ loop do
83
+ @wake.pop(timeout: @interval)
84
+ @wake.clear
85
+ once unless paused?
86
+ rescue SystemStackError, ScriptError, SecurityError => e
87
+ @queue << [:error, e.message]
88
+ end
89
+ end
90
+
91
+ def paused? = @lock.synchronize { @paused }
92
+
93
+ def notice_reaped(keys)
94
+ word = (keys.size == 1) ? "session" : "sessions"
95
+ @queue << [:notice, "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@reaper.log_path}"]
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "records"
5
+ require_relative "subprocess"
6
+
7
+ module ClaudeInbox
8
+ # A pull request tied to a session. `state` uses GitHub's vocabulary plus
9
+ # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
10
+ # or nil when nothing has told us yet.
11
+ PullRequest = Struct.new(:number, :url, :state, :title) do
12
+ def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
13
+
14
+ def merged? = state == "MERGED"
15
+
16
+ def closed? = state == "CLOSED"
17
+
18
+ # Merged or closed: nothing more will happen to it.
19
+ def resolved? = merged? || closed?
20
+
21
+ def known? = !state.nil?
22
+ end
23
+
24
+ # Finds the PRs a session is tied to and keeps their state fresh.
25
+ #
26
+ # Claude Code already does the hard part: the daemon scans each background
27
+ # session's transcript for links and writes them to the session's job state
28
+ # file, which JobState reads onto `job_state`; `claude agents --json` does
29
+ # not expose them.
30
+ # It is a link scan, so a session that merely mentions a PR gets it too.
31
+ # Interactive sessions have no job file; for those the store's `pr` override
32
+ # is the only source.
33
+ #
34
+ # State comes first from our own record of resolved PRs, then from
35
+ # ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
36
+ # from `gh pr view` for PRs that are still open, at most once per
37
+ # REFRESH_AFTER. A merged or closed PR never changes again, so it is never
38
+ # asked about twice — and once gh has said so, it is written to
39
+ # RESOLVED_PATH so the next launch does not ask either. Claude Code's cache
40
+ # only covers PRs its own sessions opened, and a link scan picks up plenty
41
+ # of others.
42
+ #
43
+ # `enrich` never touches gh; it is what stands between `claude agents` and
44
+ # the first frame. `refresh` is the slow half, one network round trip per
45
+ # open PR, and the poller calls it after the list has already gone up.
46
+ class PullRequests
47
+ REFRESH_AFTER = 60
48
+
49
+ CLAUDE_DIR = File.join(Dir.home, ".claude")
50
+ RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
51
+
52
+ def initialize(cache_path: File.join(CLAUDE_DIR, "gh-pr-status-cache.json"), resolved_path: RESOLVED_PATH,
53
+ gh: "gh", clock: -> { Time.now })
54
+ @cache_path = cache_path
55
+ @resolved_path = resolved_path
56
+ @gh = gh
57
+ @clock = clock
58
+ @known = {} # url => PullRequest
59
+ @checked_at = {} # url => epoch seconds of the last gh call
60
+ @mutex = Mutex.new
61
+ end
62
+
63
+ # Each session with its `prs` set from what is already known, asking
64
+ # nobody. The scanned links come off `job_state`, so a session without
65
+ # one (interactive, or forgotten) has none; Sessions.load sees to the
66
+ # order. `overrides` maps session key => url for links set by hand; an
67
+ # override replaces the scanned list.
68
+ def enrich(sessions, overrides = {})
69
+ sessions.map do |s|
70
+ urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
71
+ s.with(prs: urls.map { |u| known(u) })
72
+ end
73
+ end
74
+
75
+ # Asks gh about every PR on these sessions that is due. Returns the
76
+ # sessions with the answers on their `prs` and whether any state changed,
77
+ # so the caller knows whether the list is worth publishing again.
78
+ def refresh(sessions)
79
+ changed = false
80
+ fresh = sessions.map do |s|
81
+ s.with(prs: s.prs.map { |pr|
82
+ status(pr.url).tap { |now| changed = true if now.state != pr.state }
83
+ })
84
+ end
85
+ [fresh, changed]
86
+ end
87
+
88
+ # Best known state for a url without asking gh.
89
+ def known(url)
90
+ @mutex.synchronize { @known[url] ||= seed(url) }
91
+ end
92
+
93
+ # Best known state for a url, refreshed through gh when due.
94
+ def status(url)
95
+ @mutex.synchronize do
96
+ pr = @known[url] ||= seed(url)
97
+ return pr if pr.resolved? || !due?(url)
98
+ @checked_at[url] = @clock.call.to_i
99
+ fresh = fetch(url)
100
+ return pr unless fresh
101
+ remember(fresh) if fresh.resolved?
102
+ @known[url] = fresh
103
+ end
104
+ end
105
+
106
+ # Parse `gh pr view --json` output. Pure so it can be tested.
107
+ def self.parse(url, json)
108
+ h = JSON.parse(json)
109
+ state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
110
+ PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"])
111
+ rescue JSON::ParserError
112
+ nil
113
+ end
114
+
115
+ # Only an https://github.com/<owner>/<repo>/pull/<n> link makes sense here.
116
+ def self.valid_url?(url)
117
+ url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
118
+ end
119
+
120
+ private
121
+
122
+ def due?(url)
123
+ @gh && @clock.call.to_i - @checked_at.fetch(url, 0) >= REFRESH_AFTER
124
+ end
125
+
126
+ def seed(url)
127
+ number = url[%r{/pull/(\d+)}, 1]&.to_i
128
+ cached = resolved[url] || claude_cache[url]
129
+ PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"), title: cached&.dig("title"))
130
+ end
131
+
132
+ # Same shape as Claude Code's cache, so `seed` reads both alike.
133
+ def resolved
134
+ @resolved ||= Records.read(@resolved_path)
135
+ end
136
+
137
+ def claude_cache
138
+ @claude_cache ||= Records.read(@cache_path)
139
+ end
140
+
141
+ # Under @mutex.
142
+ def remember(pr)
143
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title}
144
+ return unless @resolved_path
145
+ Records.save(@resolved_path, resolved)
146
+ end
147
+
148
+ def fetch(url)
149
+ r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url")
150
+ r.success? ? self.class.parse(url, r.out) : nil
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeInbox
6
+ # The subscription's 5-hour and 7-day usage, read from
7
+ # ~/.claude/rate_limits.json. Nothing writes that file but the user's own
8
+ # status line script (README, "Usage"): `claude agents --json` says nothing
9
+ # about limits and there is no CLI command for them, but every session hands
10
+ # its status line a `rate_limits` object on each turn, so a one-line tee
11
+ # there is the only source that costs no API calls. No file, or a file
12
+ # nobody has touched in STALE_AFTER, reads as nothing to show.
13
+ class RateLimits
14
+ DEFAULT_PATH = File.join(Dir.home, ".claude", "rate_limits.json")
15
+ STALE_AFTER = 15 * 60
16
+
17
+ def initialize(path: DEFAULT_PATH)
18
+ @path = path
19
+ @mtime = nil
20
+ @data = nil
21
+ end
22
+
23
+ # "usage 5h 23% · 7d 41%", or nil. Parses only when the file has changed,
24
+ # since render asks several times a second.
25
+ def label(now = Time.now)
26
+ mtime = File.mtime(@path)
27
+ return nil if now - mtime > STALE_AFTER
28
+ @data = parse if mtime != @mtime
29
+ @mtime = mtime
30
+ @data
31
+ rescue SystemCallError
32
+ @mtime = @data = nil
33
+ end
34
+
35
+ private
36
+
37
+ def parse
38
+ hash = JSON.parse(File.read(@path))
39
+ parts = {"five_hour" => "5h", "seven_day" => "7d"}.filter_map do |key, word|
40
+ pct = hash.dig(key, "used_percentage")
41
+ "#{word} #{pct.round}%" if pct.is_a?(Numeric)
42
+ end
43
+ "usage " + parts.join(" · ") unless parts.empty?
44
+ rescue JSON::ParserError, TypeError
45
+ nil
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "store"
5
+ require_relative "agents_client"
6
+
7
+ module ClaudeInbox
8
+ # Reaps sessions that have sat idle past Store::REAP_AFTER.
9
+ #
10
+ # The one thing in the inbox that destroys anything without being asked
11
+ # first, so the whole of it lives here rather than spread through the poll
12
+ # loop: one public method, and one append-only log that is the last record
13
+ # a session ever existed once `claude rm` has taken its transcript.
14
+ #
15
+ # Unpushed work is safe by construction. `claude rm` refuses a worktree
16
+ # holding commits that aren't pushed and reports a --discard-unpushed token
17
+ # to override it; nothing here ever passes that token, so a refusal is the
18
+ # end of it. Refusals are logged and retried at most daily.
19
+ class Reaper
20
+ RETRY_AFTER = 24 * 3600
21
+ DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
22
+
23
+ attr_reader :log_path
24
+
25
+ def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
26
+ @client = client
27
+ @store = store
28
+ @log_path = log_path
29
+ @enabled = enabled
30
+ end
31
+
32
+ def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
33
+
34
+ # For --fixture runs and tests: selects nothing, deletes nothing, and
35
+ # needs neither a client nor a store to do it.
36
+ def self.disabled = new(nil, nil, enabled: false)
37
+
38
+ # Reaps everything due, returning the keys it actually deleted so the
39
+ # caller can drop those rows before they reach the store. Refusals come
40
+ # back as survivors rather than exceptions: one worktree with unpushed
41
+ # commits must not stop the rest of the sweep.
42
+ #
43
+ # Raises if the log cannot be opened, before anything is deleted. No
44
+ # audit trail, no reaping.
45
+ def sweep(sessions, now)
46
+ return [] unless @enabled
47
+ now_i = now.to_i
48
+ due = sessions.filter_map { |s| row_if_due(s, now_i) }
49
+ return [] if due.empty?
50
+ with_log { |log| due.filter_map { |row| reap(row, log, now_i) } }
51
+ end
52
+
53
+ # Keys `sweep` would go after right now, without touching any of them,
54
+ # so the poller can hand the list over minus these before `claude rm`.
55
+ def due(sessions, now)
56
+ return [] unless @enabled
57
+ now_i = now.to_i
58
+ sessions.filter_map { |s| row_if_due(s, now_i)&.key }
59
+ end
60
+
61
+ private
62
+
63
+ def row_if_due(session, now_i)
64
+ row = @store.row(session)
65
+ return nil unless row.reapable?(now_i)
66
+ backing_off?(row, now_i) ? nil : row
67
+ end
68
+
69
+ def backing_off?(row, now_i)
70
+ at = row.reap_failed_at
71
+ !at.nil? && now_i - at.to_i < RETRY_AFTER
72
+ end
73
+
74
+ def reap(row, log, now_i)
75
+ @client.rm(row.session.id)
76
+ write(log, row, now_i, "reaped")
77
+ @store.forget(row.key)
78
+ row.key
79
+ rescue AgentsClient::Error => e
80
+ reason = e.message.lines.first.to_s.strip
81
+ @store.mark_reap_failed(row.key, reason)
82
+ write(log, row, now_i, "kept — #{reason}")
83
+ nil
84
+ end
85
+
86
+ def write(log, row, now_i, outcome)
87
+ idle_days = (now_i - row.state_since.to_i) / 86_400
88
+ log.puts([
89
+ Time.at(now_i).utc.strftime("%FT%TZ"),
90
+ row.session.id,
91
+ "idle #{idle_days}d",
92
+ row.session.display_name.inspect,
93
+ row.session.cwd,
94
+ outcome
95
+ ].join(" "))
96
+ end
97
+
98
+ def with_log
99
+ FileUtils.mkdir_p(File.dirname(@log_path))
100
+ File.open(@log_path, "a") { |f| yield f }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module ClaudeInbox
7
+ # The inbox's own records — the snooze table, the resolved PRs — kept
8
+ # under ~/.config/claude-inbox between launches. Saved atomically (a temp
9
+ # file beside the target, renamed over it) so a poll mid-write never
10
+ # reads half a record. Reading a missing or unparsable record yields {}
11
+ # so callers start from empty instead of failing.
12
+ module Records
13
+ module_function
14
+
15
+ def read(path)
16
+ (path && File.exist?(path)) ? JSON.parse(File.read(path)) : {}
17
+ rescue JSON::ParserError
18
+ {}
19
+ end
20
+
21
+ def save(path, data)
22
+ FileUtils.mkdir_p(File.dirname(path))
23
+ tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
24
+ File.write(tmp, JSON.pretty_generate(data))
25
+ File.rename(tmp, path)
26
+ end
27
+ end
28
+ end