zillacore 0.0.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 (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore Log Viewer (Rofi version)
5
+ # Shows a rofi menu to select which agent log to tail
6
+ # Non-blocking, safe for waybar on-click
7
+
8
+ require "json"
9
+ require "net/http"
10
+ require "socket"
11
+
12
+ SOCKET_PATH = "/tmp/zillacore-monitor.sock"
13
+ API_URL = "http://localhost:4567/api/status"
14
+ CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
15
+
16
+ # Load agent configuration from JSON
17
+ def load_agent_config
18
+ config = JSON.parse(File.read(CONFIG_PATH))
19
+ agents = {}
20
+ config["agents"].each do |agent|
21
+ agents[agent["name"].downcase] = agent["emoji"]
22
+ end
23
+ default_emoji = config["default_emoji"] || "❓"
24
+ [agents, default_emoji]
25
+ rescue StandardError => e
26
+ warn "Failed to load waybar.json: #{e.message}"
27
+ [{}, "❓"]
28
+ end
29
+
30
+ AGENTS, DEFAULT_EMOJI = load_agent_config
31
+
32
+ def fetch_state_from_socket
33
+ socket = UNIXSocket.new(SOCKET_PATH)
34
+ data = socket.read
35
+ socket.close
36
+ JSON.parse(data)
37
+ end
38
+
39
+ def fetch_state_from_api
40
+ uri = URI(API_URL)
41
+ response = Net::HTTP.get_response(uri)
42
+ return nil unless response.is_a?(Net::HTTPSuccess)
43
+
44
+ JSON.parse(response.body)
45
+ end
46
+
47
+ def fetch_state
48
+ # Prefer socket (daemon mode) — faster, no HTTP overhead
49
+ fetch_state_from_socket
50
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
51
+ # Daemon not running — fall back to direct API call
52
+ data = fetch_state_from_api
53
+ unless data
54
+ system("notify-send", "ZillaCore", "Server not reachable")
55
+ exit 1
56
+ end
57
+ data
58
+ rescue StandardError => e
59
+ system("notify-send", "ZillaCore Error", e.message)
60
+ exit 1
61
+ end
62
+
63
+ def format_elapsed(seconds)
64
+ return "#{seconds}s" if seconds < 60
65
+
66
+ minutes = seconds / 60
67
+ return "#{minutes}m" if minutes < 60
68
+
69
+ hours = minutes / 60
70
+ "#{hours}h"
71
+ end
72
+
73
+ state = fetch_state
74
+ sessions = state["sessions"] || []
75
+
76
+ if sessions.empty?
77
+ system("notify-send", "ZillaCore", "No active agent sessions")
78
+ exit 0
79
+ end
80
+
81
+ INFRA_CMDS = %w[kiro-cli-chat ruby-lsp clangd gopls].freeze
82
+
83
+ # Build menu entries: sessions + their child processes
84
+ entries = []
85
+ sessions.each do |s|
86
+ agent = s["agent"]
87
+ elapsed = format_elapsed(s["elapsed_seconds"])
88
+ card_key = s["card_key"]
89
+ context = if card_key.start_with?("discord-")
90
+ "Discord chat"
91
+ elsif card_key.start_with?("card-")
92
+ card_key.split("-")[1]
93
+ else
94
+ card_key
95
+ end
96
+ emoji = AGENTS[agent.downcase] || DEFAULT_EMOJI
97
+ entries << { display: "#{emoji} #{agent}: #{context} (#{elapsed})", type: :log, log: s["log_file"] }
98
+ entries << { display: " ⛔ Kill session: #{agent} (#{context})", type: :kill_session, card_key: card_key, agent: agent }
99
+
100
+ (s["children"] || []).each do |c|
101
+ cmd_short = c["cmd"].to_s.split("/").last.to_s.split.first.to_s
102
+ cmd_short = c["cmd"].to_s[0..40] if cmd_short.empty?
103
+ next if INFRA_CMDS.any? { |ic| cmd_short.start_with?(ic) }
104
+
105
+ entries << {
106
+ display: " └ 🔪 Kill: #{cmd_short} (#{format_elapsed(c["elapsed_seconds"])}) [PID #{c["pid"]}]",
107
+ type: :kill_child, pid: c["pid"], cmd: cmd_short
108
+ }
109
+ end
110
+ end
111
+
112
+ # If only one session with no children, open log directly
113
+ if entries.size == 1 && entries[0][:type] == :log
114
+ spawn("alacritty", "-e", "tail", "-f", entries[0][:log]) if entries[0][:log]
115
+ exit 0
116
+ end
117
+
118
+ def find_launcher
119
+ %w[rofi fuzzel wofi zenity].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
120
+ end
121
+
122
+ def run_menu(launcher, entries)
123
+ menu_text = entries.map { |e| e[:display] }.join("\n")
124
+ case launcher
125
+ when "rofi"
126
+ IO.popen(%w[rofi -dmenu -i -p] + ["Agent Sessions"], "r+") do |io|
127
+ io.puts menu_text
128
+ io.close_write
129
+ io.read.strip
130
+ end
131
+ when "fuzzel"
132
+ IO.popen(%w[fuzzel --dmenu --prompt] + ["Agent Sessions: "], "r+") do |io|
133
+ io.puts menu_text
134
+ io.close_write
135
+ io.read.strip
136
+ end
137
+ when "wofi"
138
+ IO.popen(%w[wofi --dmenu --prompt] + ["Agent Sessions"], "r+") do |io|
139
+ io.puts menu_text
140
+ io.close_write
141
+ io.read.strip
142
+ end
143
+ when "zenity"
144
+ IO.popen(["zenity", "--list", "--title", "Agent Sessions", "--column", "Session", "--width", "600", "--height", "400"], "r+",
145
+ err: "/dev/null") do |io|
146
+ entries.each { |e| io.puts e[:display] }
147
+ io.close_write
148
+ io.read.strip
149
+ end
150
+ end
151
+ end
152
+
153
+ launcher = find_launcher
154
+ unless launcher
155
+ system("notify-send", "ZillaCore", "No menu launcher found (install rofi, fuzzel, wofi, or zenity)")
156
+ exit 1
157
+ end
158
+
159
+ selected_line = run_menu(launcher, entries)
160
+
161
+ unless selected_line.to_s.empty?
162
+ selected = entries.find { |e| e[:display].strip == selected_line.strip }
163
+ if selected
164
+ case selected[:type]
165
+ when :log
166
+ spawn("alacritty", "-e", "tail", "-f", selected[:log]) if selected[:log]
167
+ when :kill_session
168
+ card_key = selected[:card_key]
169
+ uri = URI("http://localhost:4567/api/sessions/kill/#{card_key}")
170
+ response = Net::HTTP.post(uri, "", { "Content-Type" => "application/json" })
171
+ if response.is_a?(Net::HTTPSuccess)
172
+ system("notify-send", "ZillaCore", "Killed session: #{selected[:agent]}")
173
+ else
174
+ system("notify-send", "ZillaCore", "Failed to kill session: #{selected[:agent]}")
175
+ end
176
+ when :kill_child
177
+ pid = selected[:pid]
178
+ begin
179
+ Process.kill("TERM", pid)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+ Thread.new do
184
+ sleep 3
185
+ begin
186
+ Process.kill("KILL", pid)
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ system("notify-send", "ZillaCore", "Killed #{selected[:cmd]} (PID #{pid})")
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore Log Viewer
5
+ # Shows a rofi menu to select which agent log to tail
6
+
7
+ require "json"
8
+ require "net/http"
9
+ require "socket"
10
+
11
+ SOCKET_PATH = "/tmp/zillacore-monitor.sock"
12
+ API_URL = "http://localhost:4567/api/status"
13
+ CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
14
+
15
+ # Load agent configuration from JSON
16
+ def load_agent_config
17
+ config = JSON.parse(File.read(CONFIG_PATH))
18
+ agents = {}
19
+ config["agents"].each do |agent|
20
+ agents[agent["name"].downcase] = agent["emoji"]
21
+ end
22
+ default_emoji = config["default_emoji"] || "❓"
23
+ [agents, default_emoji]
24
+ rescue StandardError => e
25
+ warn "Failed to load waybar.json: #{e.message}"
26
+ [{}, "❓"]
27
+ end
28
+
29
+ AGENTS, DEFAULT_EMOJI = load_agent_config
30
+
31
+ def fetch_state_from_socket
32
+ socket = UNIXSocket.new(SOCKET_PATH)
33
+ data = socket.read
34
+ socket.close
35
+ JSON.parse(data)
36
+ end
37
+
38
+ def fetch_state_from_api
39
+ uri = URI(API_URL)
40
+ response = Net::HTTP.get_response(uri)
41
+ return nil unless response.is_a?(Net::HTTPSuccess)
42
+
43
+ JSON.parse(response.body)
44
+ end
45
+
46
+ def fetch_state
47
+ # Prefer socket (daemon mode) — faster, no HTTP overhead
48
+ fetch_state_from_socket
49
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
50
+ # Daemon not running — fall back to direct API call
51
+ data = fetch_state_from_api
52
+ unless data
53
+ puts "Error: Server not reachable"
54
+ exit 1
55
+ end
56
+ data
57
+ rescue StandardError => e
58
+ puts "Error: #{e.message}"
59
+ exit 1
60
+ end
61
+
62
+ def format_elapsed(seconds)
63
+ return "#{seconds}s" if seconds < 60
64
+
65
+ minutes = seconds / 60
66
+ return "#{minutes}m" if minutes < 60
67
+
68
+ hours = minutes / 60
69
+ "#{hours}h"
70
+ end
71
+
72
+ state = fetch_state
73
+ sessions = state["sessions"] || []
74
+
75
+ if sessions.empty?
76
+ system("notify-send", "ZillaCore", "No active agent sessions")
77
+ exit 0
78
+ end
79
+
80
+ # If only one session, open it directly
81
+ if sessions.size == 1
82
+ log_file = sessions[0]["log_file"]
83
+ exec("alacritty", "-e", "tail", "-f", log_file) if log_file
84
+ exit 0
85
+ end
86
+
87
+ # Multiple sessions: use fzf if available, otherwise just open the first one
88
+ if system("which fzf > /dev/null 2>&1")
89
+ # Build fzf menu
90
+ options = sessions.map do |s|
91
+ agent = s["agent"]
92
+ elapsed = format_elapsed(s["elapsed_seconds"])
93
+
94
+ card_key = s["card_key"]
95
+ context = if card_key.start_with?("discord-")
96
+ "Discord chat"
97
+ elsif card_key.start_with?("card-")
98
+ card_key.split("-")[1]
99
+ else
100
+ card_key
101
+ end
102
+
103
+ emoji = AGENTS[agent.downcase] || DEFAULT_EMOJI
104
+
105
+ "#{emoji} #{agent}: #{context} (#{elapsed})|#{s["log_file"]}"
106
+ end
107
+
108
+ menu_text = options.join("\n")
109
+ selected = `echo "#{menu_text}" | fzf --prompt="Agent Logs: "`.strip
110
+
111
+ unless selected.empty?
112
+ log_file = selected.split("|").last
113
+ exec("alacritty", "-e", "tail", "-f", log_file)
114
+ end
115
+ else
116
+ # No menu system, just open the first log
117
+ log_file = sessions[0]["log_file"]
118
+ exec("alacritty", "-e", "tail", "-f", log_file) if log_file
119
+ end
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore Waybar Config Updater
5
+ # Dynamically updates waybar config with per-agent modules
6
+
7
+ require "json"
8
+
9
+ WAYBAR_CONFIG = File.expand_path("~/.config/waybar/config.jsonc")
10
+ WAYBAR_SCRIPT = File.expand_path("~/Code/zillacore/monitor/waybar.rb")
11
+
12
+ def load_config
13
+ content = File.read(WAYBAR_CONFIG)
14
+ # Strip comments for JSON parsing
15
+ json_content = content.lines.reject { |line| line.strip.start_with?("//") }.join
16
+ JSON.parse(json_content)
17
+ end
18
+
19
+ def save_config(config)
20
+ File.write(WAYBAR_CONFIG, JSON.pretty_generate(config))
21
+ end
22
+
23
+ def zillacore_modules
24
+ output = `#{WAYBAR_SCRIPT} --config`
25
+ JSON.parse(output)
26
+ end
27
+
28
+ # Load current config
29
+ config = load_config
30
+
31
+ # Get dynamic ZillaCore modules
32
+ zillacore_data = zillacore_modules
33
+ modules = zillacore_data["modules"]
34
+ module_configs = zillacore_data["config"]
35
+
36
+ # Remove old zillacore modules and groups from modules-right
37
+ config["modules-right"].reject! { |m| m.to_s.start_with?("custom/zillacore") || m.to_s == "group/zillacore-agents" }
38
+
39
+ # Insert new modules at the beginning of modules-right
40
+ config["modules-right"] = modules + config["modules-right"]
41
+
42
+ # Remove old zillacore module configs and groups
43
+ config.each_key do |key|
44
+ config.delete(key) if key.to_s.start_with?("custom/zillacore") || key.to_s == "group/zillacore-agents"
45
+ end
46
+
47
+ # Add new module configs
48
+ module_configs.each do |name, cfg|
49
+ config[name] = cfg
50
+ end
51
+
52
+ # Save updated config
53
+ save_config(config)
54
+
55
+ # Reload waybar
56
+ system("killall", "-SIGUSR2", "waybar")
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ZillaCore Waybar Per-Environment Deploy Module
5
+ # Usage: waybar-deploy-env.rb <env_key>
6
+ # waybar-deploy-env.rb <env_key> --click
7
+ # waybar-deploy-env.rb <env_key> --deploy
8
+
9
+ require "json"
10
+ require "net/http"
11
+ require "shellwords"
12
+ require "uri"
13
+ require "time"
14
+
15
+ SERVER_URL = "http://localhost:4567"
16
+ RECENT_WINDOW = 30 * 60
17
+
18
+ env_key = ARGV.find { |a| !a.start_with?("--") }
19
+ unless env_key
20
+ puts({ text: "?", tooltip: "No env specified", class: "error" }.to_json)
21
+ exit
22
+ end
23
+
24
+ def fetch_deployments
25
+ uri = URI("#{SERVER_URL}/api/deployments")
26
+ response = Net::HTTP.get_response(uri)
27
+ JSON.parse(response.body)["deployments"] || []
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ def time_ago(iso_time)
33
+ return nil unless iso_time
34
+
35
+ seconds = (Time.now - Time.parse(iso_time)).to_i
36
+ return "#{seconds}s ago" if seconds < 60
37
+
38
+ minutes = seconds / 60
39
+ return "#{minutes}m ago" if minutes < 60
40
+
41
+ hours = minutes / 60
42
+ "#{hours}h ago"
43
+ end
44
+
45
+ def resize_deploy_terminal
46
+ # Shrink the deploy terminal to ~15% width after it tiles in at 50%
47
+ # Calculates resize delta from monitor width dynamically
48
+ script = "sleep 0.5 && " \
49
+ 'width=$(hyprctl monitors -j | ruby -rjson -e "puts JSON.parse(STDIN.read)[0][%q(width)]") && ' \
50
+ "delta=$(( (width / 2) - (width * 15 / 100) )) && " \
51
+ 'hyprctl --batch "dispatch focuswindow class:zillacore-deploy; dispatch resizeactive -${delta} 0"'
52
+ spawn("bash", "-c", script, %i[out err] => "/dev/null")
53
+ end
54
+
55
+ def handle_click(env_key, deployment)
56
+ return unless deployment
57
+
58
+ if deployment["last_deploy_status"] == "failed" && deployment["last_deploy_log"]
59
+ log = deployment["last_deploy_log"]
60
+ if File.exist?(log.to_s)
61
+ spawn("alacritty", "--class", "zillacore-deploy", "-e", "bash", "-c",
62
+ "echo '=== Deploy failure: #{deployment["label"] || env_key} ===' && echo && cat #{Shellwords.escape(log)} && echo && echo 'Press Enter to close...' && read",
63
+ %i[out err] => "/dev/null")
64
+ resize_deploy_terminal
65
+ return
66
+ end
67
+ end
68
+
69
+ url = deployment["url"]
70
+ spawn("xdg-open", url, %i[out err] => "/dev/null") if url
71
+ end
72
+
73
+ def handle_deploy(env_key, deployment)
74
+ return unless deployment
75
+
76
+ prefill = deployment["status"] == "occupied" && deployment["card_number"] ? deployment["card_number"].to_s : ""
77
+ card_number = `timeout 60 zenity --entry --title="Deploy to #{env_key}" --text="Fizzy card number:"#{unless prefill.empty?
78
+ " --entry-text=#{Shellwords.escape(prefill)}"
79
+ end} 2>/dev/null`.strip
80
+ return if card_number.empty?
81
+
82
+ matches = Dir.glob(File.expand_path("~/Code/*fizzy-#{card_number}-*/"))
83
+ worktree = matches.find { |d| File.directory?(d) }
84
+ unless worktree
85
+ `timeout 10 zenity --error --text="No worktree found for card ##{card_number}" 2>/dev/null`
86
+ return
87
+ end
88
+
89
+ # Resolve AWS_PROFILE from deployments config
90
+ aws_profile = nil
91
+ config_file = File.expand_path("~/.zillacore/deployments.json")
92
+ if File.exist?(config_file)
93
+ cfg = begin
94
+ JSON.parse(File.read(config_file))
95
+ rescue StandardError
96
+ {}
97
+ end
98
+ aws_profile = cfg.dig("environments", env_key, "aws_profile")
99
+ end
100
+
101
+ deploy_script = <<~BASH
102
+ cd #{Shellwords.escape(worktree)}
103
+ #{"export AWS_PROFILE=#{Shellwords.escape(aws_profile)}" if aws_profile}
104
+ echo "🚀 #{env_key} deploy in progress..."
105
+ echo
106
+ logfile=$(mktemp)
107
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
108
+ status=${PIPESTATUS[0]}
109
+ if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
110
+ echo
111
+ echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
112
+ echo
113
+ rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
114
+ (cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
115
+ echo
116
+ echo "🔄 Retrying deploy..."
117
+ echo
118
+ ./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
119
+ status=$?
120
+ fi
121
+ rm -f "$logfile"
122
+ echo
123
+ if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
124
+ echo "Press Enter to close..."
125
+ read
126
+ BASH
127
+
128
+ # Mark deploying via API so waybar turns orange immediately
129
+ begin
130
+ uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
131
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
132
+ req.body = { worktree: worktree }.to_json
133
+ Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
134
+ rescue StandardError
135
+ # Non-fatal — deploy proceeds even if server is unreachable
136
+ end
137
+
138
+ spawn("alacritty", "--class", "zillacore-deploy", "-e", "bash", "-c", deploy_script, %i[out err] => "/dev/null")
139
+ resize_deploy_terminal
140
+ end
141
+
142
+ def generate_output(env_key)
143
+ deployments = fetch_deployments
144
+ unless deployments
145
+ puts({ text: "", tooltip: "#{env_key}: server unreachable", class: "error" }.to_json)
146
+ return
147
+ end
148
+
149
+ d = deployments.find { |dep| dep["env"] == env_key }
150
+ unless d
151
+ puts({ text: "", tooltip: "#{env_key}: not configured", class: "error" }.to_json)
152
+ return
153
+ end
154
+
155
+ label = d["label"] || env_key
156
+
157
+ if d["status"] == "occupied"
158
+ deploy_time = d["last_deploy_at"] || d["deployed_at"]
159
+ recent = deploy_time && (Time.now - Time.parse(deploy_time)) < RECENT_WINDOW
160
+ status = d["last_deploy_status"]
161
+
162
+ if status == "deploying"
163
+ dot = '<span color="#ffaa00">●</span>'
164
+ css_class = "deploy-deploying"
165
+ elsif status == "failed"
166
+ dot = '<span color="#ff4444">●</span>'
167
+ css_class = "deploy-failed"
168
+ elsif recent && status == "success"
169
+ dot = '<span color="#4488ff">●</span>'
170
+ css_class = "deploy-recent"
171
+ else
172
+ dot = '<span color="#ff4444">●</span>'
173
+ css_class = "deploy-occupied"
174
+ end
175
+
176
+ card = d["card_number"] ? "##{d["card_number"]}" : d["branch"] || "unknown"
177
+ branch = d["branch"] ? " — #{d["branch"]}" : ""
178
+ ago = time_ago(d["deployed_at"])
179
+ status_icon = case status
180
+ when "deploying" then "🚀"
181
+ when "failed" then "💥"
182
+ when "success" then recent ? "🚀✅" : "🔴"
183
+ else "🔴"
184
+ end
185
+ tooltip = "#{status_icon} #{label}: #{card}#{branch}#{" (#{ago})" if ago}\nClick: open URL | Right-click: deploy"
186
+ else
187
+ dot = '<span color="#44ff44">●</span>'
188
+ css_class = "deploy-available"
189
+ ago = time_ago(d["cleared_at"])
190
+ last = d["last_card"] ? " (was ##{d["last_card"]})" : ""
191
+ tooltip = "🟢 #{label}: Available#{" #{ago}" if ago}#{last}\nRight-click: deploy"
192
+ end
193
+
194
+ puts({ text: dot, tooltip: tooltip, class: css_class }.to_json)
195
+ end
196
+
197
+ deployments = fetch_deployments
198
+ deployment = deployments&.find { |d| d["env"] == env_key }
199
+
200
+ if ARGV.include?("--click")
201
+ handle_click(env_key, deployment)
202
+ elsif ARGV.include?("--deploy")
203
+ handle_deploy(env_key, deployment)
204
+ else
205
+ generate_output(env_key)
206
+ end