@masyv/relay 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/main.rs CHANGED
@@ -1,22 +1,20 @@
1
1
  use anyhow::Result;
2
2
  use clap::{Parser, Subcommand};
3
- use colored::Colorize;
4
3
  use std::path::PathBuf;
5
4
 
6
- use relay::{agents, capture, handoff, Config};
5
+ use relay::{agents, capture, handoff, tui, Config};
7
6
 
8
7
  #[derive(Parser)]
9
8
  #[command(
10
9
  name = "relay",
11
10
  about = "Relay — When Claude's rate limit hits, another agent picks up where you left off.",
12
- long_about = "Captures your Claude Code session state (task, todos, git diff, decisions,\nerrors) and hands it off to Codex, Gemini, Ollama, or GPT-4 — so your\nwork never stops.",
13
11
  version
14
12
  )]
15
13
  struct Cli {
16
14
  #[command(subcommand)]
17
15
  command: Commands,
18
16
 
19
- /// Output as JSON
17
+ /// Output as JSON (no TUI)
20
18
  #[arg(long, global = true)]
21
19
  json: bool,
22
20
 
@@ -31,17 +29,17 @@ struct Cli {
31
29
 
32
30
  #[derive(Subcommand)]
33
31
  enum Commands {
34
- /// Hand off current session to a fallback agent right now
32
+ /// Hand off current session to a fallback agent
35
33
  Handoff {
36
- /// Force a specific agent (codex, gemini, ollama, openai)
34
+ /// Target agent (codex, claude, aider, gemini, copilot, opencode, ollama, openai)
37
35
  #[arg(long)]
38
36
  to: Option<String>,
39
37
 
40
- /// Set deadline urgency (e.g. "7pm", "19:00", "30min")
38
+ /// Set deadline urgency (e.g. "7pm", "30min")
41
39
  #[arg(long)]
42
40
  deadline: Option<String>,
43
41
 
44
- /// Don't execute — just print the handoff package
42
+ /// Just print the handoff — don't launch agent
45
43
  #[arg(long)]
46
44
  dry_run: bool,
47
45
 
@@ -52,20 +50,40 @@ enum Commands {
52
50
  /// What to include: all, conversation, git, todos (comma-separated)
53
51
  #[arg(long, default_value = "all")]
54
52
  include: String,
53
+
54
+ /// Copy handoff to clipboard instead of launching agent
55
+ #[arg(long)]
56
+ clipboard: bool,
57
+
58
+ /// Handoff template: full (default), minimal, raw
59
+ #[arg(long, default_value = "full")]
60
+ template: String,
55
61
  },
56
62
 
57
- /// Show current session snapshot (what would be handed off)
63
+ /// Show current session snapshot
58
64
  Status,
59
65
 
60
- /// List configured agents and their availability
66
+ /// List configured agents and availability
61
67
  Agents,
62
68
 
63
- /// Generate default config file at ~/.relay/config.toml
69
+ /// Resume after rate limit resets — show what happened during handoff
70
+ Resume,
71
+
72
+ /// List past handoffs
73
+ History {
74
+ /// Number of entries to show
75
+ #[arg(long, default_value = "10")]
76
+ limit: usize,
77
+ },
78
+
79
+ /// Show what changed since the last handoff
80
+ Diff,
81
+
82
+ /// Generate default config at ~/.relay/config.toml
64
83
  Init,
65
84
 
66
- /// PostToolUse hook mode (auto-detect rate limits from stdin)
85
+ /// PostToolUse hook (auto-detect rate limits)
67
86
  Hook {
68
- /// Session ID
69
87
  #[arg(long, default_value = "unknown")]
70
88
  session: String,
71
89
  },
@@ -92,286 +110,317 @@ fn main() -> Result<()> {
92
110
  });
93
111
 
94
112
  match cli.command {
95
- Commands::Handoff { to, deadline, dry_run, turns, include } => {
96
- eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
113
+ // ═══════════════════════════════════════════════════════════════
114
+ // HANDOFF
115
+ // ═══════════════════════════════════════════════════════════════
116
+ Commands::Handoff { to, deadline, dry_run, turns, include, clipboard, template } => {
117
+ if !cli.json {
118
+ tui::print_banner();
119
+ }
120
+
121
+ // Step 1: Capture
122
+ let sp = if !cli.json { Some(tui::step(1, 3, "Capturing session state...")) } else { None };
97
123
 
98
- // Set conversation turn limit before capture
99
124
  relay::capture::session::MAX_CONVERSATION_TURNS
100
125
  .store(turns, std::sync::atomic::Ordering::Relaxed);
101
126
 
102
- let mut snapshot = capture::capture_snapshot(
103
- &project_dir,
104
- deadline.as_deref(),
105
- )?;
127
+ let mut snapshot = capture::capture_snapshot(&project_dir, deadline.as_deref())?;
106
128
 
107
- // Filter sections based on --include flag
129
+ // Apply include filter
108
130
  let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
109
131
  if !includes.contains(&"all") {
110
- if !includes.contains(&"conversation") {
111
- snapshot.conversation.clear();
112
- }
113
- if !includes.contains(&"git") {
114
- snapshot.git_state = None;
115
- snapshot.recent_files.clear();
116
- }
117
- if !includes.contains(&"todos") {
118
- snapshot.todos.clear();
119
- }
132
+ if !includes.contains(&"conversation") { snapshot.conversation.clear(); }
133
+ if !includes.contains(&"git") { snapshot.git_state = None; snapshot.recent_files.clear(); }
134
+ if !includes.contains(&"todos") { snapshot.todos.clear(); }
120
135
  }
121
136
 
122
- let target = to.as_deref().unwrap_or("auto");
123
- let handoff_text = handoff::build_handoff(
124
- &snapshot,
125
- target,
126
- config.general.max_context_tokens,
127
- )?;
137
+ if let Some(sp) = sp { sp.finish_with_message("Session captured"); }
138
+
139
+ // Step 2: Build handoff
140
+ let sp = if !cli.json { Some(tui::step(2, 3, "Building handoff package...")) } else { None };
141
+
142
+ // Resolve target agent
143
+ let target_name = if let Some(ref name) = to {
144
+ name.clone()
145
+ } else if !cli.json && !dry_run {
146
+ // Interactive agent selection
147
+ if let Some(sp) = sp.as_ref() { sp.finish_with_message("Handoff built"); }
148
+
149
+ let statuses = agents::check_all_agents(&config);
150
+ let agent_list: Vec<(String, bool, String)> = statuses
151
+ .iter()
152
+ .map(|s| (s.name.clone(), s.available, s.reason.clone()))
153
+ .collect();
154
+
155
+ match tui::select_agent(&agent_list) {
156
+ Some(name) => name,
157
+ None => {
158
+ eprintln!(" No agent selected.");
159
+ return Ok(());
160
+ }
161
+ }
162
+ } else {
163
+ "auto".into()
164
+ };
128
165
 
129
- // Save handoff file for reference
166
+ // Build handoff using selected template
167
+ let handoff_text = match handoff::templates::Template::from_str(&template) {
168
+ handoff::templates::Template::Minimal => {
169
+ handoff::templates::build_minimal(&snapshot, &target_name)
170
+ }
171
+ handoff::templates::Template::Raw => {
172
+ handoff::templates::build_raw(&snapshot)
173
+ }
174
+ handoff::templates::Template::Full => {
175
+ handoff::build_handoff(&snapshot, &target_name, config.general.max_context_tokens)?
176
+ }
177
+ };
130
178
  let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
131
179
 
132
- if dry_run || cli.json {
133
- if cli.json {
134
- let result = serde_json::json!({
135
- "snapshot": snapshot,
136
- "handoff_text": handoff_text,
137
- "handoff_file": handoff_path.to_string_lossy(),
138
- "target_agent": target,
139
- });
140
- println!("{}", serde_json::to_string_pretty(&result)?);
141
- } else {
142
- println!("{handoff_text}");
143
- eprintln!();
144
- eprintln!("{}", format!("📄 Saved to: {}", handoff_path.display()).dimmed());
180
+ if let Some(sp) = sp { sp.finish_with_message("Handoff built"); }
181
+
182
+ // JSON / dry-run output
183
+ if cli.json {
184
+ println!("{}", serde_json::to_string_pretty(&serde_json::json!({
185
+ "snapshot": snapshot,
186
+ "handoff_text": handoff_text,
187
+ "handoff_file": handoff_path.to_string_lossy(),
188
+ "target_agent": target_name,
189
+ }))?);
190
+ return Ok(());
191
+ }
192
+ // Clipboard mode
193
+ if clipboard {
194
+ #[cfg(target_os = "macos")]
195
+ {
196
+ use std::process::{Command, Stdio};
197
+ let mut child = Command::new("pbcopy")
198
+ .stdin(Stdio::piped())
199
+ .spawn()?;
200
+ if let Some(mut stdin) = child.stdin.take() {
201
+ use std::io::Write;
202
+ stdin.write_all(handoff_text.as_bytes())?;
203
+ }
204
+ child.wait()?;
205
+ eprintln!(" 📋 Handoff copied to clipboard!");
206
+ eprintln!(" 📄 Also saved: {}", handoff_path.display());
207
+ }
208
+ #[cfg(not(target_os = "macos"))]
209
+ {
210
+ eprintln!(" Clipboard not supported on this platform.");
211
+ eprintln!(" 📄 Saved to: {}", handoff_path.display());
145
212
  }
146
213
  return Ok(());
147
214
  }
215
+ if dry_run {
216
+ println!("{handoff_text}");
217
+ eprintln!();
218
+ eprintln!(" 📄 Saved: {}", handoff_path.display());
219
+ return Ok(());
220
+ }
148
221
 
149
- eprintln!("{}", format!("📄 Handoff saved: {}", handoff_path.display()).dimmed());
150
- eprintln!();
222
+ // Step 3: Launch agent
223
+ let sp = tui::step(3, 3, &format!("Launching {}...", target_name));
151
224
 
152
- // Execute handoff
153
- let result = if let Some(ref agent_name) = to {
154
- agents::handoff_to_named(&config, agent_name, &handoff_text, &project_dir.to_string_lossy())
225
+ let result = if to.is_some() {
226
+ agents::handoff_to_named(&config, &target_name, &handoff_text, &project_dir.to_string_lossy())
155
227
  } else {
156
228
  agents::handoff_to_first_available(&config, &handoff_text, &project_dir.to_string_lossy())
157
229
  }?;
158
230
 
231
+ sp.finish_with_message(if result.success {
232
+ format!("{} launched", target_name)
233
+ } else {
234
+ "Failed".into()
235
+ });
236
+
159
237
  if result.success {
160
- eprintln!("{}", format!("✅ Handed off to {}", result.agent).green().bold());
161
- eprintln!(" {}", result.message);
238
+ tui::print_handoff_success(&result.agent, &handoff_path.to_string_lossy());
162
239
  } else {
163
- eprintln!("{}", format!("❌ Handoff failed: {}", result.message).red());
164
- eprintln!();
165
- eprintln!("💡 The handoff context was saved to:");
166
- eprintln!(" {}", handoff_path.display());
167
- eprintln!(" You can copy-paste it into any AI assistant manually.");
240
+ tui::print_handoff_fail(&result.message, &handoff_path.to_string_lossy());
168
241
  }
169
242
  }
170
243
 
244
+ // ═══════════════════════════════════════════════════════════════
245
+ // STATUS
246
+ // ═══════════════════════════════════════════════════════════════
171
247
  Commands::Status => {
248
+ let sp = if !cli.json { Some(tui::spinner("Reading session state...")) } else { None };
172
249
  let snapshot = capture::capture_snapshot(&project_dir, None)?;
250
+ if let Some(sp) = sp { sp.finish_and_clear(); }
173
251
 
174
252
  if cli.json {
175
253
  println!("{}", serde_json::to_string_pretty(&snapshot)?);
176
- return Ok(());
254
+ } else {
255
+ tui::print_snapshot(&snapshot);
177
256
  }
257
+ }
178
258
 
179
- println!("{}", "═══ Relay Session Snapshot ═══".bold());
180
- println!();
181
- println!("{}: {}", "Project".bold(), snapshot.project_dir);
182
- println!("{}: {}", "Captured".bold(), snapshot.timestamp);
183
- println!();
184
-
185
- println!("{}", "── Current Task ──".cyan());
186
- println!(" {}", snapshot.current_task);
187
- println!();
188
-
189
- if !snapshot.todos.is_empty() {
190
- println!("{}", "── Todos ──".cyan());
191
- for t in &snapshot.todos {
192
- let icon = match t.status.as_str() {
193
- "completed" => "✅",
194
- "in_progress" => "🔄",
195
- _ => "⏳",
196
- };
197
- println!(" {icon} [{}] {}", t.status, t.content);
198
- }
199
- println!();
200
- }
259
+ // ═══════════════════════════════════════════════════════════════
260
+ // AGENTS
261
+ // ═══════════════════════════════════════════════════════════════
262
+ Commands::Agents => {
263
+ let sp = if !cli.json { Some(tui::spinner("Checking agents...")) } else { None };
264
+ let statuses = agents::check_all_agents(&config);
265
+ if let Some(sp) = sp { sp.finish_and_clear(); }
201
266
 
202
- if let Some(ref err) = snapshot.last_error {
203
- println!("{}", "── Last Error ──".red());
204
- println!(" {err}");
205
- println!();
267
+ if cli.json {
268
+ println!("{}", serde_json::to_string_pretty(&statuses)?);
269
+ } else {
270
+ tui::print_agents(&config.general.priority, &statuses);
206
271
  }
272
+ }
207
273
 
208
- if !snapshot.decisions.is_empty() {
209
- println!("{}", "── Decisions ──".cyan());
210
- for d in &snapshot.decisions {
211
- println!(" • {d}");
212
- }
213
- println!();
214
- }
274
+ // ═══════════════════════════════════════════════════════════════
275
+ // RESUME
276
+ // ═══════════════════════════════════════════════════════════════
277
+ Commands::Resume => {
278
+ let report = relay::resume::build_resume(&project_dir)?;
215
279
 
216
- if let Some(ref git) = snapshot.git_state {
217
- println!("{}", "── Git ──".cyan());
218
- println!(" Branch: {}", git.branch);
219
- println!(" {}", git.status_summary);
220
- if !git.recent_commits.is_empty() {
221
- println!(" Recent:");
222
- for c in git.recent_commits.iter().take(3) {
223
- println!(" {c}");
280
+ if cli.json {
281
+ println!("{}", serde_json::to_string_pretty(&report)?);
282
+ } else {
283
+ tui::print_section("🔄", "Resume — What Happened During Handoff");
284
+ eprintln!(" Handoff at: {}", report.handoff_time);
285
+ eprintln!(" Task: {}", report.original_task);
286
+ eprintln!();
287
+
288
+ if !report.new_commits.is_empty() {
289
+ tui::print_section("📝", &format!("New Commits ({})", report.new_commits.len()));
290
+ for c in &report.new_commits {
291
+ eprintln!(" {c}");
224
292
  }
225
293
  }
226
- println!();
227
- }
228
294
 
229
- if !snapshot.recent_files.is_empty() {
230
- println!("{}", "── Changed Files ──".cyan());
231
- for f in snapshot.recent_files.iter().take(10) {
232
- println!(" {f}");
295
+ if !report.changes_since.is_empty() {
296
+ tui::print_section("📄", &format!("Changed Files ({})", report.changes_since.len()));
297
+ for f in &report.changes_since {
298
+ eprintln!(" {f}");
299
+ }
233
300
  }
234
- println!();
235
- }
236
301
 
237
- if !snapshot.conversation.is_empty() {
238
- println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
239
- // Show last 15 turns
240
- let start = snapshot.conversation.len().saturating_sub(15);
241
- for turn in &snapshot.conversation[start..] {
242
- let prefix = match turn.role.as_str() {
243
- "user" => "👤 USER".to_string(),
244
- "assistant" => "🤖 CLAUDE".to_string(),
245
- "assistant_tool" => "🔧 TOOL".to_string(),
246
- "tool_result" => "📤 RESULT".to_string(),
247
- _ => turn.role.clone(),
248
- };
249
- let content = if turn.content.len() > 120 {
250
- format!("{}...", &turn.content[..117])
251
- } else {
252
- turn.content.clone()
253
- };
254
- println!(" {}: {}", prefix, content);
302
+ if !report.diff_stat.is_empty() {
303
+ tui::print_section("📊", "Diff Summary");
304
+ for line in report.diff_stat.lines() {
305
+ eprintln!(" {line}");
306
+ }
255
307
  }
256
- println!();
308
+
309
+ eprintln!();
310
+ eprintln!(" 📋 Resume prompt ready. Use --json to get the full prompt.");
257
311
  }
258
312
  }
259
313
 
260
- Commands::Agents => {
261
- let statuses = agents::check_all_agents(&config);
314
+ // ═══════════════════════════════════════════════════════════════
315
+ // HISTORY
316
+ // ═══════════════════════════════════════════════════════════════
317
+ Commands::History { limit } => {
318
+ let entries = relay::history::list_handoffs(&project_dir, limit)?;
262
319
 
263
320
  if cli.json {
264
- println!("{}", serde_json::to_string_pretty(&statuses)?);
321
+ println!("{}", serde_json::to_string_pretty(&entries)?);
322
+ return Ok(());
323
+ }
324
+
325
+ if entries.is_empty() {
326
+ eprintln!(" No handoffs recorded yet.");
265
327
  return Ok(());
266
328
  }
267
329
 
268
- println!("{}", "═══ Relay Agents ═══".bold());
269
- println!();
270
- println!("Priority order: {}", config.general.priority.join(" → "));
271
- println!();
272
-
273
- for s in &statuses {
274
- let icon = if s.available { "✅" } else { "❌" };
275
- let name = if s.available {
276
- s.name.green().bold().to_string()
277
- } else {
278
- s.name.dimmed().to_string()
279
- };
280
- println!(
281
- " {icon} {:<10} {}",
282
- name,
283
- s.reason
330
+ tui::print_section("📜", &format!("Handoff History ({} entries)", entries.len()));
331
+ eprintln!();
332
+ for e in &entries {
333
+ eprintln!(
334
+ " {} → {:<10} {}",
335
+ e.timestamp, e.agent, e.task
284
336
  );
285
- if let Some(ref v) = s.version {
286
- println!(" Version: {v}");
337
+ }
338
+ eprintln!();
339
+ }
340
+
341
+ // ═══════════════════════════════════════════════════════════════
342
+ // DIFF
343
+ // ═══════════════════════════════════════════════════════════════
344
+ Commands::Diff => {
345
+ let report = relay::diff::diff_since_handoff(&project_dir)?;
346
+
347
+ if cli.json {
348
+ println!("{}", serde_json::to_string_pretty(&report)?);
349
+ return Ok(());
350
+ }
351
+
352
+ tui::print_section("📊", "Changes Since Last Handoff");
353
+ eprintln!(" Handoff at: {}", report.handoff_time);
354
+ eprintln!(
355
+ " {} added, {} modified, {} deleted",
356
+ report.files_added, report.files_modified, report.files_deleted
357
+ );
358
+
359
+ if !report.new_commits.is_empty() {
360
+ eprintln!();
361
+ eprintln!(" Commits:");
362
+ for c in &report.new_commits {
363
+ eprintln!(" {c}");
287
364
  }
288
365
  }
289
- println!();
290
366
 
291
- let available = statuses.iter().filter(|s| s.available).count();
292
- if available == 0 {
293
- eprintln!("{}", "⚠️ No agents available. Run 'relay init' to configure.".yellow());
294
- } else {
295
- println!(
296
- " {} agent{} ready for handoff.",
297
- available,
298
- if available == 1 { "" } else { "s" }
299
- );
367
+ if !report.diff_stat.is_empty() {
368
+ eprintln!();
369
+ for line in report.diff_stat.lines() {
370
+ eprintln!(" {line}");
371
+ }
300
372
  }
373
+ eprintln!();
301
374
  }
302
375
 
376
+ // ═══════════════════════════════════════════════════════════════
377
+ // INIT
378
+ // ═══════════════════════════════════════════════════════════════
303
379
  Commands::Init => {
304
380
  let path = relay::config_path();
305
381
  if path.exists() {
306
- println!("Config already exists at: {}", path.display());
307
- println!("Edit it to add API keys and customize agent priority.");
382
+ eprintln!(" Config exists: {}", path.display());
383
+ eprintln!(" Edit to add API keys and customize priority.");
308
384
  } else {
309
385
  Config::save_default(&path)?;
310
- println!("{}", "✅ Config created at:".green());
311
- println!(" {}", path.display());
312
- println!();
313
- println!("Edit it to add API keys:");
314
- println!(" [agents.gemini]");
315
- println!(" api_key = \"your-gemini-key\"");
316
- println!();
317
- println!(" [agents.openai]");
318
- println!(" api_key = \"your-openai-key\"");
386
+ eprintln!(" ✅ Config created: {}", path.display());
387
+ eprintln!();
388
+ eprintln!(" Add API keys:");
389
+ eprintln!(" [agents.gemini]");
390
+ eprintln!(" api_key = \"your-key\"");
391
+ eprintln!();
392
+ eprintln!(" [agents.openai]");
393
+ eprintln!(" api_key = \"your-key\"");
319
394
  }
320
395
  }
321
396
 
397
+ // ═══════════════════════════════════════════════════════════════
398
+ // HOOK
399
+ // ═══════════════════════════════════════════════════════════════
322
400
  Commands::Hook { session: _ } => {
323
401
  use std::io::Read;
324
402
  let mut raw = String::new();
325
403
  std::io::stdin().read_to_string(&mut raw)?;
326
404
 
327
- // Check for rate limit signals
328
405
  if let Some(detection) = relay::detect::check_hook_output(&raw) {
329
406
  eprintln!(
330
- "{}",
331
- format!(
332
- "🚨 [relay] Rate limit detected in {} output (signal: {})",
333
- detection.tool_name, detection.signal
334
- ).red().bold()
407
+ " 🚨 Rate limit detected in {} (signal: {})",
408
+ detection.tool_name, detection.signal
335
409
  );
336
-
337
410
  if config.general.auto_handoff {
338
- // Auto-handoff
339
411
  let snapshot = capture::capture_snapshot(&project_dir, None)?;
340
- let handoff_text = handoff::build_handoff(
341
- &snapshot,
342
- "auto",
343
- config.general.max_context_tokens,
344
- )?;
345
-
412
+ let handoff_text = handoff::build_handoff(&snapshot, "auto", config.general.max_context_tokens)?;
346
413
  let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
347
- eprintln!(
348
- "📄 Handoff saved: {}",
349
- handoff_path.display()
350
- );
351
-
352
414
  let result = agents::handoff_to_first_available(
353
- &config,
354
- &handoff_text,
355
- &project_dir.to_string_lossy(),
415
+ &config, &handoff_text, &project_dir.to_string_lossy(),
356
416
  )?;
357
-
358
417
  if result.success {
359
- eprintln!(
360
- "{}",
361
- format!("✅ Auto-handed off to {}", result.agent).green()
362
- );
418
+ eprintln!(" ✅ Auto-handed off to {}", result.agent);
363
419
  } else {
364
- eprintln!(
365
- "{}",
366
- format!("⚠️ No agents available. Handoff saved to: {}",
367
- handoff_path.display()
368
- ).yellow()
369
- );
420
+ eprintln!(" 📄 Saved: {}", handoff_path.display());
370
421
  }
371
422
  }
372
423
  }
373
-
374
- // Always pass through the original output
375
424
  print!("{raw}");
376
425
  }
377
426
  }