Skip to content

Commit f5a23f7

Browse files
nikomatsakisclaude
andcommitted
feat(yopo): add yopo binary crate and simplify example
Create a new 'yopo' (You Only Prompt Once) binary crate that provides a full-featured ACP client supporting both command strings and JSON configurations. This crate can use sacp-tokio without circular dependency issues. Simplify the yolo_one_shot_client example to: - Use clap for argument parsing - Only support simple command strings (not JSON configs) - Point users to the yopo crate for full features This gives us both a useful utility (yopo) and a cleaner educational example (yolo_one_shot_client). Co-authored-by: Claude <claude@anthropic.com>
1 parent 3481d89 commit f5a23f7

File tree

6 files changed

+201
-94
lines changed

6 files changed

+201
-94
lines changed

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ members = [
55
"src/sacp",
66
"src/sacp-tokio",
77
"src/elizacp",
8-
"src/sacp-test",
8+
"src/sacp-test", "src/yopo",
99
]
1010
resolver = "2"
1111

src/sacp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ tracing.workspace = true
2121
uuid.workspace = true
2222

2323
[dev-dependencies]
24+
clap = { workspace = true, features = ["derive"] }
2425
expect-test.workspace = true
2526
shell-words = "1.1"
2627
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "io-util", "time", "process"] }

src/sacp/examples/yolo_one_shot_client.rs

Lines changed: 40 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,54 @@
11
//! YOLO one-shot client: A simple ACP client that runs a single prompt against an agent.
22
//!
3-
//! This client:
4-
//! - Takes a prompt and agent configuration as arguments
5-
//! - Spawns the agent
6-
//! - Sends the prompt
7-
//! - Auto-approves all permission requests
8-
//! - Prints all session updates to stdout
9-
//! - Runs until the agent completes
3+
//! This is a simplified example showing basic ACP client usage. It only supports
4+
//! simple command strings (not JSON configs or environment variables).
105
//!
11-
//! # Usage
6+
//! For a more full-featured client with JSON config support, see the `yopo` binary crate.
127
//!
13-
//! With a command:
14-
//! ```bash
15-
//! cargo run --example yolo_one_shot_client -- "What is 2+2?" "python my_agent.py"
16-
//! ```
8+
//! # Usage
179
//!
18-
//! With JSON config:
1910
//! ```bash
20-
//! cargo run --example yolo_one_shot_client -- "Hello!" '{"type":"stdio","name":"my-agent","command":"python","args":["agent.py"],"env":[]}'
11+
//! cargo run --example yolo_one_shot_client -- --command "python my_agent.py" "What is 2+2?"
2112
//! ```
2213
14+
use clap::Parser;
2315
use sacp::JrConnection;
2416
use sacp::schema::{
25-
ContentBlock, InitializeRequest, McpServer, NewSessionRequest, PromptRequest,
26-
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
27-
SessionNotification, TextContent, VERSION as PROTOCOL_VERSION,
17+
ContentBlock, InitializeRequest, NewSessionRequest, PromptRequest, RequestPermissionOutcome,
18+
RequestPermissionRequest, RequestPermissionResponse, SessionNotification, TextContent,
19+
VERSION as PROTOCOL_VERSION,
2820
};
2921
use std::path::PathBuf;
3022
use tokio::process::Child;
3123
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
3224

