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
data/bin/zillacore ADDED
@@ -0,0 +1,1521 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "fileutils"
6
+ require "open3"
7
+ require "net/http"
8
+ require "uri"
9
+ require "time"
10
+
11
+ # ZillaCore CLI - Register and manage projects with ZillaCore server
12
+
13
+ ZILLACORE_ROOT = File.expand_path("..", __dir__)
14
+ require_relative "../lib/zillacore/version"
15
+ ZILLACORE_VERSION = ZillaCore::VERSION
16
+
17
+ ZILLACORE_DIR = File.join(Dir.home, ".zillacore")
18
+ PROJECTS_FILE = File.join(ZILLACORE_DIR, "projects.json")
19
+ CONFIG_FILE = File.join(ZILLACORE_DIR, "config.json")
20
+ PID_FILE = File.join(ZILLACORE_DIR, "server.pid")
21
+
22
+ def ensure_zillacore_dir
23
+ FileUtils.mkdir_p(ZILLACORE_DIR)
24
+ end
25
+
26
+ def load_projects
27
+ return {} unless File.exist?(PROJECTS_FILE)
28
+
29
+ JSON.parse(File.read(PROJECTS_FILE))
30
+ rescue JSON::ParserError
31
+ {}
32
+ end
33
+
34
+ def save_projects(projects)
35
+ ensure_zillacore_dir
36
+ File.write(PROJECTS_FILE, JSON.pretty_generate(projects))
37
+ end
38
+
39
+ def load_config
40
+ return {} unless File.exist?(CONFIG_FILE)
41
+
42
+ JSON.parse(File.read(CONFIG_FILE))
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+
47
+ def save_config(config)
48
+ ensure_zillacore_dir
49
+ File.write(CONFIG_FILE, JSON.pretty_generate(config))
50
+ end
51
+
52
+ def find_server_pid
53
+ # First check PID file
54
+ if File.exist?(PID_FILE)
55
+ pid = File.read(PID_FILE).strip.to_i
56
+ return pid if pid.positive? && process_running?(pid)
57
+
58
+ # Stale PID file
59
+ File.delete(PID_FILE)
60
+
61
+ end
62
+
63
+ # Fallback: search for running receiver.rb process
64
+ # Try multiple methods to find the process
65
+
66
+ # Method 1: pgrep with full pattern
67
+ output, status = Open3.capture2("pgrep", "-f", 'ruby.*receiver\.rb')
68
+ if status.success? && !output.strip.empty?
69
+ pids = output.strip.split("\n").map(&:to_i)
70
+ return pids.first if pids.any?
71
+ end
72
+
73
+ # Method 2: ps + grep (more portable)
74
+ output, status = Open3.capture2("ps", "aux")
75
+ if status.success?
76
+ output.each_line do |line|
77
+ next unless line.include?("ruby") && line.include?("receiver.rb") && !line.include?("grep")
78
+
79
+ # Extract PID (second column in ps aux output)
80
+ parts = line.split
81
+ pid = parts[1].to_i
82
+ return pid if pid.positive? && process_running?(pid)
83
+ end
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ def process_running?(pid)
90
+ Process.kill(0, pid)
91
+ true
92
+ rescue Errno::ESRCH, Errno::EPERM
93
+ false
94
+ end
95
+
96
+ def fetch_active_sessions
97
+ uri = URI("http://localhost:4567/api/status")
98
+ response = Net::HTTP.get_response(uri)
99
+ return [] unless response.is_a?(Net::HTTPSuccess)
100
+
101
+ data = JSON.parse(response.body)
102
+ (data["sessions"] || data).select { |s| s["alive"] }
103
+ rescue StandardError
104
+ []
105
+ end
106
+
107
+ def wait_for_agents_to_finish
108
+ sessions = fetch_active_sessions
109
+ return true if sessions.empty?
110
+
111
+ agents = sessions.map { |s| s["agent"] || "Unknown" }
112
+ puts "⏳ Waiting for #{sessions.size} active agent(s) to finish: #{agents.join(", ")}"
113
+ puts " (Ctrl+C to restart immediately)"
114
+
115
+ begin
116
+ loop do
117
+ sleep 5
118
+ sessions = fetch_active_sessions
119
+ if sessions.empty?
120
+ puts "✓ All agents finished."
121
+ return true
122
+ end
123
+ agents = sessions.map { |s| s["agent"] || "Unknown" }
124
+ elapsed = sessions.map { |s| s["elapsed_seconds"] || 0 }.max
125
+ puts " Still waiting... #{sessions.size} agent(s) active: #{agents.join(", ")} (#{elapsed}s)"
126
+ end
127
+ rescue Interrupt
128
+ puts "\n⚡ Skipping wait, restarting now..."
129
+ true
130
+ end
131
+ end
132
+
133
+ def stop_server
134
+ pid = find_server_pid
135
+
136
+ unless pid
137
+ puts "No running ZillaCore server found."
138
+ return false
139
+ end
140
+
141
+ puts "Stopping ZillaCore server (PID: #{pid})..."
142
+
143
+ # Also stop the monitor daemon
144
+ daemon_pid_file = "/tmp/zillacore-daemon.pid"
145
+ if File.exist?(daemon_pid_file)
146
+ daemon_pid = File.read(daemon_pid_file).strip.to_i
147
+ begin
148
+ Process.kill("TERM", daemon_pid)
149
+ puts "✓ Monitor daemon stopped (PID: #{daemon_pid})."
150
+ rescue Errno::ESRCH
151
+ # Already dead
152
+ end
153
+ File.delete(daemon_pid_file)
154
+ end
155
+
156
+ begin
157
+ Process.kill("TERM", pid)
158
+
159
+ # Wait up to 5 seconds for graceful shutdown
160
+ 5.times do
161
+ sleep 2
162
+ next if process_running?(pid)
163
+
164
+ puts "✓ Server stopped."
165
+ FileUtils.rm_f(PID_FILE)
166
+ return true
167
+ end
168
+
169
+ # Force kill if still running
170
+ puts "Server didn't stop gracefully, forcing..."
171
+ Process.kill("KILL", pid)
172
+ sleep 1
173
+
174
+ FileUtils.rm_f(PID_FILE)
175
+ puts "✓ Server stopped (forced)."
176
+ true
177
+ rescue Errno::ESRCH
178
+ puts "✓ Server already stopped."
179
+ FileUtils.rm_f(PID_FILE)
180
+ true
181
+ rescue Errno::EPERM
182
+ puts "Error: Permission denied. Try running with sudo or check process ownership."
183
+ false
184
+ rescue StandardError => e
185
+ puts "Error stopping server: #{e.message}"
186
+ false
187
+ end
188
+ end
189
+
190
+ def start_server(daemon: false)
191
+ # Resolve the real path of the zillacore script (follows symlinks)
192
+ receiver_path = File.join(ZILLACORE_ROOT, "receiver.rb")
193
+ receiver_dir = ZILLACORE_ROOT
194
+
195
+ unless File.exist?(receiver_path)
196
+ puts "Error: receiver.rb not found at #{receiver_path}"
197
+ exit 1
198
+ end
199
+
200
+ # Check if already running
201
+ if find_server_pid
202
+ puts "Error: ZillaCore server is already running."
203
+ puts "Run 'zillacore stop' or 'zillacore restart' to manage it."
204
+ exit 1
205
+ end
206
+
207
+ if daemon
208
+ # Daemon mode: start in background (like the old behavior)
209
+ log_dir = File.join(receiver_dir, "tmp")
210
+ FileUtils.mkdir_p(log_dir)
211
+ log_file = File.join(log_dir, "zillacore-server.log")
212
+
213
+ puts "Starting ZillaCore server (daemon)..."
214
+
215
+ pid = spawn("ruby", receiver_path,
216
+ chdir: receiver_dir,
217
+ out: log_file,
218
+ err: %i[child out])
219
+ Process.detach(pid)
220
+
221
+ ensure_zillacore_dir
222
+ File.write(PID_FILE, pid.to_s)
223
+ File.write(File.join(ZILLACORE_DIR, "server.root"), receiver_dir)
224
+
225
+ puts "✓ Server started in background (PID: #{pid})"
226
+ puts " Logs: tail -f #{log_file}"
227
+ puts " Stop: zillacore stop"
228
+ puts " Restart: zillacore restart"
229
+ else
230
+ # Foreground mode (default): run in the current process like `rails server`
231
+ puts "Starting ZillaCore server (pid: #{Process.pid})..."
232
+ puts " Stop: Ctrl+C"
233
+ puts ""
234
+
235
+ ensure_zillacore_dir
236
+ File.write(PID_FILE, Process.pid.to_s)
237
+ File.write(File.join(ZILLACORE_DIR, "server.root"), receiver_dir)
238
+
239
+ begin
240
+ Dir.chdir(receiver_dir) do
241
+ exec("ruby", receiver_path)
242
+ end
243
+ rescue Interrupt
244
+ puts "\nStopping server..."
245
+ ensure
246
+ FileUtils.rm_f(PID_FILE)
247
+ end
248
+ end
249
+ end
250
+
251
+ def detect_project_info(path)
252
+ path = File.expand_path(path)
253
+
254
+ # Get git remote URL to extract repo name
255
+ github_repo = nil
256
+ if Dir.exist?(File.join(path, ".git"))
257
+ remote_url, _, status = Open3.capture3("git", "remote", "get-url", "origin", chdir: path)
258
+ if status.success?
259
+ # Parse GitHub repo from URL (supports both HTTPS and SSH)
260
+ if remote_url =~ %r{github\.com[/:](.+?)\.git}
261
+ github_repo = Regexp.last_match(1)
262
+ elsif remote_url =~ %r{github\.com[/:](.+)}
263
+ github_repo = Regexp.last_match(1).chomp
264
+ end
265
+ end
266
+ end
267
+
268
+ # Suggest project key from directory name
269
+ project_key = File.basename(path).downcase.gsub(/[^a-z0-9-]/, "-")
270
+
271
+ {
272
+ "repo_path" => path,
273
+ "github_repo" => github_repo,
274
+ "project_key" => project_key
275
+ }
276
+ end
277
+
278
+ def register_project(options)
279
+ path = options[:path] || Dir.pwd
280
+ info = detect_project_info(path)
281
+
282
+ projects = load_projects
283
+
284
+ # Interactive prompts
285
+ puts "Registering project from: #{info["repo_path"]}"
286
+ puts
287
+
288
+ print "Project key [#{info["project_key"]}]: "
289
+ project_key = $stdin.gets.chomp
290
+ project_key = info["project_key"] if project_key.empty?
291
+
292
+ if projects.key?(project_key) && !options[:force]
293
+ puts "Error: Project '#{project_key}' already exists. Use --force to overwrite."
294
+ exit 1
295
+ end
296
+
297
+ print "Fizzy tags (comma-separated) [#{project_key}]: "
298
+ tags_input = $stdin.gets.chomp
299
+ fizzy_tags = tags_input.empty? ? [project_key] : tags_input.split(",").map(&:strip)
300
+
301
+ print "GitHub repo [#{info["github_repo"]}]: "
302
+ github_repo = $stdin.gets.chomp
303
+ github_repo = info["github_repo"] if github_repo.empty?
304
+
305
+ print "CLI provider [kiro]: "
306
+ cli_provider = $stdin.gets.chomp
307
+ cli_provider = "kiro" if cli_provider.empty?
308
+
309
+ print "Default model [auto]: "
310
+ agent_model = $stdin.gets.chomp
311
+ agent_model = "auto" if agent_model.empty?
312
+
313
+ default_agent_name = ENV.fetch("AI_AGENT_NAME", "Galen")
314
+ print "Agent name [#{default_agent_name}]: "
315
+ agent_name = $stdin.gets.chomp
316
+ agent_name = default_agent_name if agent_name.empty?
317
+
318
+ # Build project config
319
+ project_config = {
320
+ "repo_path" => info["repo_path"],
321
+ "fizzy_tags" => fizzy_tags,
322
+ "github_repo" => github_repo,
323
+ "agent_name" => agent_name,
324
+ "agent_model" => agent_model,
325
+ "cli_provider" => cli_provider
326
+ }
327
+
328
+ projects[project_key] = project_config
329
+ save_projects(projects)
330
+
331
+ puts
332
+ puts "✓ Project '#{project_key}' registered successfully!"
333
+ puts " Repo: #{info["repo_path"]}"
334
+ puts " Tags: #{fizzy_tags.join(", ")}"
335
+ puts " GitHub: #{github_repo}" if github_repo
336
+ end
337
+
338
+ def unregister_project(project_key)
339
+ projects = load_projects
340
+
341
+ unless projects.key?(project_key)
342
+ puts "Error: Project '#{project_key}' not found."
343
+ exit 1
344
+ end
345
+
346
+ projects.delete(project_key)
347
+ save_projects(projects)
348
+
349
+ puts "✓ Project '#{project_key}' unregistered."
350
+ end
351
+
352
+ def list_projects
353
+ projects = load_projects
354
+
355
+ if projects.empty?
356
+ puts "No projects registered."
357
+ puts "Run 'zillacore register' from a project directory to get started."
358
+ return
359
+ end
360
+
361
+ puts "Registered projects:"
362
+ puts
363
+
364
+ projects.each do |key, config|
365
+ default_marker = config["default"] ? " (default)" : ""
366
+ puts " #{key}#{default_marker}"
367
+ puts " Path: #{config["repo_path"]}"
368
+ puts " Tags: #{config["fizzy_tags"].join(", ")}"
369
+ puts " GitHub: #{config["github_repo"]}" if config["github_repo"]
370
+ puts " Agent: #{config["agent_cli"]} (model: #{config["agent_model"]})"
371
+ puts
372
+ end
373
+ end
374
+
375
+ def show_project(project_key)
376
+ projects = load_projects
377
+
378
+ unless projects.key?(project_key)
379
+ puts "Error: Project '#{project_key}' not found."
380
+ exit 1
381
+ end
382
+
383
+ config = projects[project_key]
384
+ puts JSON.pretty_generate(config)
385
+ end
386
+
387
+ def configure_server(options)
388
+ config = load_config
389
+
390
+ config["server_url"] = options[:server_url] if options[:server_url]
391
+
392
+ if options[:show]
393
+ puts JSON.pretty_generate(config)
394
+ return
395
+ end
396
+
397
+ # Interactive configuration
398
+ current_url = config["server_url"] || "http://localhost:4567"
399
+ print "ZillaCore server URL [#{current_url}]: "
400
+ server_url = $stdin.gets.chomp
401
+ config["server_url"] = server_url.empty? ? current_url : server_url
402
+
403
+ save_config(config)
404
+ puts "✓ Configuration saved."
405
+ end
406
+
407
+ # --- Brain management ---
408
+
409
+ BRAIN_BASE_DIR = File.join(ZILLACORE_DIR, "brain")
410
+ KNOWLEDGE_DIR = File.join(BRAIN_BASE_DIR, "knowledge")
411
+ PERSONA_BASE_DIR = File.join(BRAIN_BASE_DIR, "persona")
412
+ MEMORY_BASE_DIR = File.join(BRAIN_BASE_DIR, "memory")
413
+ KNOWLEDGE_COLLECTION = "zillacore-knowledge".freeze
414
+
415
+ def memory_dir_for(agent_name)
416
+ File.join(MEMORY_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-"))
417
+ end
418
+
419
+ def persona_dir_for(agent_name)
420
+ File.join(PERSONA_BASE_DIR, agent_name.downcase.gsub(/[^a-z0-9-]/, "-"))
421
+ end
422
+
423
+ def persona_collection_for(agent_name)
424
+ "#{agent_name.downcase.gsub(/[^a-z0-9-]/, "-")}-persona"
425
+ end
426
+
427
+ def brain_init(agent_name)
428
+ persona_dir = persona_dir_for(agent_name)
429
+ persona_col = persona_collection_for(agent_name)
430
+
431
+ FileUtils.mkdir_p(KNOWLEDGE_DIR)
432
+ FileUtils.mkdir_p(persona_dir)
433
+ FileUtils.mkdir_p(File.join(BRAIN_BASE_DIR, "memory"))
434
+
435
+ # Check if qmd is available
436
+ unless system("which qmd > /dev/null 2>&1")
437
+ puts "✓ Brain directories created:"
438
+ puts " Knowledge: #{KNOWLEDGE_DIR}"
439
+ puts " Persona: #{persona_dir}"
440
+ puts ""
441
+ puts "⚠ qmd is not installed. Install it to enable semantic search:"
442
+ puts " npm install -g @tobilu/qmd"
443
+ puts ""
444
+ puts "Then run: zillacore brain init #{agent_name}"
445
+ return
446
+ end
447
+
448
+ # Set up shared knowledge collection (idempotent — qmd won't duplicate)
449
+ puts "Setting up shared knowledge collection..."
450
+ system("qmd", "collection", "add", KNOWLEDGE_DIR, "--name", KNOWLEDGE_COLLECTION)
451
+ system("qmd", "context", "add", "qmd://#{KNOWLEDGE_COLLECTION}",
452
+ "Shared technical knowledge: project conventions, coding patterns, architecture decisions, lessons learned")
453
+
454
+ # Set up per-agent persona collection
455
+ puts "Setting up persona collection for #{agent_name}..."
456
+ system("qmd", "collection", "add", persona_dir, "--name", persona_col)
457
+ system("qmd", "context", "add", "qmd://#{persona_col}",
458
+ "Communication style and personality for AI agent #{agent_name}")
459
+
460
+ # Set up card memory collection (per-agent)
461
+ memory_dir = memory_dir_for(agent_name)
462
+ FileUtils.mkdir_p(memory_dir)
463
+ puts "Setting up card memory collection for #{agent_name}..."
464
+ system("qmd", "collection", "add", memory_dir, "--name", "#{agent_name.downcase}-memory")
465
+ system("qmd", "context", "add", "qmd://#{agent_name.downcase}-memory",
466
+ "Per-card session memory for #{agent_name}: decisions, questions, answers, work status, timelines")
467
+
468
+ puts "Indexing..."
469
+ system("qmd", "update")
470
+
471
+ puts ""
472
+ puts "✓ Brain initialized for #{agent_name}"
473
+ puts " Knowledge (shared): #{KNOWLEDGE_DIR}"
474
+ puts " Memory (#{agent_name}): #{memory_dir}"
475
+ puts " Persona (#{agent_name}): #{persona_dir}"
476
+ end
477
+
478
+ def brain_status(agent_name)
479
+ persona_dir = persona_dir_for(agent_name)
480
+ persona_col = persona_collection_for(agent_name)
481
+ memory_dir = memory_dir_for(agent_name)
482
+
483
+ knowledge_files = File.directory?(KNOWLEDGE_DIR) ? Dir.glob(File.join(KNOWLEDGE_DIR, "**", "*.md")) : []
484
+ memory_files = File.directory?(memory_dir) ? Dir.glob(File.join(memory_dir, "**", "*.md")) : []
485
+ persona_files = File.directory?(persona_dir) ? Dir.glob(File.join(persona_dir, "**", "*.md")) : []
486
+
487
+ if knowledge_files.empty? && memory_files.empty? && persona_files.empty?
488
+ puts "No brain found. Run 'zillacore brain init' to create one."
489
+ return
490
+ end
491
+
492
+ puts "Knowledge (shared across all agents):"
493
+ puts " Directory: #{KNOWLEDGE_DIR}"
494
+ puts " Collection: #{KNOWLEDGE_COLLECTION}"
495
+ puts " Files: #{knowledge_files.size}"
496
+ knowledge_files.each do |f|
497
+ puts " #{f.sub("#{KNOWLEDGE_DIR}/", "")} (#{File.size(f)} bytes)"
498
+ end
499
+
500
+ puts ""
501
+ puts "Memory (#{agent_name}, per-card session history):"
502
+ puts " Directory: #{memory_dir}"
503
+ puts " Collection: #{agent_name.downcase}-memory"
504
+ puts " Files: #{memory_files.size}"
505
+ memory_files.each do |f|
506
+ puts " #{f.sub("#{memory_dir}/", "")} (#{File.size(f)} bytes)"
507
+ end
508
+
509
+ puts ""
510
+ puts "Persona (#{agent_name}):"
511
+ puts " Directory: #{persona_dir}"
512
+ puts " Collection: #{persona_col}"
513
+ puts " Files: #{persona_files.size}"
514
+ persona_files.each do |f|
515
+ puts " #{f.sub("#{persona_dir}/", "")} (#{File.size(f)} bytes)"
516
+ end
517
+ end
518
+
519
+ def brain_search(query, scope: :knowledge, agent_name: nil)
520
+ unless system("which qmd > /dev/null 2>&1")
521
+ puts "Error: qmd is not installed. Run: npm install -g @tobilu/qmd"
522
+ exit 1
523
+ end
524
+
525
+ collection = case scope
526
+ when :persona
527
+ agent_name ||= ENV.fetch("AI_AGENT_NAME", "Galen")
528
+ persona_collection_for(agent_name)
529
+ else
530
+ KNOWLEDGE_COLLECTION
531
+ end
532
+
533
+ exec("qmd", "search", query, "-c", collection)
534
+ end
535
+
536
+ def brain_list
537
+ puts "Knowledge (shared):"
538
+ if File.directory?(KNOWLEDGE_DIR)
539
+ files = Dir.glob(File.join(KNOWLEDGE_DIR, "**", "*.md"))
540
+ puts " #{KNOWLEDGE_DIR} (#{files.size} files)"
541
+ else
542
+ puts " Not initialized"
543
+ end
544
+
545
+ puts ""
546
+ puts "Memory (per-agent, per-card):"
547
+ if File.directory?(MEMORY_BASE_DIR)
548
+ agent_dirs = Dir.children(MEMORY_BASE_DIR).select { |d| File.directory?(File.join(MEMORY_BASE_DIR, d)) }
549
+ if agent_dirs.empty?
550
+ puts " None"
551
+ else
552
+ agent_dirs.each do |d|
553
+ agent_memory_dir = File.join(MEMORY_BASE_DIR, d)
554
+ files = Dir.glob(File.join(agent_memory_dir, "**", "*.md"))
555
+ puts " #{d}: #{files.size} files"
556
+ end
557
+ end
558
+ else
559
+ puts " Not initialized"
560
+ end
561
+
562
+ puts ""
563
+ puts "Personas:"
564
+ if File.directory?(PERSONA_BASE_DIR)
565
+ dirs = Dir.children(PERSONA_BASE_DIR).select { |d| File.directory?(File.join(PERSONA_BASE_DIR, d)) }
566
+ if dirs.empty?
567
+ puts " None"
568
+ else
569
+ dirs.each do |d|
570
+ dir = File.join(PERSONA_BASE_DIR, d)
571
+ files = Dir.glob(File.join(dir, "**", "*.md"))
572
+ puts " #{d} (#{files.size} files) — #{dir}"
573
+ end
574
+ end
575
+ else
576
+ puts " Not initialized"
577
+ end
578
+
579
+ puts ""
580
+ puts "Run 'zillacore brain init <agent-name>' to set up a new agent."
581
+ end
582
+
583
+ # Main CLI
584
+ options = {}
585
+ subcommand = ARGV.shift
586
+
587
+ case subcommand
588
+ when "setup"
589
+ puts "🔧 ZillaCore Setup"
590
+ puts ""
591
+
592
+ # Create directory structure
593
+ dirs = %w[brain/knowledge brain/persona brain/memory handlers plans tmp/discord/draft tmp/discord/posted]
594
+ dirs.each do |dir|
595
+ path = File.join(ZILLACORE_DIR, dir)
596
+ FileUtils.mkdir_p(path)
597
+ end
598
+ puts "✓ Created ~/.zillacore/ directory structure"
599
+
600
+ # Copy example configs
601
+ templates_dir = File.join(ZILLACORE_ROOT, "templates")
602
+ if Dir.exist?(templates_dir)
603
+ copied = 0
604
+ Dir.glob(File.join(templates_dir, "*.example")).each do |template|
605
+ dest = File.join(ZILLACORE_DIR, File.basename(template, ".example"))
606
+ unless File.exist?(dest)
607
+ FileUtils.cp(template, dest)
608
+ copied += 1
609
+ end
610
+ end
611
+ puts "✓ Copied #{copied} example config(s) to ~/.zillacore/ (existing files preserved)"
612
+ end
613
+
614
+ puts ""
615
+ puts "Next steps:"
616
+ puts " 1. Edit config files in ~/.zillacore/ with your secrets and IDs"
617
+ puts " 2. Create agent configs in ~/.kiro/agents/"
618
+ puts " 3. Register projects: cd ~/Code/myproject && zillacore register"
619
+ puts " 4. Initialize brain: zillacore brain init"
620
+ puts " 5. Start server: zillacore server"
621
+
622
+ when "register", "add"
623
+ OptionParser.new do |opts|
624
+ opts.banner = "Usage: zillacore register [options]"
625
+ opts.on("-p", "--path PATH", "Project path (default: current directory)")
626
+ opts.on("-f", "--force", "Overwrite existing project")
627
+ end.parse!(into: options)
628
+
629
+ register_project(options)
630
+
631
+ when "unregister", "remove", "rm"
632
+ if ARGV.empty?
633
+ puts "Usage: zillacore unregister <project-key>"
634
+ exit 1
635
+ end
636
+ unregister_project(ARGV[0])
637
+
638
+ when "list", "ls"
639
+ list_projects
640
+
641
+ when "projects"
642
+ # Support "zillacore projects list" as an alias for "zillacore list"
643
+ projects_cmd = ARGV.shift
644
+ case projects_cmd
645
+ when "list", "ls", nil
646
+ list_projects
647
+ when "show"
648
+ if ARGV.empty?
649
+ puts "Usage: zillacore projects show <project-key>"
650
+ exit 1
651
+ end
652
+ show_project(ARGV[0])
653
+ when "default"
654
+ project_key = ARGV[0]
655
+ unless project_key
656
+ puts "Usage: zillacore projects default <project-key>"
657
+ puts " Sets the default project for project-specific setup (e.g., .env copying)"
658
+ exit 1
659
+ end
660
+
661
+ projects = load_projects
662
+ unless projects.key?(project_key)
663
+ puts "Error: Project '#{project_key}' not found."
664
+ puts "Available projects: #{projects.keys.join(", ")}"
665
+ exit 1
666
+ end
667
+
668
+ # Remove default flag from all projects, then set it on the target
669
+ projects.each_value { |config| config.delete("default") }
670
+ projects[project_key]["default"] = true
671
+ save_projects(projects)
672
+
673
+ puts "✓ Set '#{project_key}' as the default project"
674
+ else
675
+ puts "Unknown projects command: #{projects_cmd}"
676
+ puts "Available: list, show, default"
677
+ exit 1
678
+ end
679
+
680
+ when "show"
681
+ if ARGV.empty?
682
+ puts "Usage: zillacore show <project-key>"
683
+ exit 1
684
+ end
685
+ show_project(ARGV[0])
686
+
687
+ when "config"
688
+ OptionParser.new do |opts|
689
+ opts.banner = "Usage: zillacore config [options]"
690
+ opts.on("-s", "--server-url URL", "ZillaCore server URL")
691
+ opts.on("--show", "Show current configuration")
692
+ end.parse!(into: options)
693
+
694
+ configure_server(options)
695
+
696
+ when "server", "s"
697
+ OptionParser.new do |opts|
698
+ opts.banner = "Usage: zillacore server [options]"
699
+ opts.on("-d", "--daemon", "Run server in background (detached)")
700
+ end.parse!(into: options)
701
+
702
+ start_server(daemon: options[:daemon])
703
+
704
+ when "stop"
705
+ stop_server
706
+
707
+ when "restart"
708
+ puts "Restarting ZillaCore server..."
709
+ wait_for_agents_to_finish if find_server_pid
710
+ if stop_server
711
+ # Wait a bit longer to ensure the process is fully dead
712
+ sleep 2
713
+
714
+ # Double-check that the process is really gone
715
+ if find_server_pid
716
+ puts "Warning: Old server process still running, forcing kill..."
717
+ pid = find_server_pid
718
+ begin
719
+ Process.kill("KILL", pid)
720
+ sleep 1
721
+ rescue Errno::ESRCH
722
+ # Already dead
723
+ end
724
+ FileUtils.rm_f(PID_FILE)
725
+ end
726
+ end
727
+
728
+ start_server(daemon: true)
729
+
730
+ when "logs", "log"
731
+ # Tail the server log
732
+ log_file = File.join(ZILLACORE_ROOT, "tmp", "zillacore-server.log")
733
+
734
+ unless File.exist?(log_file)
735
+ puts "No log file found at #{log_file}"
736
+ puts "Is the server running? Check with: zillacore status"
737
+ exit 1
738
+ end
739
+
740
+ exec("tail", "-f", log_file)
741
+
742
+ when "status"
743
+ pid = find_server_pid
744
+ if pid
745
+ puts "ZillaCore server is running (PID: #{pid})"
746
+ else
747
+ puts "ZillaCore server is not running"
748
+ end
749
+
750
+ when "path"
751
+ puts ZILLACORE_DIR
752
+
753
+ when "discord"
754
+ discord_cmd = ARGV.shift
755
+ discord_config_file = File.join(ZILLACORE_DIR, "discord.json")
756
+ agent_registry_file = File.join(ZILLACORE_DIR, "agents.json")
757
+
758
+ case discord_cmd
759
+ when "config"
760
+ if File.exist?(discord_config_file)
761
+ puts File.read(discord_config_file)
762
+ else
763
+ puts "No Discord config found at #{discord_config_file}"
764
+ puts "Create one with: zillacore discord map <channel-id> <project>"
765
+ puts "Or copy discord.json.example to #{discord_config_file}"
766
+ end
767
+
768
+ when "map"
769
+ channel_id = ARGV[0]
770
+ project_key = ARGV[1]
771
+
772
+ unless channel_id && project_key
773
+ puts "Usage: zillacore discord map <channel-id> <project-key>"
774
+ exit 1
775
+ end
776
+
777
+ config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : { "channel_mappings" => {} }
778
+ config["channel_mappings"] ||= {}
779
+ config["channel_mappings"][channel_id] = { "project" => project_key }
780
+
781
+ ensure_zillacore_dir
782
+ File.write(discord_config_file, JSON.pretty_generate(config))
783
+ puts "✓ Mapped channel #{channel_id} → project '#{project_key}'"
784
+
785
+ when "default"
786
+ project_key = ARGV[0]
787
+
788
+ unless project_key
789
+ puts "Usage: zillacore discord default <project-key>"
790
+ exit 1
791
+ end
792
+
793
+ config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : { "channel_mappings" => {} }
794
+ config["default_project"] = project_key
795
+
796
+ ensure_zillacore_dir
797
+ File.write(discord_config_file, JSON.pretty_generate(config))
798
+ puts "✓ Default project: #{project_key}"
799
+
800
+ when "token"
801
+ agent_key = ARGV[0]
802
+ token = ARGV[1]
803
+
804
+ unless agent_key && token
805
+ puts "Usage: zillacore discord token <agent-key> <bot-token>"
806
+ puts " Sets the DISCORD_BOT_TOKEN env var for an agent in the registry."
807
+ puts " Example: zillacore discord token galen Bot_TOKEN_HERE"
808
+ exit 1
809
+ end
810
+
811
+ registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
812
+ registry[agent_key] ||= {}
813
+ registry[agent_key]["env"] ||= {}
814
+ registry[agent_key]["env"]["DISCORD_BOT_TOKEN"] = token
815
+
816
+ ensure_zillacore_dir
817
+ File.write(agent_registry_file, JSON.pretty_generate(registry))
818
+ puts "✓ Set DISCORD_BOT_TOKEN for '#{agent_key}'"
819
+
820
+ when "status"
821
+ config = load_config
822
+ server_url = config["server_url"] || "http://localhost:4567"
823
+ begin
824
+ uri = URI("#{server_url}/api/discord")
825
+ response = Net::HTTP.get_response(uri)
826
+ data = JSON.parse(response.body)
827
+ if data["enabled"]
828
+ bots = data["bots"] || {}
829
+ if bots.empty?
830
+ puts "Discord: enabled but no bots configured"
831
+ else
832
+ puts "Discord bots:"
833
+ bots.each do |agent, info|
834
+ puts " #{agent}: #{info["status"]} (user_id: #{info["user_id"] || "n/a"})"
835
+ end
836
+ end
837
+ puts "Default project: #{data.dig("config", "default_project") || "none"}"
838
+ puts "Channel mappings: #{data.dig("config", "channel_mappings")}"
839
+ else
840
+ puts "Discord: disabled (#{data["reason"]})"
841
+ end
842
+ rescue StandardError => e
843
+ puts "Could not reach server at #{server_url}: #{e.message}"
844
+ puts "Is the server running? Check with: zillacore status"
845
+ end
846
+
847
+ when "agents"
848
+ registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
849
+ agents_with_tokens = registry.select { |_k, v| v.is_a?(Hash) && (v.dig("env", "DISCORD_BOT_TOKEN") || v["discord_bot_token"]) }
850
+ if agents_with_tokens.empty?
851
+ puts "No agents have Discord bot tokens configured."
852
+ puts "Add one with: zillacore discord token <agent-key> <bot-token>"
853
+ else
854
+ puts "Agents with Discord bots:"
855
+ agents_with_tokens.each do |key, entry|
856
+ display = entry["fizzy_name"] || key.capitalize
857
+ token = entry.dig("env", "DISCORD_BOT_TOKEN") || entry["discord_bot_token"]
858
+ token_preview = "#{token[0..10]}..."
859
+ puts " #{display} (#{key}): #{token_preview}"
860
+ end
861
+ end
862
+
863
+ when "owner"
864
+ discord_id = ARGV[0]
865
+ config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : {}
866
+ if discord_id
867
+ config["owner_discord_id"] = discord_id
868
+ ensure_zillacore_dir
869
+ File.write(discord_config_file, JSON.pretty_generate(config))
870
+ puts "✓ Owner set to #{discord_id}"
871
+ elsif config["owner_discord_id"]
872
+ puts "Owner: #{config["owner_discord_id"]}"
873
+ else
874
+ puts "No owner set."
875
+ puts "Usage: zillacore discord owner <discord-user-id>"
876
+ end
877
+
878
+ else
879
+ puts <<~HELP
880
+ Usage: zillacore discord <command>
881
+
882
+ Commands:
883
+ config Show Discord config
884
+ default <project> Set default project for all channels
885
+ map <channel-id> <project> Map a specific channel to a project
886
+ owner [<discord-user-id>] Set/show machine owner (for version notifications)
887
+ token <agent-key> <bot-token> Set Discord bot token for an agent
888
+ agents List agents with Discord bot tokens
889
+ status Check Discord bot status (via server API)
890
+
891
+ Each agent gets its own Discord bot. Users @mention @Galen or @GLaDOS
892
+ directly in Discord — no shared bot needed.
893
+
894
+ Setup:
895
+ 1. Create a Discord bot per agent at https://discord.com/developers/applications
896
+ 2. Enable MESSAGE CONTENT intent in each bot's settings
897
+ 3. Invite each bot with permissions: Send Messages, Create Public Threads,
898
+ Send Messages in Threads, Add Reactions, Read Message History
899
+ 4. Register each bot token:
900
+ zillacore discord token galen "BOT_TOKEN_FOR_GALEN"
901
+ zillacore discord token glados "BOT_TOKEN_FOR_GLADOS"
902
+ 5. Set a default project:
903
+ zillacore discord default marketplace
904
+ 6. Start the server (all bots connect automatically):
905
+ zillacore server
906
+
907
+ Config file: #{discord_config_file}
908
+ Agent registry: #{agent_registry_file}
909
+ HELP
910
+ end
911
+
912
+ when "card-map"
913
+ card_map_file = File.join(ZILLACORE_DIR, "card_map.json")
914
+ cm_cmd = ARGV.shift
915
+ case cm_cmd
916
+ when "clean"
917
+ unless File.exist?(card_map_file)
918
+ puts "No card_map.json found."
919
+ exit 0
920
+ end
921
+ map = JSON.parse(File.read(card_map_file))
922
+ before = map.size
923
+ # Remove entries with no valid worktree on disk
924
+ map.reject! { |_id, info| info["worktree"].nil? || !File.directory?(info["worktree"].to_s) }
925
+ after = map.size
926
+ removed = before - after
927
+ File.write(card_map_file, JSON.pretty_generate(map))
928
+ puts "Cleaned card map: removed #{removed} stale entries (#{before} → #{after})"
929
+ when "list", "ls", nil
930
+ unless File.exist?(card_map_file)
931
+ puts "No card_map.json found."
932
+ exit 0
933
+ end
934
+ map = JSON.parse(File.read(card_map_file))
935
+ if map.empty?
936
+ puts "Card map is empty."
937
+ else
938
+ map.each_value do |info|
939
+ num = info["number"] ? "##{info["number"]}" : "(no number)"
940
+ agent = info["agent"] || "(no agent)"
941
+ worktree_exists = info["worktree"] && File.directory?(info["worktree"].to_s) ? "✓" : "✗"
942
+ puts "#{num.ljust(8)} #{agent.ljust(18)} #{worktree_exists} #{info["worktree"] || "(none)"}"
943
+ end
944
+ end
945
+ else
946
+ puts "Usage: zillacore card-map [clean|list]"
947
+ puts " clean Remove stale entries (missing worktrees)"
948
+ puts " list Show all card map entries"
949
+ end
950
+
951
+ when "brain"
952
+ brain_cmd = ARGV.shift
953
+ case brain_cmd
954
+ when "init"
955
+ agent = ARGV[0] || ENV.fetch("AI_AGENT_NAME", "Galen")
956
+ brain_init(agent)
957
+ when "status", "show"
958
+ agent = ARGV[0] || ENV.fetch("AI_AGENT_NAME", "Galen")
959
+ brain_status(agent)
960
+ when "search", "query"
961
+ scope = :knowledge
962
+ agent_name = ENV.fetch("AI_AGENT_NAME", "Galen")
963
+ if ARGV.include?("--persona")
964
+ ARGV.delete("--persona")
965
+ scope = :persona
966
+ end
967
+ if ARGV.include?("--agent")
968
+ idx = ARGV.index("--agent")
969
+ agent_name = ARGV.delete_at(idx + 1)
970
+ ARGV.delete_at(idx)
971
+ end
972
+ if ARGV.empty?
973
+ puts "Usage: zillacore brain search [--persona] [--agent <name>] <query>"
974
+ exit 1
975
+ end
976
+ brain_search(ARGV.join(" "), scope: scope, agent_name: agent_name)
977
+ when "list", "ls"
978
+ brain_list
979
+ when "path"
980
+ what = ARGV[0]
981
+ if what == "persona"
982
+ agent = ARGV[1] || ENV.fetch("AI_AGENT_NAME", "Galen")
983
+ puts persona_dir_for(agent)
984
+ else
985
+ puts KNOWLEDGE_DIR
986
+ end
987
+ else
988
+ puts <<~HELP
989
+ Usage: zillacore brain <command> [options]
990
+
991
+ Commands:
992
+ init [agent-name] Initialize brain (shared knowledge + agent persona)
993
+ status [agent-name] Show brain status and files
994
+ search [--persona] [--agent name] <q> Search knowledge (default) or persona
995
+ list List knowledge and all personas
996
+ path Show knowledge directory
997
+ path persona [agent-name] Show persona directory
998
+
999
+ The brain has two parts:
1000
+ knowledge/ — shared across all agents (project conventions, patterns, lessons)
1001
+ persona/ — per-agent (communication style, tone, personality)
1002
+
1003
+ Knowledge is automatically queried when agents do work.
1004
+ Persona is only queried when agents write comments.
1005
+
1006
+ Examples:
1007
+ zillacore brain init # Init for default agent
1008
+ zillacore brain init Nova # Init persona for Nova
1009
+ zillacore brain search "ruby style" # Search shared knowledge
1010
+ zillacore brain search --persona "tone" # Search persona
1011
+ zillacore brain list # List everything
1012
+ HELP
1013
+ end
1014
+
1015
+ when "cron"
1016
+ cron_cmd = ARGV.shift
1017
+ config = load_config
1018
+ server_url = config["server_url"] || "http://localhost:4567"
1019
+
1020
+ case cron_cmd
1021
+ when "add", "create"
1022
+ # Parse arguments
1023
+ schedule = nil
1024
+ agent = ENV.fetch("AI_AGENT_NAME", "Galen")
1025
+ project = nil
1026
+ model = nil
1027
+ effort = nil
1028
+ discord_channel_id = nil
1029
+ forum_title = nil
1030
+ repeat_count = nil
1031
+ prompt = nil
1032
+ script = nil
1033
+
1034
+ OptionParser.new do |opts|
1035
+ opts.banner = "Usage: zillacore cron add [options] \"<prompt or --script path>\""
1036
+ opts.on("-s", "--schedule EXPR", "Cron expression, @shorthand, or one-time schedule") { |v| schedule = v }
1037
+ opts.on("-a", "--agent NAME", "Agent name (default: $AI_AGENT_NAME)") { |v| agent = v }
1038
+ opts.on("-p", "--project KEY", "Project key") { |v| project = v }
1039
+ opts.on("-m", "--model MODEL", "Model to use (opus, sonnet, haiku, auto)") { |v| model = v }
1040
+ opts.on("-e", "--effort LEVEL", "Effort level (low, medium, high, xhigh, max)") { |v| effort = v }
1041
+ opts.on("-d", "--discord CHANNEL_ID", "Discord channel ID to post results") { |v| discord_channel_id = v }
1042
+ opts.on("-t", "--title TITLE", "Forum post title (for forum channels)") { |v| forum_title = v }
1043
+ opts.on("-r", "--repeat COUNT", Integer, "Number of times to repeat (for recurring schedules)") { |v| repeat_count = v }
1044
+ opts.on("--script PATH", "Run script directly (no agent, output to Discord)") { |v| script = v }
1045
+ end.parse!
1046
+
1047
+ prompt = ARGV.join(" ") unless script
1048
+
1049
+ unless schedule && project && (prompt&.length&.> 0 || script)
1050
+ puts "Error: Missing required arguments"
1051
+ puts ""
1052
+ puts "Usage examples:"
1053
+ puts " # Agent with prompt (recurring)"
1054
+ puts " zillacore cron add -s \"0 9 * * *\" -p marketplace \"Daily standup\""
1055
+ puts ""
1056
+ puts " # Script mode (no agent, direct execution)"
1057
+ puts " zillacore cron add -s \"0 9 * * *\" -p zillacore --script ~/.zillacore/scripts/report.sh -d 1234567890"
1058
+ puts ""
1059
+ puts " # Recurring (shorthand)"
1060
+ puts " zillacore cron add -s @daily -p zillacore \"Security audit\""
1061
+ puts ""
1062
+ puts " # One-time (natural language)"
1063
+ puts " zillacore cron add -s \"tomorrow at 9am\" -p marketplace \"Reminder\""
1064
+ puts " zillacore cron add -s \"in 2 hours\" -p zillacore \"Follow up\""
1065
+ puts " zillacore cron add -s \"next monday at 3pm\" -p marketplace \"Weekly review\""
1066
+ puts ""
1067
+ puts " # Recurring with repeat limit"
1068
+ puts " zillacore cron add -s \"0 9 * * *\" -r 7 -p marketplace \"Daily reminder for 7 days\""
1069
+ puts " zillacore cron add -s @daily -r 3 -p zillacore \"Reminder for 3 days\""
1070
+ exit 1
1071
+ end
1072
+
1073
+ # Generate ID from prompt or script
1074
+ id = (script ? File.basename(script, ".*") : prompt).downcase.gsub(/[^a-z0-9]+/, "-")[0..30]
1075
+
1076
+ # Call server API
1077
+ uri = URI("#{server_url}/api/cron/add")
1078
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1079
+ payload = { id: id, schedule: schedule, agent: agent, project: project }
1080
+ payload[:prompt] = prompt if prompt
1081
+ payload[:script] = script if script
1082
+ payload[:model] = model if model
1083
+ payload[:effort] = effort if effort
1084
+ payload[:discord_channel_id] = discord_channel_id if discord_channel_id
1085
+ payload[:forum_title] = forum_title if forum_title
1086
+ payload[:repeat_count] = repeat_count if repeat_count
1087
+ req.body = payload.to_json
1088
+
1089
+ begin
1090
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
1091
+ data = JSON.parse(response.body)
1092
+ if data["success"]
1093
+ puts "✓ Cron job added: #{id}"
1094
+ puts " Schedule: #{schedule}"
1095
+ puts " Agent: #{script ? "none (script mode)" : agent}"
1096
+ puts " Project: #{project}"
1097
+ puts " Model: #{model || "default"}" unless script
1098
+ puts " Effort: #{effort || "default"}" unless script
1099
+ puts " Discord: #{discord_channel_id || "none"}"
1100
+ puts " Forum title: #{forum_title || "auto"}" if discord_channel_id
1101
+ puts " Repeat: #{repeat_count ? "#{repeat_count} times" : "unlimited"}"
1102
+ puts script ? " Script: #{script}" : " Prompt: #{prompt}"
1103
+ else
1104
+ puts "Error: #{data["error"]}"
1105
+ exit 1
1106
+ end
1107
+ rescue StandardError => e
1108
+ puts "Could not reach server: #{e.message}"
1109
+ puts "Is the server running? Check with: zillacore status"
1110
+ exit 1
1111
+ end
1112
+
1113
+ when "list", "ls"
1114
+ begin
1115
+ uri = URI("#{server_url}/api/cron")
1116
+ response = Net::HTTP.get_response(uri)
1117
+ data = JSON.parse(response.body)
1118
+
1119
+ if data["jobs"].empty?
1120
+ puts "No cron jobs configured."
1121
+ puts "Add one with: zillacore cron add -s \"0 9 * * *\" -p marketplace \"Daily summary\""
1122
+ puts "Or one-time: zillacore cron add -s \"tomorrow at 9am\" -p marketplace \"Reminder\""
1123
+ else
1124
+ puts "Cron jobs:"
1125
+ data["jobs"].each do |id, job|
1126
+ status = job["enabled"] ? "✓" : "✗"
1127
+ last_run = job["last_run"] ? Time.parse(job["last_run"]).strftime("%Y-%m-%d %H:%M") : "never"
1128
+ execution_count = job["execution_count"] || 0
1129
+
1130
+ # Show one-time jobs differently
1131
+ schedule_display = job["schedule"]
1132
+ if job["parsed"] && job["parsed"]["one_time"]
1133
+ target_time = Time.parse(job["parsed"]["timestamp"])
1134
+ schedule_display = "#{job["schedule"]} (#{target_time.strftime("%Y-%m-%d %H:%M")})"
1135
+ schedule_display += " [COMPLETED]" unless job["enabled"]
1136
+ elsif job["repeat_count"]
1137
+ schedule_display = "#{job["schedule"]} (#{execution_count}/#{job["repeat_count"]} runs)"
1138
+ schedule_display += " [COMPLETED]" unless job["enabled"]
1139
+ end
1140
+
1141
+ puts " #{status} #{id}"
1142
+ puts " Schedule: #{schedule_display}"
1143
+ puts " Agent: #{job["script"] ? "none (script mode)" : job["agent"]} | Project: #{job["project"]}"
1144
+ puts " Discord: #{job["discord_channel_id"]}" if job["discord_channel_id"]
1145
+ puts job["script"] ? " Script: #{job["script"]}" : " Prompt: #{job["prompt"]}"
1146
+ puts " Last run: #{last_run}"
1147
+ puts
1148
+ end
1149
+ end
1150
+ rescue StandardError => e
1151
+ puts "Could not reach server: #{e.message}"
1152
+ exit 1
1153
+ end
1154
+
1155
+ when "remove", "rm", "delete"
1156
+ id = ARGV[0]
1157
+ unless id
1158
+ puts "Usage: zillacore cron remove <job-id>"
1159
+ exit 1
1160
+ end
1161
+
1162
+ uri = URI("#{server_url}/api/cron/remove")
1163
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1164
+ req.body = { id: id }.to_json
1165
+
1166
+ begin
1167
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
1168
+ data = JSON.parse(response.body)
1169
+ if data["success"]
1170
+ puts "✓ Removed cron job: #{id}"
1171
+ else
1172
+ puts "Error: #{data["error"]}"
1173
+ exit 1
1174
+ end
1175
+ rescue StandardError => e
1176
+ puts "Could not reach server: #{e.message}"
1177
+ exit 1
1178
+ end
1179
+
1180
+ when "enable"
1181
+ id = ARGV[0]
1182
+ unless id
1183
+ puts "Usage: zillacore cron enable <job-id>"
1184
+ exit 1
1185
+ end
1186
+
1187
+ uri = URI("#{server_url}/api/cron/toggle")
1188
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1189
+ req.body = { id: id, enabled: true }.to_json
1190
+
1191
+ begin
1192
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
1193
+ data = JSON.parse(response.body)
1194
+ if data["success"]
1195
+ puts "✓ Enabled cron job: #{id}"
1196
+ else
1197
+ puts "Error: #{data["error"]}"
1198
+ exit 1
1199
+ end
1200
+ rescue StandardError => e
1201
+ puts "Could not reach server: #{e.message}"
1202
+ exit 1
1203
+ end
1204
+
1205
+ when "disable"
1206
+ id = ARGV[0]
1207
+ unless id
1208
+ puts "Usage: zillacore cron disable <job-id>"
1209
+ exit 1
1210
+ end
1211
+
1212
+ uri = URI("#{server_url}/api/cron/toggle")
1213
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1214
+ req.body = { id: id, enabled: false }.to_json
1215
+
1216
+ begin
1217
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
1218
+ data = JSON.parse(response.body)
1219
+ if data["success"]
1220
+ puts "✓ Disabled cron job: #{id}"
1221
+ else
1222
+ puts "Error: #{data["error"]}"
1223
+ exit 1
1224
+ end
1225
+ rescue StandardError => e
1226
+ puts "Could not reach server: #{e.message}"
1227
+ exit 1
1228
+ end
1229
+
1230
+ when "update"
1231
+ id = ARGV[0]
1232
+ schedule = nil
1233
+ discord_channel_id = nil
1234
+ forum_title = nil
1235
+
1236
+ OptionParser.new do |opts|
1237
+ opts.banner = "Usage: zillacore cron update <job-id> [options]"
1238
+ opts.on("-s", "--schedule EXPR", "New cron expression") { |v| schedule = v }
1239
+ opts.on("-c", "--channel CHANNEL_ID", "New Discord channel ID") { |v| discord_channel_id = v }
1240
+ opts.on("-t", "--title TITLE", "New forum post title") { |v| forum_title = v }
1241
+ end.parse!
1242
+
1243
+ unless id && (schedule || discord_channel_id || forum_title)
1244
+ puts "Error: Missing required arguments"
1245
+ puts "Usage: zillacore cron update <job-id> -s \"42 13 * * 1-5\""
1246
+ puts " or: zillacore cron update <job-id> -c \"1423854179880927274\""
1247
+ puts " or: zillacore cron update <job-id> -t \"Daily Update\""
1248
+ puts " or: zillacore cron update <job-id> -s \"42 13 * * 1-5\" -c \"1423854179880927274\""
1249
+ exit 1
1250
+ end
1251
+
1252
+ uri = URI("#{server_url}/api/cron/update")
1253
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1254
+ payload = { id: id }
1255
+ payload[:schedule] = schedule if schedule
1256
+ payload[:discord_channel_id] = discord_channel_id if discord_channel_id
1257
+ payload[:forum_title] = forum_title if forum_title
1258
+ req.body = payload.to_json
1259
+
1260
+ begin
1261
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
1262
+ data = JSON.parse(response.body)
1263
+ if data["success"]
1264
+ puts "✓ Updated cron job: #{id}"
1265
+ puts " New schedule: #{schedule}" if schedule
1266
+ puts " New Discord channel: #{discord_channel_id}" if discord_channel_id
1267
+ puts " New forum title: #{forum_title}" if forum_title
1268
+ else
1269
+ puts "Error: #{data["error"]}"
1270
+ exit 1
1271
+ end
1272
+ rescue StandardError => e
1273
+ puts "Could not reach server: #{e.message}"
1274
+ exit 1
1275
+ end
1276
+
1277
+ else
1278
+ puts <<~HELP
1279
+ Usage: zillacore cron <command>
1280
+
1281
+ Commands:
1282
+ add -s <schedule> -p <project> [-a <agent>] "<prompt>"
1283
+ Add a new cron job
1284
+ list List all cron jobs
1285
+ remove <job-id> Remove a cron job
1286
+ enable <job-id> Enable a cron job
1287
+ disable <job-id> Disable a cron job
1288
+ update <job-id> [-s <schedule>] [-c <channel>]
1289
+ Update a cron job's schedule and/or Discord channel
1290
+
1291
+ Schedule format:
1292
+ Cron expression: "minute hour day month weekday"
1293
+ Examples:
1294
+ "0 9 * * 1-5" — 9am on weekdays
1295
+ "0 */4 * * *" — Every 4 hours
1296
+ "30 17 * * *" — 5:30pm daily
1297
+
1298
+ Special strings:
1299
+ @hourly — Every hour (minute 0)
1300
+ @daily — Every day at midnight
1301
+ @weekly — Every Sunday at midnight
1302
+ @monthly — First day of month at midnight
1303
+
1304
+ One-time (natural language):
1305
+ "tomorrow at 9am"
1306
+ "in 2 hours"
1307
+ "next monday at 3pm"
1308
+ #{" "}
1309
+ One-time (ISO8601):
1310
+ "2026-02-27T09:00:00-05:00"
1311
+
1312
+ Examples:
1313
+ # Recurring
1314
+ zillacore cron add -s "0 9 * * *" -p marketplace "Summarize yesterday's work"
1315
+ zillacore cron add -s "@daily" -p zillacore -a GLaDOS "Run security audit"
1316
+ #{" "}
1317
+ # One-time
1318
+ zillacore cron add -s "tomorrow at 9am" -p marketplace "Reminder about priorities"
1319
+ zillacore cron add -s "in 30 minutes" -p zillacore "Follow up on PR"
1320
+ #{" "}
1321
+ # Management
1322
+ zillacore cron list
1323
+ zillacore cron update post-morning-message -s "42 13 * * 1-5"
1324
+ zillacore cron disable daily-summary
1325
+ zillacore cron remove daily-summary
1326
+
1327
+ Config file: #{ZILLACORE_DIR}/cron.json
1328
+ HELP
1329
+ end
1330
+
1331
+ when "provider"
1332
+ provider_cmd = ARGV.shift
1333
+ providers_dir = File.join(ZILLACORE_DIR, "cli-providers")
1334
+
1335
+ case provider_cmd
1336
+ when "list", "ls"
1337
+ unless Dir.exist?(providers_dir)
1338
+ puts "No providers configured. Run 'zillacore provider add <name>' to create one."
1339
+ exit 0
1340
+ end
1341
+ files = Dir.glob(File.join(providers_dir, "*.json"))
1342
+ if files.empty?
1343
+ puts "No providers configured."
1344
+ else
1345
+ files.each do |f|
1346
+ name = File.basename(f, ".json")
1347
+ data = JSON.parse(File.read(f))
1348
+ puts "#{name} — #{data["binary"]} (#{data["models"]&.size || 0} models)"
1349
+ end
1350
+ end
1351
+
1352
+ when "show"
1353
+ name = ARGV[0]
1354
+ unless name
1355
+ puts "Usage: zillacore provider show <name>"
1356
+ exit 1
1357
+ end
1358
+ file = File.join(providers_dir, "#{name}.json")
1359
+ unless File.exist?(file)
1360
+ puts "Provider '#{name}' not found."
1361
+ exit 1
1362
+ end
1363
+ puts JSON.pretty_generate(JSON.parse(File.read(file)))
1364
+
1365
+ when "add", "create"
1366
+ name = ARGV[0]
1367
+ unless name
1368
+ puts "Usage: zillacore provider add <name>"
1369
+ exit 1
1370
+ end
1371
+ FileUtils.mkdir_p(providers_dir)
1372
+ file = File.join(providers_dir, "#{name}.json")
1373
+ if File.exist?(file)
1374
+ puts "Provider '#{name}' already exists. Edit #{file} directly."
1375
+ exit 1
1376
+ end
1377
+ scaffold = {
1378
+ "binary" => name,
1379
+ "default_args" => "",
1380
+ "model_flag" => "--model",
1381
+ "list_models_command" => "",
1382
+ "models" => {}
1383
+ }
1384
+ File.write(file, JSON.pretty_generate(scaffold))
1385
+ puts "Created #{file} — edit it to configure the provider."
1386
+
1387
+ else
1388
+ puts <<~HELP
1389
+ Usage: zillacore provider <command>
1390
+
1391
+ Commands:
1392
+ list List configured CLI providers
1393
+ show <name> Show provider configuration
1394
+ add <name> Create a new provider config
1395
+ HELP
1396
+ end
1397
+
1398
+ when "version", "--version", "-v"
1399
+ puts "zillacore #{ZILLACORE_VERSION}"
1400
+
1401
+ when "help", "--help", "-h", nil
1402
+ puts <<~HELP
1403
+ ZillaCore CLI - Manage projects with ZillaCore server
1404
+
1405
+ Usage:
1406
+ zillacore setup Bootstrap a new machine (deps, dirs, configs)
1407
+ zillacore server [options] Start the ZillaCore webhook server (foreground)
1408
+ zillacore stop Stop the running server
1409
+ zillacore restart Restart the server
1410
+ zillacore logs Tail the server log
1411
+ zillacore status Check if server is running
1412
+ zillacore register [options] Register current directory as a project
1413
+ zillacore unregister <key> Unregister a project
1414
+ zillacore list List all registered projects
1415
+ zillacore projects list List all registered projects (alias)
1416
+ zillacore projects default <key> Set the default project
1417
+ zillacore show <key> Show project configuration
1418
+ zillacore brain <command> Manage agent long-term memory (brain)
1419
+ zillacore discord <command> Manage the Discord bot
1420
+ zillacore cron <command> Manage scheduled agent tasks
1421
+ zillacore provider <command> Manage CLI providers
1422
+ zillacore config Configure ZillaCore CLI
1423
+ zillacore path Show ZillaCore config directory
1424
+ zillacore version Show version
1425
+ zillacore help Show this help message
1426
+
1427
+ Server Options:
1428
+ -d, --daemon Run server in background (detached)
1429
+
1430
+ Card Map Commands:
1431
+ card-map list Show all card map entries
1432
+ card-map clean Remove stale entries (missing worktrees)
1433
+
1434
+ Brain Commands:
1435
+ brain init [agent] Initialize brain (knowledge + persona)
1436
+ brain status [agent] Show brain status and files
1437
+ brain search [--persona] <q> Search knowledge or persona
1438
+ brain list List knowledge and all personas
1439
+ brain path Show knowledge directory
1440
+
1441
+ Cron Commands:
1442
+ cron add -s <schedule> -p <project> "<prompt>"
1443
+ Add a scheduled task
1444
+ cron list List all cron jobs
1445
+ cron remove <job-id> Remove a cron job
1446
+ cron enable <job-id> Enable a cron job
1447
+ cron disable <job-id> Disable a cron job
1448
+ cron update <job-id> -s <schedule>
1449
+ Update a cron job's schedule
1450
+
1451
+ Discord Commands:
1452
+ discord config Show Discord config
1453
+ discord default <proj> Set default project for all channels
1454
+ discord map <ch> <proj> Map a channel to a project
1455
+ discord owner [<id>] Set/show machine owner (version notifications)
1456
+ discord token <agent> <token> Set Discord bot token for an agent
1457
+ discord agents List agents with Discord bot tokens
1458
+ discord status Check bot status via server API
1459
+
1460
+ Provider Commands:
1461
+ provider list List configured CLI providers
1462
+ provider show <name> Show provider configuration
1463
+ provider add <name> Create a new provider config
1464
+
1465
+ Examples:
1466
+ # Start the server in the foreground (like rails server)
1467
+ zillacore server
1468
+ zillacore s
1469
+ #{" "}
1470
+ # Start the server in background (detached)
1471
+ zillacore server --daemon
1472
+ zillacore server -d
1473
+ #{" "}
1474
+ # Tail the server logs
1475
+ zillacore logs
1476
+ #{" "}
1477
+ # Restart the server
1478
+ zillacore restart
1479
+ #{" "}
1480
+ # Stop the server
1481
+ zillacore stop
1482
+ #{" "}
1483
+ # Check server status
1484
+ zillacore status
1485
+ #{" "}
1486
+ # Register current directory
1487
+ cd ~/Code/my-project && zillacore register
1488
+ #{" "}
1489
+ # Register with specific path
1490
+ zillacore register --path ~/Code/my-project
1491
+ #{" "}
1492
+ # List all projects
1493
+ zillacore list
1494
+ zillacore projects list # alternative syntax
1495
+ #{" "}
1496
+ # Set default project
1497
+ zillacore projects default my-project
1498
+ #{" "}
1499
+ # Unregister a project
1500
+ zillacore unregister my-project
1501
+ #{" "}
1502
+ # Show project details
1503
+ zillacore show my-project
1504
+ #{" "}
1505
+ # Initialize brain for your agent
1506
+ zillacore brain init Galen
1507
+ #{" "}
1508
+ # Search the brain
1509
+ zillacore brain search "ruby conventions"
1510
+
1511
+ Configuration:
1512
+ Projects are stored in: #{ZILLACORE_DIR}/projects.json
1513
+ Server config: #{ZILLACORE_DIR}/config.json
1514
+ Brain storage: #{ZILLACORE_DIR}/brain/
1515
+ HELP
1516
+
1517
+ else
1518
+ puts "Unknown command: #{subcommand}"
1519
+ puts "Run 'zillacore help' for usage information."
1520
+ exit 1
1521
+ end