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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Zoho Mail REST API client for fetching email content.
|
|
4
|
+
# Used when webhook payloads don't include the body (which is most of the time).
|
|
5
|
+
#
|
|
6
|
+
# Requires zoho.json to have:
|
|
7
|
+
# "api": {
|
|
8
|
+
# "client_id": "...",
|
|
9
|
+
# "client_secret": "...",
|
|
10
|
+
# "refresh_token": "...",
|
|
11
|
+
# "account_id": "..."
|
|
12
|
+
# }
|
|
13
|
+
|
|
14
|
+
ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
|
|
15
|
+
ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
|
|
16
|
+
|
|
17
|
+
# In-memory cache for access token (expires after ~55 min to be safe)
|
|
18
|
+
@zoho_access_token = nil
|
|
19
|
+
@zoho_token_expires_at = Time.at(0)
|
|
20
|
+
|
|
21
|
+
def zoho_api_configured?
|
|
22
|
+
api = ZOHO_CONFIG["api"]
|
|
23
|
+
api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def zoho_refresh_access_token!
|
|
27
|
+
api = ZOHO_CONFIG["api"]
|
|
28
|
+
uri = URI(ZOHO_TOKEN_URL)
|
|
29
|
+
res = Net::HTTP.post_form(uri, {
|
|
30
|
+
"grant_type" => "refresh_token",
|
|
31
|
+
"client_id" => api["client_id"],
|
|
32
|
+
"client_secret" => api["client_secret"],
|
|
33
|
+
"refresh_token" => api["refresh_token"]
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
data = JSON.parse(res.body)
|
|
37
|
+
if data["access_token"]
|
|
38
|
+
@zoho_access_token = data["access_token"]
|
|
39
|
+
@zoho_token_expires_at = Time.now + 3300 # ~55 min
|
|
40
|
+
LOG.info "[Zoho:API] Refreshed access token"
|
|
41
|
+
@zoho_access_token
|
|
42
|
+
else
|
|
43
|
+
LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
LOG.error "[Zoho:API] Token refresh error: #{e.message}"
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def zoho_access_token
|
|
52
|
+
return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at
|
|
53
|
+
|
|
54
|
+
zoho_refresh_access_token!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Fetch email content by messageId using the "original message" endpoint.
|
|
58
|
+
# This endpoint doesn't require a folder ID — just accountId + messageId.
|
|
59
|
+
# Returns plain-text body extracted from the MIME content, or nil.
|
|
60
|
+
def fetch_zoho_email_content(message_id)
|
|
61
|
+
return nil unless zoho_api_configured?
|
|
62
|
+
|
|
63
|
+
token = zoho_access_token
|
|
64
|
+
return nil unless token
|
|
65
|
+
|
|
66
|
+
account_id = ZOHO_CONFIG.dig("api", "account_id")
|
|
67
|
+
uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
|
|
68
|
+
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = true
|
|
71
|
+
req = Net::HTTP::Get.new(uri)
|
|
72
|
+
req["Authorization"] = "Zoho-oauthtoken #{token}"
|
|
73
|
+
req["Accept"] = "application/json"
|
|
74
|
+
|
|
75
|
+
res = http.request(req)
|
|
76
|
+
data = JSON.parse(res.body)
|
|
77
|
+
|
|
78
|
+
if data.dig("status", "code") == 200
|
|
79
|
+
raw_mime = data.dig("data", "content").to_s
|
|
80
|
+
text = extract_text_from_mime(raw_mime)
|
|
81
|
+
LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
|
|
82
|
+
text
|
|
83
|
+
else
|
|
84
|
+
LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
LOG.error "[Zoho:API] Error fetching content: #{e.message}"
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Extract readable text from raw MIME content.
|
|
93
|
+
# Prefers text/plain part; falls back to stripping HTML from text/html part.
|
|
94
|
+
def extract_text_from_mime(mime)
|
|
95
|
+
# Try to find text/plain part
|
|
96
|
+
if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
97
|
+
return Regexp.last_match(1).gsub("\r\n", "\n").strip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Fallback: extract text/html part and strip tags
|
|
101
|
+
if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
102
|
+
html = Regexp.last_match(1).gsub("\r\n", "\n")
|
|
103
|
+
return html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/&/i, "&")
|
|
104
|
+
.gsub(/</i, "<").gsub(/>/i, ">").gsub(/\s+/, " ").strip
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Last resort: strip all HTML-ish content from the whole thing
|
|
108
|
+
mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
|
|
109
|
+
end
|
data/lib/zillacore.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require_relative "zillacore/config"
|
|
2
|
+
require_relative "zillacore/users"
|
|
3
|
+
require_relative "zillacore/agents"
|
|
4
|
+
require_relative "zillacore/brain"
|
|
5
|
+
require_relative "zillacore/skills"
|
|
6
|
+
require_relative "zillacore/sessions"
|
|
7
|
+
require_relative "zillacore/prompts"
|
|
8
|
+
require_relative "zillacore/planning"
|
|
9
|
+
require_relative "zillacore/helpers"
|
|
10
|
+
require_relative "zillacore/cron"
|
data/monitor/daemon.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore Monitor Daemon
|
|
5
|
+
# Polls /api/status and exposes agent state via Unix socket for waybar
|
|
6
|
+
# No longer triggers config updates - waybar module polls this socket directly
|
|
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
|
+
POLL_INTERVAL = 2 # seconds
|
|
15
|
+
CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
|
|
16
|
+
|
|
17
|
+
# Load agent configuration from JSON
|
|
18
|
+
def load_agent_config
|
|
19
|
+
config = JSON.parse(File.read(CONFIG_PATH))
|
|
20
|
+
agents = {}
|
|
21
|
+
config["agents"].each do |agent|
|
|
22
|
+
agents[agent["name"]] = { color: agent["color"], emoji: agent["emoji"] }
|
|
23
|
+
end
|
|
24
|
+
agents
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
warn "Failed to load waybar.json: #{e.message}"
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
AGENTS = load_agent_config.freeze
|
|
31
|
+
|
|
32
|
+
@state = { sessions: [], count: 0, recent: [], last_update: nil }
|
|
33
|
+
|
|
34
|
+
def fetch_status
|
|
35
|
+
uri = URI(API_URL)
|
|
36
|
+
response = Net::HTTP.get_response(uri)
|
|
37
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
|
|
39
|
+
JSON.parse(response.body)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
warn "Failed to fetch status: #{e.message}"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_state
|
|
46
|
+
data = fetch_status
|
|
47
|
+
return unless data
|
|
48
|
+
|
|
49
|
+
@state = {
|
|
50
|
+
sessions: data["sessions"],
|
|
51
|
+
count: data["count"],
|
|
52
|
+
recent: data["recent"] || [],
|
|
53
|
+
last_update: Time.now.to_i
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handle_client(client)
|
|
58
|
+
client.puts @state.to_json
|
|
59
|
+
client.close
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
warn "Error handling client: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start_server
|
|
65
|
+
FileUtils.rm_f(SOCKET_PATH)
|
|
66
|
+
|
|
67
|
+
server = UNIXServer.new(SOCKET_PATH)
|
|
68
|
+
File.chmod(0o666, SOCKET_PATH)
|
|
69
|
+
|
|
70
|
+
# Write PID file
|
|
71
|
+
File.write("/tmp/zillacore-daemon.pid", Process.pid)
|
|
72
|
+
|
|
73
|
+
puts "Monitor daemon started, socket: #{SOCKET_PATH}"
|
|
74
|
+
|
|
75
|
+
# Start polling thread
|
|
76
|
+
poller = Thread.new do
|
|
77
|
+
loop do
|
|
78
|
+
update_state
|
|
79
|
+
sleep POLL_INTERVAL
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Initial state fetch
|
|
84
|
+
update_state
|
|
85
|
+
|
|
86
|
+
# Accept client connections
|
|
87
|
+
loop do
|
|
88
|
+
client = server.accept
|
|
89
|
+
Thread.new { handle_client(client) }
|
|
90
|
+
end
|
|
91
|
+
rescue Interrupt
|
|
92
|
+
puts "\nShutting down..."
|
|
93
|
+
poller&.kill
|
|
94
|
+
FileUtils.rm_f(SOCKET_PATH)
|
|
95
|
+
FileUtils.rm_f("/tmp/zillacore-daemon.pid")
|
|
96
|
+
exit 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
start_server
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore macOS Deploy Action
|
|
5
|
+
# Prompts for a Fizzy card number via osascript, finds the worktree, and deploys.
|
|
6
|
+
# Usage: deploy-env-macos.rb <env_key>
|
|
7
|
+
|
|
8
|
+
require "English"
|
|
9
|
+
require "json"
|
|
10
|
+
require "net/http"
|
|
11
|
+
require "shellwords"
|
|
12
|
+
require "uri"
|
|
13
|
+
|
|
14
|
+
SERVER_URL = "http://localhost:4567"
|
|
15
|
+
|
|
16
|
+
env_key = ARGV[0]
|
|
17
|
+
exit unless env_key
|
|
18
|
+
|
|
19
|
+
def fetch_deployments
|
|
20
|
+
uri = URI("#{SERVER_URL}/api/deployments")
|
|
21
|
+
response = Net::HTTP.get_response(uri)
|
|
22
|
+
JSON.parse(response.body)["deployments"] || []
|
|
23
|
+
rescue StandardError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
deployments = fetch_deployments
|
|
28
|
+
deployment = deployments&.find { |d| d["env"] == env_key }
|
|
29
|
+
|
|
30
|
+
prefill = deployment && deployment["status"] == "occupied" && deployment["card_number"] ? deployment["card_number"].to_s : ""
|
|
31
|
+
|
|
32
|
+
# Prompt via AppleScript
|
|
33
|
+
prompt_script = <<~APPLESCRIPT
|
|
34
|
+
set defaultAnswer to "#{prefill}"
|
|
35
|
+
set dialogResult to display dialog "Fizzy card number:" default answer defaultAnswer with title "Deploy to #{env_key}" buttons {"Cancel", "Deploy"} default button "Deploy"
|
|
36
|
+
return text returned of dialogResult
|
|
37
|
+
APPLESCRIPT
|
|
38
|
+
|
|
39
|
+
card_number = `osascript -e #{Shellwords.escape(prompt_script)} 2>/dev/null`.strip
|
|
40
|
+
exit if card_number.empty?
|
|
41
|
+
|
|
42
|
+
# Find worktree via card_map.json
|
|
43
|
+
card_map_path = File.expand_path("~/.zillacore/card_map.json")
|
|
44
|
+
worktree = nil
|
|
45
|
+
if File.exist?(card_map_path)
|
|
46
|
+
card_map = begin
|
|
47
|
+
JSON.parse(File.read(card_map_path))
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
entry = card_map.values.find { |e| e["card_number"].to_s == card_number }
|
|
52
|
+
worktree = entry["worktree"] if entry && entry["worktree"] && File.directory?(entry["worktree"].to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fallback: glob for worktree directories
|
|
56
|
+
unless worktree
|
|
57
|
+
matches = Dir.glob(File.expand_path("~/projects/sogholdings/*fizzy-#{card_number}-*/"))
|
|
58
|
+
worktree = matches.find { |d| File.directory?(d) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless worktree
|
|
62
|
+
system("osascript", "-e",
|
|
63
|
+
"display dialog \"No worktree found for card ##{card_number}\" buttons {\"OK\"} default button \"OK\" with title \"Deploy Failed\" with icon stop")
|
|
64
|
+
exit
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve AWS_PROFILE
|
|
68
|
+
config_file = File.expand_path("~/.zillacore/deployments.json")
|
|
69
|
+
aws_profile = nil
|
|
70
|
+
if File.exist?(config_file)
|
|
71
|
+
cfg = begin
|
|
72
|
+
JSON.parse(File.read(config_file))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
{}
|
|
75
|
+
end
|
|
76
|
+
aws_profile = cfg.dig("environments", env_key, "aws_profile")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Mark deploying
|
|
80
|
+
begin
|
|
81
|
+
uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
|
|
82
|
+
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
|
|
83
|
+
req.body = { worktree: worktree }.to_json
|
|
84
|
+
Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
|
|
85
|
+
rescue StandardError
|
|
86
|
+
# Non-fatal
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Build deploy script
|
|
90
|
+
deploy_script = <<~BASH
|
|
91
|
+
cd #{Shellwords.escape(worktree)}
|
|
92
|
+
#{"export AWS_PROFILE=#{Shellwords.escape(aws_profile)}" if aws_profile}
|
|
93
|
+
echo "🚀 Deploying card ##{card_number} to #{env_key}..."
|
|
94
|
+
echo " Worktree: #{worktree}"
|
|
95
|
+
echo
|
|
96
|
+
logfile=$(mktemp)
|
|
97
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
|
|
98
|
+
status=${PIPESTATUS[0]}
|
|
99
|
+
if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
|
|
100
|
+
echo
|
|
101
|
+
echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
|
|
102
|
+
echo
|
|
103
|
+
rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
|
|
104
|
+
(cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
|
|
105
|
+
echo
|
|
106
|
+
echo "🔄 Retrying deploy..."
|
|
107
|
+
echo
|
|
108
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
|
|
109
|
+
status=$?
|
|
110
|
+
fi
|
|
111
|
+
rm -f "$logfile"
|
|
112
|
+
echo
|
|
113
|
+
if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
|
|
114
|
+
echo
|
|
115
|
+
echo "Press any key to close..."
|
|
116
|
+
read -n 1
|
|
117
|
+
BASH
|
|
118
|
+
|
|
119
|
+
# Write to temp file and run in Terminal.app
|
|
120
|
+
script_file = "/tmp/zillacore-deploy-#{env_key}-#{$PROCESS_ID}.sh"
|
|
121
|
+
File.write(script_file, deploy_script)
|
|
122
|
+
File.chmod(0o755, script_file)
|
|
123
|
+
|
|
124
|
+
terminal_script = <<~APPLESCRIPT
|
|
125
|
+
tell application "Terminal"
|
|
126
|
+
activate
|
|
127
|
+
do script "#{script_file.gsub('"', '\\"')}; rm -f #{script_file.gsub('"', '\\"')}"
|
|
128
|
+
end tell
|
|
129
|
+
APPLESCRIPT
|
|
130
|
+
|
|
131
|
+
system("osascript", "-e", terminal_script)
|
data/monitor/menubar.rb
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ZillaCore macOS Menu Bar Plugin (xbar/SwiftBar)
|
|
5
|
+
# Reads from monitor daemon socket and outputs xbar-format text
|
|
6
|
+
# Mirrors monitor/waybar.rb patterns for macOS-native display
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "net/http"
|
|
10
|
+
require "shellwords"
|
|
11
|
+
require "socket"
|
|
12
|
+
require "time"
|
|
13
|
+
require "uri"
|
|
14
|
+
|
|
15
|
+
SERVER_URL = "http://localhost:4567"
|
|
16
|
+
SOCKET_PATH = "/tmp/zillacore-monitor.sock"
|
|
17
|
+
CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
|
|
18
|
+
DEFAULT_EMOJI = "❓"
|
|
19
|
+
SELF_PATH = File.realpath(__FILE__)
|
|
20
|
+
|
|
21
|
+
def load_config
|
|
22
|
+
JSON.parse(File.read(CONFIG_PATH))
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "Failed to load waybar.json: #{e.message}"
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
CONFIG = load_config.freeze
|
|
29
|
+
|
|
30
|
+
def load_agent_config
|
|
31
|
+
agents = {}
|
|
32
|
+
(CONFIG["agents"] || []).each do |agent|
|
|
33
|
+
agents[agent["name"].downcase] = { emoji: agent["emoji"], color: agent["color"] }
|
|
34
|
+
end
|
|
35
|
+
agents
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
AGENTS = load_agent_config.freeze
|
|
39
|
+
FIZZY_ACCOUNT_ID = CONFIG["fizzy_account_id"]
|
|
40
|
+
DISCORD_GUILD_ID = CONFIG["discord_guild_id"]
|
|
41
|
+
|
|
42
|
+
def fetch_state
|
|
43
|
+
socket = UNIXSocket.new(SOCKET_PATH)
|
|
44
|
+
data = socket.read
|
|
45
|
+
socket.close
|
|
46
|
+
JSON.parse(data)
|
|
47
|
+
rescue Errno::ENOENT
|
|
48
|
+
{ "sessions" => [], "count" => 0, "recent" => [], "error" => "daemon not running" }
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
{ "sessions" => [], "count" => 0, "recent" => [], "error" => e.message }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch_deployments
|
|
54
|
+
uri = URI("#{SERVER_URL}/api/deployments")
|
|
55
|
+
response = Net::HTTP.get_response(uri)
|
|
56
|
+
JSON.parse(response.body)["deployments"] || []
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
DEPLOY_RECENT_WINDOW = 30 * 60
|
|
62
|
+
|
|
63
|
+
def deploy_dot(dep)
|
|
64
|
+
status = dep["last_deploy_status"]
|
|
65
|
+
if status == "deploying"
|
|
66
|
+
"🟠"
|
|
67
|
+
elsif status == "failed"
|
|
68
|
+
"💥"
|
|
69
|
+
elsif dep["status"] == "occupied"
|
|
70
|
+
deploy_time = dep["last_deploy_at"] || dep["deployed_at"]
|
|
71
|
+
recent = deploy_time && (Time.now - Time.parse(deploy_time)) < DEPLOY_RECENT_WINDOW
|
|
72
|
+
recent ? "🚀" : "🔴"
|
|
73
|
+
else
|
|
74
|
+
"🟢"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
LOG_VIEWER_PATH = File.join(File.dirname(SELF_PATH), "view-logs-macos.rb")
|
|
79
|
+
DEPLOY_SCRIPT_PATH = File.join(File.dirname(SELF_PATH), "deploy-env-macos.rb")
|
|
80
|
+
|
|
81
|
+
ANSI_REGEX = /\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/
|
|
82
|
+
LOG_PREVIEW_LINES = 15
|
|
83
|
+
LOG_LINE_MAX = 80
|
|
84
|
+
LOG_FONT = "SFMono-Regular"
|
|
85
|
+
LOG_SIZE = 12
|
|
86
|
+
|
|
87
|
+
def tail_log(log_file, lines: LOG_PREVIEW_LINES)
|
|
88
|
+
return [] unless log_file && File.exist?(log_file)
|
|
89
|
+
|
|
90
|
+
raw = `tail -n 50 #{log_file.shellescape} 2>/dev/null`
|
|
91
|
+
raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
92
|
+
.lines
|
|
93
|
+
.map { |l| l.gsub(ANSI_REGEX, "").gsub(/[^[:print:]\t]/, "").strip }
|
|
94
|
+
.reject(&:empty?)
|
|
95
|
+
.last(lines)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_log_line(text)
|
|
101
|
+
text.length > LOG_LINE_MAX ? "#{text[0, LOG_LINE_MAX]}…" : text
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_elapsed(seconds)
|
|
105
|
+
return "#{seconds}s" if seconds < 60
|
|
106
|
+
|
|
107
|
+
minutes = seconds / 60
|
|
108
|
+
return "#{minutes}m" if minutes < 60
|
|
109
|
+
|
|
110
|
+
"#{minutes / 60}h"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def format_context(card_key)
|
|
114
|
+
return "" unless card_key
|
|
115
|
+
|
|
116
|
+
if card_key.start_with?("discord-")
|
|
117
|
+
"Discord"
|
|
118
|
+
elsif card_key.start_with?("card-")
|
|
119
|
+
"##{card_key.split("-")[1]}"
|
|
120
|
+
else
|
|
121
|
+
card_key
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def time_ago(iso_string)
|
|
126
|
+
return nil unless iso_string
|
|
127
|
+
|
|
128
|
+
seconds = (Time.now - Time.parse(iso_string)).to_i
|
|
129
|
+
"#{format_elapsed(seconds)} ago"
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def log_action(log_file)
|
|
135
|
+
return "" unless log_file
|
|
136
|
+
|
|
137
|
+
" | shell=#{LOG_VIEWER_PATH} param1=#{log_file} terminal=false refresh=false"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
OPEN_SCRIPT = File.join(File.dirname(SELF_PATH), "open-action.sh")
|
|
141
|
+
|
|
142
|
+
def full_log_action(log_file)
|
|
143
|
+
return "" unless log_file
|
|
144
|
+
|
|
145
|
+
" | shell=#{OPEN_SCRIPT} param1=#{log_file.shellescape} terminal=false refresh=false"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def prompt_url(card_key)
|
|
149
|
+
return nil unless card_key
|
|
150
|
+
|
|
151
|
+
if card_key.start_with?("card-")
|
|
152
|
+
card_num = card_key.split("-")[1]
|
|
153
|
+
"https://app.fizzy.do/#{FIZZY_ACCOUNT_ID}/cards/#{card_num}" if FIZZY_ACCOUNT_ID && card_num
|
|
154
|
+
elsif card_key.start_with?("discord-") && DISCORD_GUILD_ID
|
|
155
|
+
parts = card_key.split("-")
|
|
156
|
+
# discord-AGENT-CHANNEL_ID-MESSAGE_ID (agent name may contain hyphens, IDs are last two numeric parts)
|
|
157
|
+
channel_id = parts[-2]
|
|
158
|
+
message_id = parts[-1]
|
|
159
|
+
"https://discord.com/channels/#{DISCORD_GUILD_ID}/#{channel_id}/#{message_id}" if channel_id && message_id
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def prompt_action(card_key)
|
|
164
|
+
url = prompt_url(card_key)
|
|
165
|
+
return "" unless url
|
|
166
|
+
|
|
167
|
+
" | shell=#{OPEN_SCRIPT} param1=#{url} terminal=false refresh=false"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def worktree_path(log_file, card_key)
|
|
171
|
+
return nil unless log_file && card_key&.start_with?("card-")
|
|
172
|
+
|
|
173
|
+
dir = File.dirname(log_file, 2)
|
|
174
|
+
dir if File.directory?(dir) && dir != "/"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def worktree_action(log_file, card_key)
|
|
178
|
+
path = worktree_path(log_file, card_key)
|
|
179
|
+
return "" unless path
|
|
180
|
+
|
|
181
|
+
" | shell=#{OPEN_SCRIPT} param1=#{path.shellescape} terminal=false refresh=false"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
COLOR_MAP = {
|
|
185
|
+
"red" => "#ff5555", "green" => "#50fa7b", "blue" => "#8be9fd",
|
|
186
|
+
"yellow" => "#f1fa8c", "cyan" => "#8be9fd", "magenta" => "#ff79c6",
|
|
187
|
+
"purple" => "#bd93f9", "pink" => "#ff79c6", "white" => "#f8f8f2"
|
|
188
|
+
}.freeze
|
|
189
|
+
|
|
190
|
+
def hex_color(name)
|
|
191
|
+
COLOR_MAP[name] || name
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def generate_output
|
|
195
|
+
state = fetch_state
|
|
196
|
+
deployments = fetch_deployments
|
|
197
|
+
|
|
198
|
+
return ["⚠️", "---", state["error"], "---", "Refresh | refresh=true"].join("\n") if state["error"] && !deployments
|
|
199
|
+
|
|
200
|
+
sessions = state["sessions"] || []
|
|
201
|
+
recent = state["recent"] || []
|
|
202
|
+
lines = []
|
|
203
|
+
|
|
204
|
+
# Title line — agent emojis + deploy dots
|
|
205
|
+
parts = []
|
|
206
|
+
parts << sessions.map { |s| AGENTS.dig(s["agent"]&.downcase, :emoji) || DEFAULT_EMOJI }.join(" ") if sessions.any?
|
|
207
|
+
parts << deployments.map { |d| deploy_dot(d) }.join if deployments&.any?
|
|
208
|
+
title = parts.any? ? parts.join(" ") : "💤"
|
|
209
|
+
lines << title
|
|
210
|
+
lines << "---"
|
|
211
|
+
|
|
212
|
+
# Active sessions
|
|
213
|
+
if sessions.any?
|
|
214
|
+
lines << "Active | size=12"
|
|
215
|
+
sessions.each do |s|
|
|
216
|
+
agent_key = (s["agent"] || "").downcase
|
|
217
|
+
emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
|
|
218
|
+
color = AGENTS.dig(agent_key, :color)
|
|
219
|
+
color_str = color ? " color=#{hex_color(color)}" : ""
|
|
220
|
+
context = format_context(s["card_key"])
|
|
221
|
+
elapsed = format_elapsed(s["elapsed_seconds"] || 0)
|
|
222
|
+
lines << "#{emoji} #{s["agent"]}: #{context} (#{elapsed}) |#{color_str}"
|
|
223
|
+
|
|
224
|
+
tail_log(s["log_file"]).each do |line|
|
|
225
|
+
lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
|
|
226
|
+
end
|
|
227
|
+
lines << "-- ---" if s["log_file"]
|
|
228
|
+
lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
|
|
229
|
+
lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
|
|
230
|
+
lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
|
|
231
|
+
wt = worktree_path(s["log_file"], s["card_key"])
|
|
232
|
+
lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
lines << "No active sessions | size=12"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Recent completed sessions
|
|
239
|
+
if recent.any?
|
|
240
|
+
lines << "---"
|
|
241
|
+
lines << "Recent | size=12"
|
|
242
|
+
recent.each do |s|
|
|
243
|
+
agent_key = (s["agent"] || "").downcase
|
|
244
|
+
emoji = AGENTS.dig(agent_key, :emoji) || DEFAULT_EMOJI
|
|
245
|
+
context = format_context(s["card_key"])
|
|
246
|
+
ago = time_ago(s["finished_at"]) || "?"
|
|
247
|
+
lines << "#{emoji} #{s["agent"]}: #{context} — #{ago}"
|
|
248
|
+
|
|
249
|
+
tail_log(s["log_file"]).each do |line|
|
|
250
|
+
lines << "-- #{format_log_line(line)} | font=#{LOG_FONT} size=#{LOG_SIZE}"
|
|
251
|
+
end
|
|
252
|
+
lines << "-- ---" if s["log_file"]
|
|
253
|
+
lines << "-- Tail Log#{log_action(s["log_file"])}" if s["log_file"]
|
|
254
|
+
lines << "-- View Full Log#{full_log_action(s["log_file"])}" if s["log_file"]
|
|
255
|
+
lines << "-- Open Prompt#{prompt_action(s["card_key"])}" unless prompt_url(s["card_key"]).nil?
|
|
256
|
+
wt = worktree_path(s["log_file"], s["card_key"])
|
|
257
|
+
lines << "-- Open Worktree#{worktree_action(s["log_file"], s["card_key"])}" if wt
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Deployments
|
|
262
|
+
if deployments&.any?
|
|
263
|
+
lines << "---"
|
|
264
|
+
lines << "Deployments | size=12"
|
|
265
|
+
deployments.each do |d|
|
|
266
|
+
label = d["label"] || d["env"]
|
|
267
|
+
env = d["env"]
|
|
268
|
+
dot = deploy_dot(d)
|
|
269
|
+
if d["status"] == "occupied"
|
|
270
|
+
card = d["card_number"] ? "##{d["card_number"]}" : d["branch"] || "unknown"
|
|
271
|
+
ago = time_ago(d["deployed_at"])
|
|
272
|
+
status_label = case d["last_deploy_status"]
|
|
273
|
+
when "deploying" then " — deploying…"
|
|
274
|
+
when "failed" then " — FAILED"
|
|
275
|
+
else ""
|
|
276
|
+
end
|
|
277
|
+
line = "#{dot} #{label}: #{card}#{status_label}#{" (#{ago})" if ago}"
|
|
278
|
+
url = d["url"]
|
|
279
|
+
lines << (url ? "#{line} | href=#{url}" : line)
|
|
280
|
+
else
|
|
281
|
+
ago = time_ago(d["cleared_at"])
|
|
282
|
+
last = d["last_card"] ? " (was ##{d["last_card"]})" : ""
|
|
283
|
+
lines << "#{dot} #{label}: Available#{" #{ago}" if ago}#{last}"
|
|
284
|
+
end
|
|
285
|
+
lines << "-- Deploy to #{label} | shell=#{DEPLOY_SCRIPT_PATH} param1=#{env} terminal=false refresh=true"
|
|
286
|
+
lines << "-- Open #{label} | shell=#{OPEN_SCRIPT} param1=#{d["url"]} terminal=false refresh=false" if d["status"] == "occupied" && d["url"]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
lines << "---"
|
|
291
|
+
lines << "Refresh | refresh=true"
|
|
292
|
+
lines.join("\n")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
puts generate_output
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Opens a URL in browser, a directory in Finder, or a file in the default viewer.
|
|
3
|
+
# Used by the SwiftBar menubar plugin for prompt links, worktrees, and full log views.
|
|
4
|
+
|
|
5
|
+
arg="$1"
|
|
6
|
+
|
|
7
|
+
if [[ "$arg" == https://discord.com/* ]]; then
|
|
8
|
+
open "discord://${arg#https://}"
|
|
9
|
+
elif [[ "$arg" == http* ]]; then
|
|
10
|
+
open "$arg"
|
|
11
|
+
elif [[ -d "$arg" ]]; then
|
|
12
|
+
open -a Kiro "$arg"
|
|
13
|
+
elif [[ -f "$arg" ]]; then
|
|
14
|
+
open -a "Console" "$arg" 2>/dev/null || open "$arg"
|
|
15
|
+
fi
|