33-
// NOTE: This code is inlined here to avoid a circular dependency between `sacp` and `sacp-tokio`.
34-
// If you're writing your own client, you can use `sacp_tokio::AcpAgent` and `JrConnectionExt::to_agent()`
35-
// to simplify this setup significantly.
36-
37-
/// Parse agent configuration from either a command string or JSON.
38-
fn parse_agent_config(s: &str) -> Result<McpServer, Box<dyn std::error::Error>> {
39-
let trimmed = s.trim();
25+
#[derive(Parser)]
26+
#[command(name = "yolo-one-shot-client")]
27+
#[command(about = "A simple ACP client for one-shot prompts", long_about = None)]
28+
struct Cli {
29+
/// The command to run the agent (e.g., "python my_agent.py")
30+
#[arg(short, long)]
31+
command: String,
4032

41-
// If it starts with '{', try to parse as JSON
42-
if trimmed.starts_with('{') {
43-
let server: McpServer = serde_json::from_str(trimmed)?;
44-
return Ok(server);
45-
}
33+
/// The prompt to send to the agent
34+
prompt: String,
35+
}
4636

47-
// Otherwise, parse as a command string
48-
let parts = shell_words::split(trimmed)?;
37+
/// Parse a command string into command and args
38+
fn parse_command_string(s: &str) -> Result<(PathBuf, Vec<String>), Box<dyn std::error::Error>> {
39+
let parts = shell_words::split(s)?;
4940
if parts.is_empty() {
5041
return Err("Command string cannot be empty".into());
5142
}
52-
5343
let command = PathBuf::from(&parts[0]);
5444
let args = parts[1..].to_vec();
55-
let name = command
56-
.file_name()
57-
.and_then(|n| n.to_str())
58-
.unwrap_or("agent")
59-
.to_string();
60-
61-
Ok(McpServer::Stdio {
62-
name,
63-
command,
64-
args,
65-
env: vec![],
66-
})
45+
Ok((command, args))
6746
}
6847

6948
/// Spawn a process for the agent and get stdio streams.
7049
fn spawn_agent_process(
71-
server: &McpServer,
50+
command: PathBuf,
51+
args: Vec<String>,
7252
) -> Result<
7353
(
7454
tokio::process::ChildStdin,
@@ -77,64 +57,31 @@ fn spawn_agent_process(
7757
),
7858
Box<dyn std::error::Error>,
7959
> {
80-
match server {
81-
McpServer::Stdio {
82-
command,
83-
args,
84-
env,
85-
name: _,
86-
} => {
87-
let mut cmd = tokio::process::Command::new(command);
88-
cmd.args(args);
89-
for env_var in env {
90-
cmd.env(&env_var.name, &env_var.value);
91-
}
92-
cmd.stdin(std::process::Stdio::piped())
93-
.stdout(std::process::Stdio::piped());
60+
let mut cmd = tokio::process::Command::new(&command);
61+
cmd.args(&args);
62+
cmd.stdin(std::process::Stdio::piped())
63+
.stdout(std::process::Stdio::piped());
9464

95-
let mut child = cmd.spawn()?;
96-
let child_stdin = child.stdin.take().ok_or("Failed to open stdin")?;
97-
let child_stdout = child.stdout.take().ok_or("Failed to open stdout")?;
65+
let mut child = cmd.spawn()?;
66+
let child_stdin = child.stdin.take().ok_or("Failed to open stdin")?;
67+
let child_stdout = child.stdout.take().ok_or("Failed to open stdout")?;
9868

99-
Ok((child_stdin, child_stdout, child))
100-
}
101-
McpServer::Http { .. } => Err("HTTP transport not yet supported".into()),
102-
McpServer::Sse { .. } => Err("SSE transport not yet supported".into()),
103-
}
69+
Ok((child_stdin, child_stdout, child))
10470
}
10571

10672
#[tokio::main]
10773
async fn main() -> Result<(), Box<dyn std::error::Error>> {
108-
// Parse command line arguments
109-
let args: Vec<String> = std::env::args().collect();
110-
if args.len() != 3 {
111-
eprintln!("Usage: {} <prompt> <agent-config>", args[0]);
112-
eprintln!();
113-
eprintln!(" <prompt> - The prompt to send to the agent");
114-
eprintln!(" <agent-config> - Either a command string or JSON (starting with '{{')");
115-
eprintln!();
116-
eprintln!("Examples:");
117-
eprintln!(" {} \"What is 2+2?\" \"python my_agent.py\"", args[0]);
118-
eprintln!(
119-
" {} \"Hello!\" '{{\"type\":\"stdio\",\"name\":\"agent\",\"command\":\"python\",\"args\":[\"agent.py\"],\"env\":[]}}'",
120-
args[0]
121-
);
122-
std::process::exit(1);
123-
}
124-
125-
let prompt = &args[1];
126-
let agent_config = &args[2];
74+
let cli = Cli::parse();
12775

128-
// Parse the agent configuration
129-
let server = parse_agent_config(agent_config)?;
76+
// Parse the command string
77+
let (command, args) = parse_command_string(&cli.command)?;
13078

131-
eprintln!("🚀 Spawning agent and connecting...");
79+
eprintln!("🚀 Spawning agent: {} {:?}", command.display(), args);
13280

13381
// Spawn the agent process
134-
let (child_stdin, child_stdout, mut child) = spawn_agent_process(&server)?;
82+
let (child_stdin, child_stdout, mut child) = spawn_agent_process(command, args)?;
13583

13684
// Create a JrConnection with the agent's stdio streams
137-
// NOTE: Using sacp_tokio::JrConnectionExt::to_agent() would simplify this setup
13885
let connection = JrConnection::new(child_stdin.compat_write(), child_stdout.compat());
13986

14087
// Run the client
@@ -192,12 +139,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
192139
eprintln!("✓ Session created: {}", session_id);
193140

194141
// Send the prompt
195-
eprintln!("💬 Sending prompt: \"{}\"", prompt);
142+
eprintln!("💬 Sending prompt: \"{}\"", cli.prompt);
196143
let prompt_response = cx
197144
.send_request(PromptRequest {
198145
session_id: session_id.clone(),
199146
prompt: vec![ContentBlock::Text(TextContent {
200-
text: prompt.to_string(),
147+
text: cli.prompt.clone(),
201148
annotations: None,
202149
meta: None,
203150
})],

src/yopo/Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
name = "yopo"
3+
version = "1.0.0-alpha"
4+
edition = "2024"
5+
description = "YOPO (You Only Prompt Once) - A simple ACP client for one-shot prompts"
6+
license = "MIT OR Apache-2.0"
7+
repository = "https://github.com/symposium-dev/symposium-acp"
8+
9+
[dependencies]
10+
sacp = { version = "1.0.0-alpha", path = "../sacp" }
11+
sacp-tokio = { version = "1.0.0-alpha", path = "../sacp-tokio" }
12+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

src/yopo/src/main.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! YOPO (You Only Prompt Once) - A simple ACP client for one-shot prompts
2+
//!
3+
//! This client:
4+
//! - Takes a prompt and agent configuration as arguments
5+
//! - Spawns the agent
6+
//! - Sends the prompt
7+
//! - Auto-approves all permission requests
8+
//! - Prints all session updates to stdout
9+
//! - Runs until the agent completes
10+
//!
11+
//! # Usage
12+
//!
13+
//! With a command:
14+
//! ```bash
15+
//! yopo "What is 2+2?" "python my_agent.py"
16+
//! ```
17+
//!
18+
//! With JSON config:
19+
//! ```bash
20+
//! yopo "Hello!" '{"type":"stdio","name":"my-agent","command":"python","args":["agent.py"],"env":[]}'
21+
//! ```
22+
23+
use sacp::JrConnection;
24+
use sacp::schema::{
25+
ContentBlock, InitializeRequest, NewSessionRequest, PromptRequest, RequestPermissionOutcome,
26+
RequestPermissionRequest, RequestPermissionResponse, SessionNotification, TextContent,
27+
VERSION as PROTOCOL_VERSION,
28+
};
29+
use sacp_tokio::{AcpAgent, JrConnectionExt};
30+
use std::path::PathBuf;
31+
use std::str::FromStr;
32+
33+
#[tokio::main]
34+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
35+
// Parse command line arguments
36+
let args: Vec<String> = std::env::args().collect();
37+
if args.len() != 3 {
38+
eprintln!("Usage: {} <prompt> <agent-config>", args[0]);
39+
eprintln!();
40+
eprintln!(" <prompt> - The prompt to send to the agent");
41+
eprintln!(" <agent-config> - Either a command string or JSON (starting with '{{')");
42+
eprintln!();
43+
eprintln!("Examples:");
44+
eprintln!(" {} \"What is 2+2?\" \"python my_agent.py\"", args[0]);
45+
eprintln!(
46+
" {} \"Hello!\" '{{\"type\":\"stdio\",\"name\":\"agent\",\"command\":\"python\",\"args\":[\"agent.py\"],\"env\":[]}}'",
47+
args[0]
48+
);
49+
std::process::exit(1);
50+
}
51+
52+
let prompt = &args[1];
53+
let agent_config = &args[2];
54+
55+
// Parse the agent configuration
56+
let agent = AcpAgent::from_str(agent_config)?;
57+
58+
eprintln!("🚀 Spawning agent and connecting...");
59+
60+
// Run the client
61+
JrConnection::to_agent(agent)?
62+
.on_receive_notification(async move |notification: SessionNotification, _cx| {
63+
// Print session updates to stdout (so 2>/dev/null shows only agent output)
64+
println!("{:?}", notification.update);
65+
Ok(())
66+
})
67+
.on_receive_request(async move |request: RequestPermissionRequest, request_cx| {
68+
// YOPO: Auto-approve all permission requests by selecting the first option
69+
eprintln!("✅ Auto-approving permission request: {:?}", request);
70+
let option_id = request.options.first().map(|opt| opt.id.clone());
71+
match option_id {
72+
Some(id) => request_cx.respond(RequestPermissionResponse {
73+
outcome: RequestPermissionOutcome::Selected { option_id: id },
74+
meta: None,
75+
}),
76+
None => {
77+
eprintln!("⚠️ No options provided in permission request, cancelling");
78+
request_cx.respond(RequestPermissionResponse {
79+
outcome: RequestPermissionOutcome::Cancelled,
80+
meta: None,
81+
})
82+
}
83+
}
84+
})
85+
.with_client(|cx: sacp::JrConnectionCx| async move {
86+
// Initialize the agent
87+
eprintln!("🤝 Initializing agent...");
88+
let init_response = cx
89+
.send_request(InitializeRequest {
90+
protocol_version: PROTOCOL_VERSION,
91+
client_capabilities: Default::default(),
92+
client_info: Default::default(),
93+
meta: None,
94+
})
95+
.block_task()
96+
.await?;
97+
98+
eprintln!("✓ Agent initialized: {:?}", init_response.agent_info);
99+
100+
// Create a new session
101+
eprintln!("📝 Creating new session...");
102+
let new_session_response = cx
103+
.send_request(NewSessionRequest {
104+
mcp_servers: vec![],
105+
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
106+
meta: None,
107+
})
108+
.block_task()
109+
.await?;
110+
111+
let session_id = new_session_response.session_id;
112+
eprintln!("✓ Session created: {}", session_id);
113+
114+
// Send the prompt
115+
eprintln!("💬 Sending prompt: \"{}\"", prompt);
116+
let prompt_response = cx
117+
.send_request(PromptRequest {
118+
session_id: session_id.clone(),
119+
prompt: vec![ContentBlock::Text(TextContent {
120+
text: prompt.to_string(),
121+
annotations: None,
122+
meta: None,
123+
})],
124+
meta: None,
125+
})
126+
.block_task()
127+
.await?;
128+
129+
eprintln!("✅ Agent completed!");
130+
eprintln!("Stop reason: {:?}", prompt_response.stop_reason);
131+
132+
Ok(())
133+
})
134+
.await?;
135+
136+
Ok(())
137+
}

0 commit comments

Comments
 (0)