diff --git a/README.md b/README.md index 3130f836b1..4b90a46e72 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production- | **Python** | [`cookbook/python/`](./cookbook/python/README.md) | `pip install github-copilot-sdk` | | **Go** | [`cookbook/go/`](./cookbook/go/README.md) | `go get github.com/github/copilot-sdk/go` | | **.NET** | [`cookbook/dotnet/`](./cookbook/dotnet/README.md) | `dotnet add package GitHub.Copilot.SDK` | +| **Rust** | [`rust/`](./rust/README.md) | `cargo add github-copilot-sdk` | See the individual SDK READMEs for installation, usage examples, and API reference. diff --git a/justfile b/justfile index 8b1af30c52..3601137333 100644 --- a/justfile +++ b/justfile @@ -3,13 +3,13 @@ default: @just --list # Format all code across all languages -format: format-go format-python format-nodejs format-dotnet +format: format-go format-python format-nodejs format-dotnet format-rust # Lint all code across all languages -lint: lint-go lint-python lint-nodejs lint-dotnet +lint: lint-go lint-python lint-nodejs lint-dotnet lint-rust # Run tests for all languages -test: test-go test-python test-nodejs test-dotnet +test: test-go test-python test-nodejs test-dotnet test-rust # Format Go code format-go: @@ -71,6 +71,21 @@ test-dotnet: @echo "=== Testing .NET code ===" @cd dotnet && dotnet test test/GitHub.Copilot.SDK.Test.csproj +# Format Rust code +format-rust: + @echo "=== Formatting Rust code ===" + @cd rust && cargo fmt + +# Lint Rust code +lint-rust: + @echo "=== Linting Rust code ===" + @cd rust && cargo clippy --all-targets -- -D warnings + +# Test Rust code +test-rust: + @echo "=== Testing Rust code ===" + @cd rust && cargo test + # Install all dependencies install: @echo "=== Installing dependencies ===" @@ -78,6 +93,7 @@ install: @cd python && uv pip install -e ".[dev]" @cd go && go mod download @cd dotnet && dotnet restore + @cd rust && cargo fetch @echo "✅ All dependencies installed" # Run interactive SDK playground diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000000..6114cc5791 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,16 @@ +# Rust +/target +Cargo.lock +**/*.rs.bk +*.pdb + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000000..fd490f9f67 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "github-copilot-sdk" +version = "0.1.0" +edition = "2021" +authors = ["GitHub "] +description = "GitHub Copilot SDK for Rust - Embed Copilot's agentic workflows in your application" +license = "MIT" +repository = "https://github.com/github/copilot-sdk" +homepage = "https://github.com/github/copilot-sdk" +documentation = "https://docs.rs/github-copilot-sdk" +readme = "README.md" +keywords = ["github", "copilot", "ai", "agent", "sdk"] +categories = ["api-bindings", "development-tools"] + +[dependencies] +tokio = { version = "1.41", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.11", features = ["v4", "serde"] } +thiserror = "2.0" +async-trait = "0.1" + +[dev-dependencies] +tokio-test = "0.4" +chrono = "0.4" + +[lib] +name = "github_copilot_sdk" +path = "src/lib.rs" + +[[example]] +name = "hello" +path = "examples/hello.rs" + +[[example]] +name = "tools" +path = "examples/tools.rs" + +[[example]] +name = "permissions" +path = "examples/permissions.rs" + +[[example]] +name = "mcp" +path = "examples/mcp.rs" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000000..2ac4535894 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,284 @@ +# GitHub Copilot SDK for Rust + +[![Crates.io](https://img.shields.io/crates/v/github-copilot-sdk.svg)](https://crates.io/crates/github-copilot-sdk) +[![Documentation](https://docs.rs/github-copilot-sdk/badge.svg)](https://docs.rs/github-copilot-sdk) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Embed Copilot's agentic workflows in your Rust application. The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production-tested agent runtime you can invoke programmatically. + +## Features + +- 🤖 **Agent Runtime** - Full access to Copilot's planning, tool invocation, and file editing capabilities +- 🔧 **Custom Tools** - Define and register your own tools with type-safe handlers +- 🔒 **Permission Control** - Fine-grained control over what the agent can access +- 🔌 **MCP Support** - Integration with Model Context Protocol servers +- ⚡ **Async/Await** - Built on Tokio for high-performance async operations +- 📡 **Multiple Transports** - Stdio (default) or TCP connections + +## Prerequisites + +You need the GitHub Copilot CLI installed and available in your PATH: + +```bash +# Install Copilot CLI +# Follow instructions at: https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line +``` + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +github-copilot-sdk = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +## Quick Start + +```rust +use github_copilot_sdk::{Client, ClientOptions, SessionConfig, SessionEvent}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create and start client + let client = Client::new(ClientOptions::default()).await?; + + // Create session + let session = client.create_session(SessionConfig { + model: Some("gpt-4o".to_string()), + ..Default::default() + }).await?; + + // Register event handler + session.on_event(std::sync::Arc::new(|event| { + if let SessionEvent::AssistantMessage { content, .. } = event { + println!("Assistant: {}", content); + } + })).await; + + // Send message and wait for response + let response = session.send_and_wait("Hello, Copilot!").await?; + println!("Response: {}", response); + + // Clean shutdown + client.stop().await?; + Ok(()) +} +``` + +## Custom Tools + +Define and register custom tools with type-safe handlers: + +```rust +use github_copilot_sdk::{Tool, ToolHandler, ToolInvocation, ToolResult}; +use async_trait::async_trait; +use std::collections::HashMap; + +struct GetTimeHandler; + +#[async_trait] +impl ToolHandler for GetTimeHandler { + async fn handle( + &self, + _arguments: HashMap, + _invocation: ToolInvocation, + ) -> github_copilot_sdk::Result { + let now = chrono::Local::now(); + Ok(ToolResult::text(format!("Current time: {}", now))) + } +} + +// Register the tool +let tool = Tool::new( + "get_current_time", + "Get the current date and time", + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), +); + +session.register_tool(tool, std::sync::Arc::new(GetTimeHandler)).await?; +``` + +## Permission Handling + +Control what the agent can access: + +```rust +use github_copilot_sdk::{PermissionRequest, PermissionRequestResult}; + +session.set_permission_handler(std::sync::Arc::new( + |request: PermissionRequest, _invocation| { + match request.kind.as_str() { + "file.read" => Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }), + "file.write" => Ok(PermissionRequestResult { + kind: "deny".to_string(), + rules: None, + }), + _ => Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }), + } + }, +)).await; +``` + +## Configuration + +### Client Options + +```rust +use github_copilot_sdk::ClientOptions; + +let options = ClientOptions { + cli_path: "copilot".to_string(), // Path to CLI executable + cwd: Some("/path/to/dir".to_string()), // Working directory + use_stdio: true, // Use stdio transport (default) + log_level: "info".to_string(), // CLI log level + auto_start: true, // Auto-start CLI + auto_restart: true, // Auto-restart on crash + ..Default::default() +}; + +let client = Client::new(options).await?; +``` + +### Session Configuration + +```rust +use github_copilot_sdk::{SessionConfig, SystemMessage}; + +let config = SessionConfig { + model: Some("gpt-4o".to_string()), + system_message: Some(SystemMessage::Append { + content: Some("You are a helpful assistant.".to_string()), + }), + cwd: Some("/project/path".to_string()), + ..Default::default() +}; + +let session = client.create_session(config).await?; +``` + +## Transport Modes + +### Stdio (Default) + +```rust +let client = Client::new(ClientOptions { + use_stdio: true, + ..Default::default() +}).await?; +``` + +### TCP + +```rust +let client = Client::new(ClientOptions { + use_stdio: false, + port: 3000, + ..Default::default() +}).await?; +``` + +### Connect to External Server + +```rust +let client = Client::new(ClientOptions { + cli_url: Some("localhost:3000".to_string()), + ..Default::default() +}).await?; +``` + +## Examples + +The repository includes several examples: + +- **hello** - Basic usage +- **tools** - Custom tool registration +- **permissions** - Permission handling +- **mcp** - MCP server integration + +Run an example: + +```bash +cargo run --example hello +``` + +## API Documentation + +Full API documentation is available at [docs.rs/github-copilot-sdk](https://docs.rs/github-copilot-sdk). + +## Session Events + +The SDK emits various events during operation: + +- `AssistantMessage` - Final response from the assistant +- `MessagePending` - Progress indicator +- `ToolCallRequested` - Tool invocation request +- `PermissionRequested` - Permission request +- `SessionStateChanged` - Session state changes +- `Error` - Error events + +## Models + +All models available via Copilot CLI are supported. Popular choices: + +- `gpt-4o` - GPT-4 Optimized +- `gpt-4o-mini` - Smaller, faster GPT-4 +- `claude-sonnet-4` - Claude Sonnet +- `o1` - OpenAI o1 +- `o1-mini` - Smaller o1 + +## Error Handling + +The SDK uses `Result` for error handling: + +```rust +use github_copilot_sdk::Error; + +match session.send_and_wait("Hello").await { + Ok(response) => println!("{}", response), + Err(Error::NotConnected) => eprintln!("Not connected"), + Err(Error::Timeout) => eprintln!("Request timed out"), + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Requirements + +- **Rust**: 1.70 or higher +- **Copilot CLI**: Latest version +- **GitHub Copilot**: Active subscription + +## Billing + +Usage is billed according to the GitHub Copilot CLI billing model. See [GitHub Copilot Pricing](https://github.com/features/copilot#pricing). + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines. + +## License + +MIT License - see [LICENSE](../LICENSE) for details. + +## Support + +- **Issues**: [GitHub Issues](https://github.com/github/copilot-sdk/issues) +- **Documentation**: [SDK Documentation](https://docs.rs/github-copilot-sdk) +- **Examples**: See the `examples/` directory + +## Additional Resources + +- [Getting Started Guide](../docs/getting-started.md) +- [Cookbook](../cookbook/README.md) +- [Samples](../samples/README.md) diff --git a/rust/examples/hello.rs b/rust/examples/hello.rs new file mode 100644 index 0000000000..c4885db47d --- /dev/null +++ b/rust/examples/hello.rs @@ -0,0 +1,50 @@ +//! Basic hello world example + +use github_copilot_sdk::{Client, ClientOptions, SessionConfig, SessionEvent}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting Copilot SDK example..."); + + // Create client with default options + let client = Client::new(ClientOptions::default()).await?; + println!("Client started successfully"); + + // Create a session + let session = client + .create_session(SessionConfig { + model: Some("gpt-4o".to_string()), + ..Default::default() + }) + .await?; + + println!("Session created: {}", session.id()); + + // Register event handler + session + .on_event(std::sync::Arc::new(|event| match event { + SessionEvent::AssistantMessage { content, .. } => { + println!("Assistant: {}", content); + } + SessionEvent::MessagePending { .. } => { + print!("."); + use std::io::Write; + std::io::stdout().flush().unwrap(); + } + _ => {} + })) + .await; + + // Send a message and wait for response + println!("\nSending message..."); + let response = session + .send_and_wait("Hello, Copilot! What can you do?") + .await?; + println!("\nFinal response: {}", response); + + // Clean shutdown + client.stop().await?; + println!("\nClient stopped"); + + Ok(()) +} diff --git a/rust/examples/mcp.rs b/rust/examples/mcp.rs new file mode 100644 index 0000000000..b7978dab32 --- /dev/null +++ b/rust/examples/mcp.rs @@ -0,0 +1,56 @@ +//! Example showing MCP server integration + +use github_copilot_sdk::{ + Client, ClientOptions, MCPLocalServerConfig, MCPServerConfig, SessionConfig, SystemMessage, +}; +use std::collections::HashMap; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("MCP Server Example"); + + // Create client + let client = Client::new(ClientOptions::default()).await?; + + // Configure MCP server + let mut mcp_servers = HashMap::new(); + mcp_servers.insert( + "my-mcp-server".to_string(), + MCPServerConfig::Local(MCPLocalServerConfig { + tools: vec!["search".to_string(), "fetch".to_string()], + server_type: "stdio".to_string(), + timeout: Some(30), + command: "node".to_string(), + args: vec!["mcp-server.js".to_string()], + env: None, + }), + ); + + // Create session with MCP server + let session = client + .create_session(SessionConfig { + model: Some("gpt-4o".to_string()), + mcp_servers: Some(mcp_servers), + system_message: Some(SystemMessage::Append { + content: Some( + "You have access to an MCP server with search and fetch tools.".to_string(), + ), + }), + ..Default::default() + }) + .await?; + + println!("Session created with MCP server!"); + + // Send a message + let response = session + .send_and_wait("Search for information about Rust async programming") + .await?; + + println!("Response: {}", response); + + // Clean up + client.stop().await?; + + Ok(()) +} diff --git a/rust/examples/permissions.rs b/rust/examples/permissions.rs new file mode 100644 index 0000000000..8061d484e6 --- /dev/null +++ b/rust/examples/permissions.rs @@ -0,0 +1,83 @@ +//! Example showing permission handling + +use github_copilot_sdk::{ + Client, ClientOptions, PermissionInvocation, PermissionRequest, PermissionRequestResult, + SessionConfig, +}; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Permission Handler Example"); + + // Create client + let client = Client::new(ClientOptions::default()).await?; + + // Create session + let session = client + .create_session(SessionConfig { + model: Some("gpt-4o".to_string()), + ..Default::default() + }) + .await?; + + // Set custom permission handler + session + .set_permission_handler(Arc::new( + |request: PermissionRequest, _invocation: PermissionInvocation| { + println!("Permission requested: {:?}", request.kind); + + // Custom permission logic + match request.kind.as_str() { + "file.read" => { + // Allow reading files + println!(" -> Allowing file read"); + Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }) + } + "file.write" => { + // Deny writing files + println!(" -> Denying file write"); + Ok(PermissionRequestResult { + kind: "deny".to_string(), + rules: None, + }) + } + "web.request" => { + // Allow web requests + println!(" -> Allowing web request"); + Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }) + } + _ => { + // Default: allow + println!(" -> Default: allowing"); + Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }) + } + } + }, + )) + .await; + + println!("Permission handler registered!"); + + // Send a message that might trigger permission requests + println!("\nAsking Copilot to check files..."); + let response = session + .send_and_wait("List the files in the current directory") + .await?; + + println!("Response: {}", response); + + // Clean up + client.stop().await?; + + Ok(()) +} diff --git a/rust/examples/tools.rs b/rust/examples/tools.rs new file mode 100644 index 0000000000..c238f75f81 --- /dev/null +++ b/rust/examples/tools.rs @@ -0,0 +1,148 @@ +//! Example showing custom tool registration + +use async_trait::async_trait; +use github_copilot_sdk::{ + Client, ClientOptions, SessionConfig, SessionEvent, Tool, ToolHandler, ToolInvocation, + ToolResult, +}; +use std::collections::HashMap; +use std::sync::Arc; + +/// A custom tool that gets the current time +struct GetTimeHandler; + +#[async_trait] +impl ToolHandler for GetTimeHandler { + async fn handle( + &self, + _arguments: HashMap, + _invocation: ToolInvocation, + ) -> github_copilot_sdk::Result { + let now = chrono::Local::now(); + Ok(ToolResult::text(format!( + "Current time is: {}", + now.format("%Y-%m-%d %H:%M:%S") + ))) + } +} + +/// A custom tool that calculates something +struct CalculatorHandler; + +#[async_trait] +impl ToolHandler for CalculatorHandler { + async fn handle( + &self, + arguments: HashMap, + _invocation: ToolInvocation, + ) -> github_copilot_sdk::Result { + let a = arguments.get("a").and_then(|v| v.as_f64()).unwrap_or(0.0); + + let b = arguments.get("b").and_then(|v| v.as_f64()).unwrap_or(0.0); + + let operation = arguments + .get("operation") + .and_then(|v| v.as_str()) + .unwrap_or("add"); + + let result = match operation { + "add" => a + b, + "subtract" => a - b, + "multiply" => a * b, + "divide" => { + if b != 0.0 { + a / b + } else { + return Ok(ToolResult::error("Division by zero")); + } + } + _ => return Ok(ToolResult::error("Unknown operation")), + }; + + Ok(ToolResult::text(format!( + "{} {} {} = {}", + a, operation, b, result + ))) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Custom Tools Example"); + + // Create client + let client = Client::new(ClientOptions::default()).await?; + + // Create session + let session = client + .create_session(SessionConfig { + model: Some("gpt-4o".to_string()), + ..Default::default() + }) + .await?; + + // Register get_time tool + let get_time_tool = Tool::new( + "get_current_time", + "Get the current date and time", + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + ); + session + .register_tool(get_time_tool, Arc::new(GetTimeHandler)) + .await?; + + // Register calculator tool + let calculator_tool = Tool::new( + "calculator", + "Perform basic arithmetic operations", + serde_json::json!({ + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + }, + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "Operation to perform" + } + }, + "required": ["a", "b", "operation"] + }), + ); + session + .register_tool(calculator_tool, Arc::new(CalculatorHandler)) + .await?; + + println!("Tools registered!"); + + // Set up event handler + session + .on_event(Arc::new(|event| { + if let SessionEvent::AssistantMessage { content, .. } = event { + println!("Assistant: {}", content); + } + })) + .await; + + // Test the tools + println!("\n--- Test 1: Get current time ---"); + session.send_and_wait("What time is it?").await?; + + println!("\n--- Test 2: Calculator ---"); + session.send_and_wait("Calculate 42 * 17 for me").await?; + + // Clean up + client.stop().await?; + + Ok(()) +} diff --git a/rust/src/client.rs b/rust/src/client.rs new file mode 100644 index 0000000000..8c1d4d55ea --- /dev/null +++ b/rust/src/client.rs @@ -0,0 +1,453 @@ +//! Client for managing Copilot CLI connections and sessions + +use crate::error::{Error, Result}; +use crate::generated::SessionEvent; +use crate::jsonrpc::{JsonRpcClient, NotificationHandler, RequestHandler}; +use crate::sdk_protocol_version::SDK_PROTOCOL_VERSION; +use crate::session::{Session, SessionConfig}; +use crate::types::{ClientOptions, ConnectionState, PermissionRequest}; +use serde_json::Value; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::AsyncRead; +use tokio::net::TcpStream; +use tokio::process::{Child, Command}; +use tokio::sync::{mpsc, Mutex}; + +pub use crate::types::ClientOptions as Options; + +/// Copilot CLI client +pub struct Client { + options: ClientOptions, + client: Arc>>>, + state: Arc>, + process: Arc>>, + sessions: Arc>>>, + session_event_channels: Arc>>>, +} + +impl Client { + /// Create a new Copilot client + pub async fn new(options: ClientOptions) -> Result { + let client = Self { + options: options.clone(), + client: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(ConnectionState::Disconnected)), + process: Arc::new(Mutex::new(None)), + sessions: Arc::new(Mutex::new(HashMap::new())), + session_event_channels: Arc::new(Mutex::new(HashMap::new())), + }; + + // Auto-start if enabled + if options.auto_start { + client.start().await?; + } + + Ok(client) + } + + /// Start the CLI connection + pub async fn start(&self) -> Result<()> { + let mut state = self.state.lock().await; + if *state == ConnectionState::Connected { + return Err(Error::AlreadyConnected); + } + + *state = ConnectionState::Connecting; + drop(state); + + // Connect based on options + let rpc_client = if let Some(ref url) = self.options.cli_url { + self.connect_external(url).await? + } else if self.options.use_stdio { + self.spawn_stdio_process().await? + } else { + self.spawn_tcp_process().await? + }; + + // Set up notification and request handlers + self.setup_handlers(&rpc_client).await; + + // Store client + *self.client.lock().await = Some(Arc::new(rpc_client)); + + // Send initialize request + self.initialize().await?; + + *self.state.lock().await = ConnectionState::Connected; + + Ok(()) + } + + /// Spawn CLI process with stdio transport + async fn spawn_stdio_process(&self) -> Result { + let mut cmd = Command::new(&self.options.cli_path); + cmd.arg("serve") + .arg("--log-level") + .arg(&self.options.log_level) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + if let Some(ref cwd) = self.options.cwd { + cmd.current_dir(cwd); + } + + if let Some(ref env) = self.options.env { + for (key, value) in env { + cmd.env(key, value); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| Error::ProcessError(format!("Failed to spawn CLI process: {}", e)))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| Error::ProcessError("Failed to get stdin".to_string()))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::ProcessError("Failed to get stdout".to_string()))?; + + *self.process.lock().await = Some(child); + + Ok(JsonRpcClient::new(stdout, stdin)) + } + + /// Spawn CLI process with TCP transport + async fn spawn_tcp_process(&self) -> Result { + // Start CLI in TCP mode + let port = if self.options.port > 0 { + self.options.port + } else { + 0 // Random port + }; + + let mut cmd = Command::new(&self.options.cli_path); + cmd.arg("serve") + .arg("--log-level") + .arg(&self.options.log_level) + .arg("--port") + .arg(port.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + if let Some(ref cwd) = self.options.cwd { + cmd.current_dir(cwd); + } + + let mut child = cmd + .spawn() + .map_err(|e| Error::ProcessError(format!("Failed to spawn CLI process: {}", e)))?; + + // Read port from stdout + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::ProcessError("Failed to get stdout".to_string()))?; + + let actual_port = self.read_port_from_stdout(stdout).await?; + + *self.process.lock().await = Some(child); + + // Connect to the TCP server + let stream = TcpStream::connect(format!("127.0.0.1:{}", actual_port)) + .await + .map_err(|e| { + Error::ConnectionError(format!("Failed to connect to TCP server: {}", e)) + })?; + + let (reader, writer) = stream.into_split(); + Ok(JsonRpcClient::new(reader, writer)) + } + + /// Connect to external CLI server + async fn connect_external(&self, url: &str) -> Result { + // Parse URL to extract host and port + let addr = if url.starts_with("http://") { + url.trim_start_matches("http://") + } else { + url + }; + + let addr = if !addr.contains(':') { + format!("127.0.0.1:{}", addr) + } else { + addr.to_string() + }; + + let stream = TcpStream::connect(&addr) + .await + .map_err(|e| Error::ConnectionError(format!("Failed to connect to {}: {}", addr, e)))?; + + let (reader, writer) = stream.into_split(); + Ok(JsonRpcClient::new(reader, writer)) + } + + /// Read port from CLI stdout + async fn read_port_from_stdout(&self, mut reader: R) -> Result + where + R: AsyncRead + Unpin, + { + use tokio::io::AsyncBufReadExt; + let mut buf_reader = tokio::io::BufReader::new(&mut reader); + let mut line = String::new(); + + // Read until we find the port line + loop { + line.clear(); + buf_reader.read_line(&mut line).await?; + + if line.contains("listening on port") { + // Extract port number + if let Some(port_str) = line.split_whitespace().last() { + if let Ok(port) = port_str.trim().parse() { + return Ok(port); + } + } + } + + if line.is_empty() { + break; + } + } + + Err(Error::ProcessError( + "Failed to read port from CLI".to_string(), + )) + } + + /// Set up notification and request handlers + async fn setup_handlers(&self, client: &JsonRpcClient) { + let sessions = Arc::clone(&self.sessions); + let channels = Arc::clone(&self.session_event_channels); + + // Handle session events + let notification_handler: NotificationHandler = Arc::new(move |method, params| { + let _sessions = Arc::clone(&sessions); + let channels = Arc::clone(&channels); + + tokio::spawn(async move { + if method == "session/event" { + if let Some(session_id) = params.get("sessionId").and_then(|v| v.as_str()) { + if let Some(event_data) = params.get("event") { + if let Ok(event) = + serde_json::from_value::(event_data.clone()) + { + // Send to session event channel + if let Some(tx) = channels.lock().await.get(session_id) { + let _ = tx.send(event); + } + } + } + } + } + }); + }); + + client.set_notification_handler(notification_handler).await; + + // Handle tool calls + let sessions_clone = Arc::clone(&self.sessions); + let tool_handler: RequestHandler = Arc::new(move |params| { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let tool_name = params + .get("toolName") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let tool_call_id = params + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let arguments = params + .get("arguments") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }) + .unwrap_or_default(); + + let sessions = Arc::clone(&sessions_clone); + + // Spawn async handler + tokio::spawn(async move { + if let Some(session) = sessions.lock().await.get(&session_id) { + let _ = session + .handle_tool_call(tool_name, tool_call_id, arguments) + .await; + } + }); + + Ok(HashMap::new()) + }); + + client + .register_request_handler("tool/execute".to_string(), tool_handler) + .await; + + // Handle permission requests + let sessions_clone = Arc::clone(&self.sessions); + let permission_handler: RequestHandler = Arc::new(move |params| { + let sessions = Arc::clone(&sessions_clone); + + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let request = serde_json::from_value::( + serde_json::to_value(¶ms).unwrap_or_default(), + ) + .unwrap_or_else(|_| PermissionRequest { + kind: "unknown".to_string(), + tool_call_id: None, + extra: HashMap::new(), + }); + + // Handle synchronously for now + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + if let Some(session) = sessions.lock().await.get(&session_id) { + session.handle_permission_request(request).await + } else { + Ok(crate::types::PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }) + } + }) + }); + + match result { + Ok(res) => { + let mut map = HashMap::new(); + if let Ok(value) = serde_json::to_value(&res) { + if let Some(obj) = value.as_object() { + for (k, v) in obj { + map.insert(k.clone(), v.clone()); + } + } + } + Ok(map) + } + Err(e) => Err(crate::error::Error::PermissionDenied(e.to_string())), + } + }); + + client + .register_request_handler("permission/request".to_string(), permission_handler) + .await; + } + + /// Initialize the connection + async fn initialize(&self) -> Result<()> { + let client = self.client.lock().await; + let client = client.as_ref().ok_or(Error::NotConnected)?; + + let mut params = HashMap::new(); + params.insert( + "sdkProtocolVersion".to_string(), + Value::Number(SDK_PROTOCOL_VERSION.into()), + ); + + client.request("initialize".to_string(), params).await?; + + Ok(()) + } + + /// Create a new session + pub async fn create_session(&self, config: SessionConfig) -> Result> { + let client = self.client.lock().await; + let client = client.as_ref().ok_or(Error::NotConnected)?; + + let mut params = HashMap::new(); + if let Ok(config_value) = serde_json::to_value(&config) { + if let Some(obj) = config_value.as_object() { + for (k, v) in obj { + params.insert(k.clone(), v.clone()); + } + } + } + + let result = client.request("session/create".to_string(), params).await?; + + let session_id = result + .get("sessionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::Other("No session ID in response".to_string()))? + .to_string(); + + // Create event channel + let (tx, rx) = mpsc::unbounded_channel(); + self.session_event_channels + .lock() + .await + .insert(session_id.clone(), tx); + + // Create session + let session = Arc::new(Session::new(session_id.clone(), Arc::clone(client), rx)); + + // Start event loop + Session::start_event_loop(Arc::clone(&session)).await; + + // Store session + self.sessions + .lock() + .await + .insert(session_id, Arc::clone(&session)); + + Ok(session) + } + + /// Get connection state + pub async fn state(&self) -> ConnectionState { + *self.state.lock().await + } + + /// Stop the client + pub async fn stop(&self) -> Result<()> { + // Close all sessions + self.sessions.lock().await.clear(); + self.session_event_channels.lock().await.clear(); + + // Shutdown JSON-RPC client + if let Some(_client) = self.client.lock().await.as_ref() { + // Note: JsonRpcClient doesn't have shutdown yet, would need to add + } + + // Kill process if we spawned it + if let Some(mut child) = self.process.lock().await.take() { + let _ = child.kill().await; + } + + *self.state.lock().await = ConnectionState::Disconnected; + + Ok(()) + } +} + +impl Drop for Client { + fn drop(&mut self) { + // Best effort cleanup + if let Some(mut child) = self.process.blocking_lock().take() { + let _ = child.start_kill(); + } + } +} diff --git a/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 0000000000..55d3dc263f --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,49 @@ +//! Error types for the Copilot SDK + +use thiserror::Error; + +/// Result type alias for SDK operations +pub type Result = std::result::Result; + +/// SDK error types +#[derive(Error, Debug)] +pub enum Error { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("JSON-RPC error: {0}")] + JsonRpc(String), + + #[error("Client not connected")] + NotConnected, + + #[error("Client already connected")] + AlreadyConnected, + + #[error("Session not found: {0}")] + SessionNotFound(String), + + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + + #[error("CLI process error: {0}")] + ProcessError(String), + + #[error("Connection error: {0}")] + ConnectionError(String), + + #[error("Timeout waiting for response")] + Timeout, + + #[error("Permission denied: {0}")] + PermissionDenied(String), + + #[error("Tool execution error: {0}")] + ToolError(String), + + #[error("{0}")] + Other(String), +} diff --git a/rust/src/generated/mod.rs b/rust/src/generated/mod.rs new file mode 100644 index 0000000000..36b3275d6f --- /dev/null +++ b/rust/src/generated/mod.rs @@ -0,0 +1,4 @@ +//! Generated module exports + +pub mod session_events; +pub use session_events::SessionEvent; diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs new file mode 100644 index 0000000000..1cde040b38 --- /dev/null +++ b/rust/src/generated/session_events.rs @@ -0,0 +1,63 @@ +//! Generated session event types + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Session event type +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum SessionEvent { + /// Assistant message event + #[serde(rename = "assistant.message")] + AssistantMessage { + content: String, + #[serde(flatten)] + extra: serde_json::Map, + }, + + /// Message pending event (progress indicator) + #[serde(rename = "message.pending")] + MessagePending { + #[serde(flatten)] + data: serde_json::Map, + }, + + /// Tool call requested event + #[serde(rename = "tool.call_requested")] + ToolCallRequested { + tool_name: String, + tool_call_id: String, + arguments: serde_json::Map, + }, + + /// Permission requested event + #[serde(rename = "permission.requested")] + PermissionRequested { + kind: String, + #[serde(flatten)] + data: serde_json::Map, + }, + + /// Session state changed + #[serde(rename = "session.state_changed")] + SessionStateChanged { + state: String, + #[serde(flatten)] + data: serde_json::Map, + }, + + /// Error event + #[serde(rename = "error")] + Error { + message: String, + #[serde(flatten)] + data: serde_json::Map, + }, + + /// Unknown event type (forward compatibility) + #[serde(untagged)] + Unknown { + #[serde(flatten)] + data: serde_json::Map, + }, +} diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs new file mode 100644 index 0000000000..4e84c5055a --- /dev/null +++ b/rust/src/jsonrpc.rs @@ -0,0 +1,330 @@ +//! JSON-RPC 2.0 implementation for Copilot SDK + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, oneshot, Mutex}; + +/// JSON-RPC 2.0 error +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl std::fmt::Display for JsonRpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "JSON-RPC Error {}: {}", self.code, self.message) + } +} + +/// JSON-RPC 2.0 request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Value, + pub method: String, + pub params: HashMap, +} + +/// JSON-RPC 2.0 response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC 2.0 notification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + pub params: HashMap, +} + +/// Handler for incoming notifications +pub type NotificationHandler = Arc) + Send + Sync>; + +/// Handler for incoming requests +pub type RequestHandler = + Arc) -> Result> + Send + Sync>; + +/// JSON-RPC client for bidirectional communication +pub struct JsonRpcClient { + writer: Arc>>, + pending_requests: Arc>>>, + notification_handler: Arc>>, + request_handlers: Arc>>, + shutdown_tx: Option>, +} + +impl JsonRpcClient { + /// Create a new JSON-RPC client with the given reader and writer + pub fn new(reader: R, writer: W) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let writer = Arc::new(Mutex::new( + Box::new(writer) as Box + )); + let pending_requests = Arc::new(Mutex::new(HashMap::new())); + let notification_handler = Arc::new(Mutex::new(None)); + let request_handlers = Arc::new(Mutex::new(HashMap::new())); + + let (shutdown_tx, shutdown_rx) = mpsc::channel(1); + + // Spawn reader task + let pending_requests_clone = Arc::clone(&pending_requests); + let notification_handler_clone = Arc::clone(¬ification_handler); + let request_handlers_clone = Arc::clone(&request_handlers); + let writer_clone = Arc::clone(&writer); + + tokio::spawn(Self::read_loop( + reader, + pending_requests_clone, + notification_handler_clone, + request_handlers_clone, + writer_clone, + shutdown_rx, + )); + + Self { + writer, + pending_requests, + notification_handler, + request_handlers, + shutdown_tx: Some(shutdown_tx), + } + } + + /// Register a notification handler + pub async fn set_notification_handler(&self, handler: NotificationHandler) { + *self.notification_handler.lock().await = Some(handler); + } + + /// Register a request handler for a specific method + pub async fn register_request_handler(&self, method: String, handler: RequestHandler) { + self.request_handlers.lock().await.insert(method, handler); + } + + /// Send a JSON-RPC request and wait for response + pub async fn request( + &self, + method: String, + params: HashMap, + ) -> Result> { + let id = uuid::Uuid::new_v4().to_string(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Value::String(id.clone()), + method, + params, + }; + + let (tx, rx) = oneshot::channel(); + self.pending_requests.lock().await.insert(id, tx); + + self.send_message(&request).await?; + + let response = rx + .await + .map_err(|_| Error::ConnectionError("Request cancelled".to_string()))?; + + if let Some(error) = response.error { + return Err(Error::JsonRpc(error.to_string())); + } + + Ok(response.result.unwrap_or_default()) + } + + /// Send a JSON-RPC notification (no response expected) + pub async fn notify(&self, method: String, params: HashMap) -> Result<()> { + let notification = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method, + params, + }; + + self.send_message(¬ification).await + } + + /// Send a message with Content-Length header framing + async fn send_message(&self, message: &T) -> Result<()> { + let json = serde_json::to_string(message)?; + let content = format!("Content-Length: {}\r\n\r\n{}", json.len(), json); + + let mut writer = self.writer.lock().await; + writer.write_all(content.as_bytes()).await?; + writer.flush().await?; + + Ok(()) + } + + /// Read loop that processes incoming messages + async fn read_loop( + reader: R, + pending_requests: Arc>>>, + notification_handler: Arc>>, + request_handlers: Arc>>, + writer: Arc>>, + mut shutdown_rx: mpsc::Receiver<()>, + ) where + R: AsyncRead + Unpin, + { + let mut reader = BufReader::new(reader); + let mut headers = Vec::new(); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + break; + } + result = Self::read_message(&mut reader, &mut headers) => { + match result { + Ok(Some(message)) => { + Self::handle_message( + message, + &pending_requests, + ¬ification_handler, + &request_handlers, + &writer, + ) + .await; + } + Ok(None) => break, // EOF + Err(e) => { + eprintln!("Error reading message: {}", e); + break; + } + } + } + } + } + } + + /// Read a single message with Content-Length header + async fn read_message( + reader: &mut BufReader, + headers: &mut Vec, + ) -> Result> + where + R: AsyncRead + Unpin, + { + headers.clear(); + + // Read headers + let mut content_length = 0; + loop { + let n = reader.read_until(b'\n', headers).await?; + if n == 0 { + return Ok(None); // EOF + } + + let line = std::str::from_utf8(headers) + .map_err(|e| Error::Other(format!("Invalid UTF-8 in headers: {}", e)))?; + + if line.trim().is_empty() { + break; // End of headers + } + + if line.starts_with("Content-Length:") { + content_length = line + .trim_start_matches("Content-Length:") + .trim() + .parse() + .map_err(|e| Error::Other(format!("Invalid Content-Length: {}", e)))?; + } + + headers.clear(); + } + + if content_length == 0 { + return Err(Error::Other("Missing Content-Length header".to_string())); + } + + // Read body + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).await?; + + let message: Value = serde_json::from_slice(&body)?; + Ok(Some(message)) + } + + /// Handle an incoming message + async fn handle_message( + message: Value, + pending_requests: &Arc>>>, + notification_handler: &Arc>>, + request_handlers: &Arc>>, + writer: &Arc>>, + ) { + // Check if it's a response + if message.get("result").is_some() || message.get("error").is_some() { + if let Ok(response) = serde_json::from_value::(message) { + if let Some(id) = response.id.as_ref().and_then(|v| v.as_str()) { + if let Some(tx) = pending_requests.lock().await.remove(id) { + let _ = tx.send(response); + } + } + } + } + // Check if it's a request + else if message.get("id").is_some() { + if let Ok(request) = serde_json::from_value::(message) { + let handlers = request_handlers.lock().await; + if let Some(handler) = handlers.get(&request.method) { + let result = handler(request.params); + let response = match result { + Ok(res) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: Some(request.id), + result: Some(res), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: Some(request.id), + result: None, + error: Some(JsonRpcError { + code: -32603, + message: e.to_string(), + data: None, + }), + }, + }; + + // Send response + let json = serde_json::to_string(&response).unwrap(); + let content = format!("Content-Length: {}\r\n\r\n{}", json.len(), json); + let mut w = writer.lock().await; + let _ = w.write_all(content.as_bytes()).await; + let _ = w.flush().await; + } + } + } + // It's a notification + else if let Ok(notification) = serde_json::from_value::(message) { + if let Some(handler) = notification_handler.lock().await.as_ref() { + handler(notification.method, notification.params); + } + } + } + + /// Shutdown the client + pub async fn shutdown(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()).await; + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000000..69a1fe85e5 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,45 @@ +//! # GitHub Copilot SDK for Rust +//! +//! Embed Copilot's agentic workflows in your Rust application. The GitHub Copilot SDK +//! exposes the same engine behind Copilot CLI: a production-tested agent runtime you +//! can invoke programmatically. +//! +//! ## Quick Start +//! +//! ```no_run +//! use github_copilot_sdk::{Client, ClientOptions, SessionConfig}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create and start client +//! let client = Client::new(ClientOptions::default()).await?; +//! +//! // Create session +//! let mut session = client.create_session(SessionConfig { +//! model: Some("gpt-4o".to_string()), +//! ..Default::default() +//! }).await?; +//! +//! // Send message and wait for response +//! let response = session.send_and_wait("Hello, Copilot!").await?; +//! println!("Response: {}", response); +//! +//! Ok(()) +//! } +//! ``` + +pub mod client; +pub mod error; +pub mod generated; +pub mod jsonrpc; +pub mod sdk_protocol_version; +pub mod session; +pub mod tools; +pub mod types; + +pub use client::Client; +pub use error::{Error, Result}; +pub use generated::SessionEvent; +pub use session::{Session, SessionConfig}; +pub use tools::{Tool, ToolHandler, ToolInvocation, ToolResult}; +pub use types::*; diff --git a/rust/src/sdk_protocol_version.rs b/rust/src/sdk_protocol_version.rs new file mode 100644 index 0000000000..791dac25fe --- /dev/null +++ b/rust/src/sdk_protocol_version.rs @@ -0,0 +1,4 @@ +//! SDK protocol version + +/// The SDK protocol version that this SDK implements +pub const SDK_PROTOCOL_VERSION: u32 = 2; diff --git a/rust/src/session.rs b/rust/src/session.rs new file mode 100644 index 0000000000..cadefead1d --- /dev/null +++ b/rust/src/session.rs @@ -0,0 +1,252 @@ +//! Session management for Copilot SDK + +use crate::error::{Error, Result}; +use crate::generated::SessionEvent; +use crate::jsonrpc::JsonRpcClient; +use crate::tools::{Tool, ToolHandler, ToolInvocation, ToolResult}; +use crate::types::{ + MCPServerConfig, PermissionInvocation, PermissionRequest, PermissionRequestResult, + SystemMessage, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; + +/// Configuration for creating a session +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionConfig { + /// Model to use (e.g., "gpt-4o", "claude-sonnet-4") + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// System message configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub system_message: Option, + + /// MCP servers to use + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, + + /// Working directory for the session + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Custom metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Options for sending a message +#[derive(Debug, Clone, Default)] +pub struct MessageOptions { + /// The prompt to send + pub prompt: String, + + /// Additional context or parameters + pub context: Option>, +} + +/// Event callback type +pub type EventCallback = Arc; + +/// Permission handler callback type +pub type PermissionHandler = Arc< + dyn Fn(PermissionRequest, PermissionInvocation) -> Result + + Send + + Sync, +>; + +/// A Copilot session for interactive conversations +pub struct Session { + id: String, + client: Arc, + event_callbacks: Arc>>, + tool_handlers: Arc>>>, + permission_handler: Arc>>, + event_rx: Arc>>, +} + +impl Session { + /// Create a new session + pub(crate) fn new( + id: String, + client: Arc, + event_rx: mpsc::UnboundedReceiver, + ) -> Self { + Self { + id, + client, + event_callbacks: Arc::new(Mutex::new(Vec::new())), + tool_handlers: Arc::new(Mutex::new(HashMap::new())), + permission_handler: Arc::new(Mutex::new(None)), + event_rx: Arc::new(Mutex::new(event_rx)), + } + } + + /// Get the session ID + pub fn id(&self) -> &str { + &self.id + } + + /// Register an event callback + pub async fn on_event(&self, callback: EventCallback) { + self.event_callbacks.lock().await.push(callback); + } + + /// Register a tool handler + pub async fn register_tool(&self, tool: Tool, handler: Arc) -> Result<()> { + let tool_name = tool.name.clone(); + + // Register tool with server + let mut params = HashMap::new(); + params.insert("sessionId".to_string(), Value::String(self.id.clone())); + params.insert("tool".to_string(), serde_json::to_value(&tool)?); + + self.client + .request("session/registerTool".to_string(), params) + .await?; + + // Store handler locally + self.tool_handlers.lock().await.insert(tool_name, handler); + + Ok(()) + } + + /// Set permission handler + pub async fn set_permission_handler(&self, handler: PermissionHandler) { + *self.permission_handler.lock().await = Some(handler); + } + + /// Send a message and don't wait for completion + pub async fn send(&self, prompt: impl Into) -> Result<()> { + let prompt = prompt.into(); + let mut params = HashMap::new(); + params.insert("sessionId".to_string(), Value::String(self.id.clone())); + params.insert("prompt".to_string(), Value::String(prompt)); + + self.client + .notify("session/send".to_string(), params) + .await?; + + Ok(()) + } + + /// Send a message and wait for the response + pub async fn send_and_wait(&self, prompt: impl Into) -> Result { + let prompt = prompt.into(); + let mut params = HashMap::new(); + params.insert("sessionId".to_string(), Value::String(self.id.clone())); + params.insert("prompt".to_string(), Value::String(prompt)); + + let result = self + .client + .request("session/sendAndWait".to_string(), params) + .await?; + + // Extract content from response + let content = result + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok(content) + } + + /// Handle an incoming tool call + pub(crate) async fn handle_tool_call( + &self, + tool_name: String, + tool_call_id: String, + arguments: HashMap, + ) -> Result<()> { + let handlers = self.tool_handlers.lock().await; + let handler = handlers.get(&tool_name).ok_or_else(|| { + Error::ToolError(format!("No handler registered for tool: {}", tool_name)) + })?; + + let invocation = ToolInvocation { + session_id: self.id.clone(), + tool_call_id: tool_call_id.clone(), + }; + + // Execute tool handler + let handler = Arc::clone(handler); + drop(handlers); // Release lock before async operation + + let result = handler.handle(arguments, invocation).await; + + // Send result back + self.send_tool_result(&tool_call_id, result).await?; + + Ok(()) + } + + /// Send tool result back to the server + async fn send_tool_result(&self, tool_call_id: &str, result: Result) -> Result<()> { + let mut params = HashMap::new(); + params.insert("sessionId".to_string(), Value::String(self.id.clone())); + params.insert( + "toolCallId".to_string(), + Value::String(tool_call_id.to_string()), + ); + + match result { + Ok(tool_result) => { + params.insert("result".to_string(), serde_json::to_value(tool_result)?); + } + Err(e) => { + let error_result = ToolResult::text(format!("Tool execution failed: {}", e)); + params.insert("result".to_string(), serde_json::to_value(error_result)?); + } + } + + self.client + .notify("session/toolResult".to_string(), params) + .await?; + + Ok(()) + } + + /// Handle permission request + pub(crate) async fn handle_permission_request( + &self, + request: PermissionRequest, + ) -> Result { + let handler = self.permission_handler.lock().await; + + if let Some(h) = handler.as_ref() { + let invocation = PermissionInvocation { + session_id: self.id.clone(), + }; + h(request, invocation) + } else { + // Default: allow all + Ok(PermissionRequestResult { + kind: "allow".to_string(), + rules: None, + }) + } + } + + /// Emit an event to all registered callbacks + pub(crate) async fn emit_event(&self, event: SessionEvent) { + let callbacks = self.event_callbacks.lock().await; + for callback in callbacks.iter() { + callback(event.clone()); + } + } + + /// Start event processing loop + pub async fn start_event_loop(self: Arc) { + let session = Arc::clone(&self); + tokio::spawn(async move { + let mut rx = session.event_rx.lock().await; + while let Some(event) = rx.recv().await { + session.emit_event(event).await; + } + }); + } +} diff --git a/rust/src/tools.rs b/rust/src/tools.rs new file mode 100644 index 0000000000..ab1b5c62ca --- /dev/null +++ b/rust/src/tools.rs @@ -0,0 +1,221 @@ +//! Tool system for defining and handling custom tools + +use crate::error::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// A tool definition with JSON schema for parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tool { + /// Tool name + pub name: String, + + /// Tool description + pub description: String, + + /// JSON schema for parameters + pub parameters: Value, +} + +impl Tool { + /// Create a new tool with the given name, description, and parameter schema + pub fn new(name: impl Into, description: impl Into, parameters: Value) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters, + } + } + + /// Create a simple tool with no parameters + pub fn simple(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } +} + +/// Context for tool invocation +#[derive(Debug, Clone)] +pub struct ToolInvocation { + /// Session ID where the tool was called + pub session_id: String, + + /// Unique ID for this tool call + pub tool_call_id: String, +} + +/// Result of a tool execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + /// Result content (text or data) + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + + /// Binary data (base64 encoded) + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + + /// MIME type for binary data + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + + /// Telemetry data + #[serde(skip_serializing_if = "Option::is_none")] + pub telemetry: Option>, + + /// Whether the tool execution was successful + #[serde(skip_serializing_if = "Option::is_none")] + pub success: Option, + + /// Error message if execution failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ToolResult { + /// Create a text result + pub fn text(content: impl Into) -> Self { + Self { + content: Some(content.into()), + data: None, + mime_type: None, + telemetry: None, + success: Some(true), + error: None, + } + } + + /// Create a binary result + pub fn binary(data: Vec, mime_type: impl Into) -> Self { + Self { + content: None, + data: Some(base64::encode(&data)), + mime_type: Some(mime_type.into()), + telemetry: None, + success: Some(true), + error: None, + } + } + + /// Create an error result + pub fn error(message: impl Into) -> Self { + Self { + content: None, + data: None, + mime_type: None, + telemetry: None, + success: Some(false), + error: Some(message.into()), + } + } + + /// Add telemetry data to the result + pub fn with_telemetry(mut self, telemetry: HashMap) -> Self { + self.telemetry = Some(telemetry); + self + } +} + +/// Handler trait for tool execution +#[async_trait] +pub trait ToolHandler: Send + Sync { + /// Execute the tool with the given arguments + async fn handle( + &self, + arguments: HashMap, + invocation: ToolInvocation, + ) -> Result; +} + +/// Helper to create a tool handler from a closure +pub struct FunctionToolHandler +where + F: Fn( + HashMap, + ToolInvocation, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +{ + handler: F, +} + +impl FunctionToolHandler +where + F: Fn( + HashMap, + ToolInvocation, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +{ + pub fn new(handler: F) -> Self { + Self { handler } + } +} + +#[async_trait] +impl ToolHandler for FunctionToolHandler +where + F: Fn( + HashMap, + ToolInvocation, + ) + -> std::pin::Pin> + Send>> + + Send + + Sync, +{ + async fn handle( + &self, + arguments: HashMap, + invocation: ToolInvocation, + ) -> Result { + (self.handler)(arguments, invocation).await + } +} + +// Helper module for base64 encoding (simple implementation) +mod base64 { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + pub fn encode(data: &[u8]) -> String { + let mut result = String::new(); + let mut i = 0; + + while i < data.len() { + let b0 = data[i]; + let b1 = if i + 1 < data.len() { data[i + 1] } else { 0 }; + let b2 = if i + 2 < data.len() { data[i + 2] } else { 0 }; + + result.push(CHARSET[(b0 >> 2) as usize] as char); + result.push(CHARSET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + + if i + 1 < data.len() { + result.push(CHARSET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char); + } else { + result.push('='); + } + + if i + 2 < data.len() { + result.push(CHARSET[(b2 & 0x3f) as usize] as char); + } else { + result.push('='); + } + + i += 3; + } + + result + } +} diff --git a/rust/src/types.rs b/rust/src/types.rs new file mode 100644 index 0000000000..2571f72ef2 --- /dev/null +++ b/rust/src/types.rs @@ -0,0 +1,127 @@ +//! Core types for the Copilot SDK + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Connection state of the client +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, + Error, +} + +/// Options for configuring the Copilot CLI client +#[derive(Debug, Clone)] +pub struct ClientOptions { + /// Path to the Copilot CLI executable (default: "copilot") + pub cli_path: String, + /// Working directory for the CLI process (default: current directory) + pub cwd: Option, + /// Port for TCP transport (default: 0 = random port) + pub port: u16, + /// Use stdio transport instead of TCP (default: true) + pub use_stdio: bool, + /// URL of an existing Copilot CLI server to connect to over TCP + /// Format: "host:port", "http://host:port", or just "port" + /// Mutually exclusive with cli_path when use_stdio is true + pub cli_url: Option, + /// Log level for the CLI server (default: "info") + pub log_level: String, + /// Automatically start the CLI server on first use (default: true) + pub auto_start: bool, + /// Automatically restart the CLI server if it crashes (default: true) + pub auto_restart: bool, + /// Environment variables for the CLI process + pub env: Option>, +} + +impl Default for ClientOptions { + fn default() -> Self { + Self { + cli_path: "copilot".to_string(), + cwd: None, + port: 0, + use_stdio: true, + cli_url: None, + log_level: "info".to_string(), + auto_start: true, + auto_restart: true, + env: None, + } + } +} + +/// System message configuration mode +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum SystemMessage { + /// Append content to the default system message + Append { + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + }, + /// Replace the entire system message (removes SDK guardrails) + Replace { content: String }, +} + +/// Permission request from the server +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionRequest { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +/// Result of a permission request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionRequestResult { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option>, +} + +/// Context for permission invocation +#[derive(Debug, Clone)] +pub struct PermissionInvocation { + pub session_id: String, +} + +/// MCP (Model Context Protocol) server configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MCPServerConfig { + Local(MCPLocalServerConfig), + Remote(MCPRemoteServerConfig), +} + +/// Local/stdio MCP server configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MCPLocalServerConfig { + pub tools: Vec, + #[serde(rename = "type")] + pub server_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + pub command: String, + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, +} + +/// Remote MCP server configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MCPRemoteServerConfig { + pub tools: Vec, + #[serde(rename = "type")] + pub server_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} diff --git a/rust/tests/jsonrpc_tests.rs b/rust/tests/jsonrpc_tests.rs new file mode 100644 index 0000000000..61f9efc26c --- /dev/null +++ b/rust/tests/jsonrpc_tests.rs @@ -0,0 +1,83 @@ +//! Tests for JSON-RPC implementation + +use github_copilot_sdk::jsonrpc::JsonRpcClient; +use std::collections::HashMap; + +#[tokio::test] +async fn test_jsonrpc_request_response() { + // Create in-memory duplex streams for testing + let (client_stream, server_stream) = tokio::io::duplex(1024); + let (server_read, server_write) = tokio::io::split(server_stream); + let (client_read, client_write) = tokio::io::split(client_stream); + + // Create client + let client = JsonRpcClient::new(client_read, client_write); + + // Spawn a simple server that echoes requests + tokio::spawn(async move { + let server_client = JsonRpcClient::new(server_read, server_write); + + // Register a simple handler that echoes the input + server_client + .register_request_handler("echo".to_string(), std::sync::Arc::new(Ok)) + .await; + + // Keep server alive + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + }); + + // Give server time to start + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Send request + let mut params = HashMap::new(); + params.insert("message".to_string(), serde_json::json!("hello")); + + let result = client + .request("echo".to_string(), params.clone()) + .await + .unwrap(); + + assert_eq!(result.get("message").unwrap(), &serde_json::json!("hello")); +} + +#[tokio::test] +async fn test_jsonrpc_notification() { + let (client_stream, server_stream) = tokio::io::duplex(1024); + let (server_read, server_write) = tokio::io::split(server_stream); + let (client_read, client_write) = tokio::io::split(client_stream); + + let client = JsonRpcClient::new(client_read, client_write); + + // Spawn server + tokio::spawn(async move { + let _server_client = JsonRpcClient::new(server_read, server_write); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Send notification (should not error) + let mut params = HashMap::new(); + params.insert("event".to_string(), serde_json::json!("test")); + + let result = client.notify("test_event".to_string(), params).await; + assert!(result.is_ok()); +} + +#[test] +fn test_jsonrpc_error_serialization() { + use github_copilot_sdk::jsonrpc::JsonRpcError; + + let error = JsonRpcError { + code: -32600, + message: "Invalid Request".to_string(), + data: None, + }; + + let json = serde_json::to_string(&error).unwrap(); + let deserialized: JsonRpcError = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.code, -32600); + assert_eq!(deserialized.message, "Invalid Request"); +} diff --git a/rust/tests/tools_tests.rs b/rust/tests/tools_tests.rs new file mode 100644 index 0000000000..8de779957c --- /dev/null +++ b/rust/tests/tools_tests.rs @@ -0,0 +1,75 @@ +//! Tests for tool system + +use github_copilot_sdk::{Tool, ToolResult}; + +#[test] +fn test_tool_creation() { + let tool = Tool::new( + "test_tool", + "A test tool", + serde_json::json!({ + "type": "object", + "properties": { + "param1": {"type": "string"} + } + }), + ); + + assert_eq!(tool.name, "test_tool"); + assert_eq!(tool.description, "A test tool"); +} + +#[test] +fn test_simple_tool() { + let tool = Tool::simple("simple", "Simple tool"); + + assert_eq!(tool.name, "simple"); + let schema = tool.parameters; + assert_eq!(schema["type"], "object"); +} + +#[test] +fn test_tool_result_text() { + let result = ToolResult::text("Hello"); + + assert_eq!(result.content, Some("Hello".to_string())); + assert_eq!(result.success, Some(true)); + assert!(result.error.is_none()); +} + +#[test] +fn test_tool_result_error() { + let result = ToolResult::error("Something went wrong"); + + assert_eq!(result.error, Some("Something went wrong".to_string())); + assert_eq!(result.success, Some(false)); + assert!(result.content.is_none()); +} + +#[test] +fn test_tool_result_binary() { + let data = vec![1, 2, 3, 4]; + let result = ToolResult::binary(data, "application/octet-stream"); + + assert!(result.data.is_some()); + assert_eq!( + result.mime_type, + Some("application/octet-stream".to_string()) + ); + assert_eq!(result.success, Some(true)); +} + +#[test] +fn test_tool_result_with_telemetry() { + let mut telemetry = std::collections::HashMap::new(); + telemetry.insert("duration_ms".to_string(), serde_json::json!(42)); + + let result = ToolResult::text("Done").with_telemetry(telemetry.clone()); + + assert_eq!(result.content, Some("Done".to_string())); + assert!(result.telemetry.is_some()); + assert_eq!( + result.telemetry.unwrap().get("duration_ms"), + Some(&serde_json::json!(42)) + ); +} diff --git a/rust/tests/types_tests.rs b/rust/tests/types_tests.rs new file mode 100644 index 0000000000..e0f699859b --- /dev/null +++ b/rust/tests/types_tests.rs @@ -0,0 +1,52 @@ +//! Tests for core types and serialization + +use github_copilot_sdk::{ClientOptions, SessionConfig, SystemMessage}; + +#[test] +fn test_client_options_default() { + let options = ClientOptions::default(); + + assert_eq!(options.cli_path, "copilot"); + assert!(options.use_stdio); + assert!(options.auto_start); + assert!(options.auto_restart); + assert_eq!(options.log_level, "info"); +} + +#[test] +fn test_session_config_serialization() { + let config = SessionConfig { + model: Some("gpt-4o".to_string()), + system_message: Some(SystemMessage::Append { + content: Some("Test".to_string()), + }), + ..Default::default() + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: SessionConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.model, Some("gpt-4o".to_string())); +} + +#[test] +fn test_system_message_append() { + let msg = SystemMessage::Append { + content: Some("Additional instructions".to_string()), + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["mode"], "append"); + assert_eq!(json["content"], "Additional instructions"); +} + +#[test] +fn test_system_message_replace() { + let msg = SystemMessage::Replace { + content: "Complete replacement".to_string(), + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["mode"], "replace"); + assert_eq!(json["content"], "Complete replacement"); +}