refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code shaped. Adding a second client meant rewriting each role in that client's syntax and maintaining both copies — the drift this scaffold exists to prevent, one layer up. Roles and skills now live under `.agents/` and render into each registered client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated; `scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI. The role metadata is portable rather than vendor-named: `reasoning_tier` (deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an optional `preload_skills`. A client manifest maps those to native syntax and must declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported` and its adapters say so in the file, rather than the tier silently evaporating and leaving the repository to believe it was enforced. The port is behaviour-preserving where it should be and a fix where it should not. Every instruction body is byte-identical — the whole diff to `.claude/agents/` is 18 added lines and zero deletions. What changed is frontmatter that was missing: - four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`) declared no `tools:` and therefore inherited the ENTIRE tool pool, so three review-only agents could edit and write the code they were reviewing. All eight now declare capabilities explicitly. - the six read-only roles gain a non-editing permission mode, so the constraint is enforced by the client rather than by the prompt asking nicely. - `mutation` is now explicit, which records the two roles that genuinely need to write: `pr-creator` (external-write — it pushes a branch and opens a PR) and `dependency-audit` (workspace-write — package managers rewrite lockfiles). `pr-creator` keeps `shell` because opening a PR needs it, but it is now the only agent here with a write mutation and a declared reason for it, instead of one of four with unlimited access by omission.
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
---
|
||||
name: rust-async
|
||||
description: Rust development with Tokio async runtime, cargo test, clippy, and systems programming patterns. Use when writing Rust code, agents, or system utilities.
|
||||
---
|
||||
|
||||
# Rust Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my-agent/
|
||||
├── Cargo.toml
|
||||
├── Cargo.lock
|
||||
├── src/
|
||||
│ ├── main.rs # Binary entry point
|
||||
│ ├── lib.rs # Library root (if dual crate)
|
||||
│ ├── config.rs # Configuration handling
|
||||
│ ├── error.rs # Error types
|
||||
│ ├── client/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── http.rs
|
||||
│ └── discovery/
|
||||
│ ├── mod.rs
|
||||
│ ├── system.rs
|
||||
│ └── network.rs
|
||||
├── tests/
|
||||
│ └── integration/
|
||||
│ └── discovery_test.rs
|
||||
└── benches/
|
||||
└── performance.rs
|
||||
```
|
||||
|
||||
## Cargo Configuration
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[package]
|
||||
name = "my-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.11", features = ["json", "socks"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# System info
|
||||
sysinfo = "0.30"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
mockall = "0.12"
|
||||
tempfile = "3.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
lto = true # Link-time optimization
|
||||
codegen-units = 1 # Better optimization
|
||||
strip = true # Strip symbols
|
||||
panic = "abort" # Smaller binary
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
all = "deny"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Types
|
||||
```rust
|
||||
// src/error.rs
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AgentError {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Discovery failed: {message}")]
|
||||
Discovery { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> },
|
||||
|
||||
#[error("Connection timeout after {duration_secs}s")]
|
||||
Timeout { duration_secs: u64 },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AgentError>;
|
||||
```
|
||||
|
||||
### Result Pattern Usage
|
||||
```rust
|
||||
// src/client/http.rs
|
||||
use crate::error::{AgentError, Result};
|
||||
|
||||
pub struct HttpClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(base_url: &str, timeout_secs: u64) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(AgentError::Network)?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: base_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<T>()
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn post<T, R>(&self, path: &str, body: &T) -> Result<R>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
R: serde::de::DeserializeOwned,
|
||||
{
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<R>()
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Patterns with Tokio
|
||||
|
||||
### Main Entry Point
|
||||
```rust
|
||||
// src/main.rs
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod client;
|
||||
mod discovery;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "Starting agent");
|
||||
|
||||
run(config).await
|
||||
}
|
||||
|
||||
async fn run(config: Config) -> Result<()> {
|
||||
let client = client::HttpClient::new(&config.server_url, config.timeout_secs)?;
|
||||
|
||||
// Main loop with graceful shutdown
|
||||
let mut interval = tokio::time::interval(config.poll_interval);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Err(e) = discovery::collect_and_send(&client).await {
|
||||
tracing::error!(error = %e, "Discovery cycle failed");
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("Shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Concurrent Operations
|
||||
```rust
|
||||
// src/discovery/mod.rs
|
||||
use tokio::task::JoinSet;
|
||||
use crate::error::Result;
|
||||
|
||||
pub async fn discover_all() -> Result<SystemInfo> {
|
||||
let mut tasks = JoinSet::new();
|
||||
|
||||
// Spawn concurrent discovery tasks
|
||||
tasks.spawn(async { ("cpu", discover_cpu().await) });
|
||||
tasks.spawn(async { ("memory", discover_memory().await) });
|
||||
tasks.spawn(async { ("disk", discover_disk().await) });
|
||||
tasks.spawn(async { ("network", discover_network().await) });
|
||||
|
||||
let mut info = SystemInfo::default();
|
||||
|
||||
// Collect results as they complete
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
match result {
|
||||
Ok((name, Ok(data))) => {
|
||||
tracing::debug!(component = name, "Discovery completed");
|
||||
info.merge(name, data);
|
||||
}
|
||||
Ok((name, Err(e))) => {
|
||||
tracing::warn!(component = name, error = %e, "Discovery failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Tests
|
||||
```rust
|
||||
// src/discovery/system.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CpuInfo {
|
||||
pub cores: usize,
|
||||
pub usage_percent: f32,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl CpuInfo {
|
||||
pub fn is_high_usage(&self) -> bool {
|
||||
self.usage_percent > 80.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get_mock_cpu_info(overrides: Option<CpuInfo>) -> CpuInfo {
|
||||
let default = CpuInfo {
|
||||
cores: 4,
|
||||
usage_percent: 25.0,
|
||||
model: "Test CPU".to_string(),
|
||||
};
|
||||
overrides.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_true_above_threshold() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 85.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(cpu.is_high_usage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_false_below_threshold() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 50.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(!cpu.is_high_usage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_high_usage_returns_false_at_boundary() {
|
||||
let cpu = get_mock_cpu_info(Some(CpuInfo {
|
||||
usage_percent: 80.0,
|
||||
..get_mock_cpu_info(None)
|
||||
}));
|
||||
|
||||
assert!(!cpu.is_high_usage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Tests
|
||||
```rust
|
||||
// tests/integration/client_test.rs
|
||||
use tokio_test::block_on;
|
||||
use my_agent::client::HttpClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_handles_timeout() {
|
||||
let client = HttpClient::new("http://localhost:9999", 1).unwrap();
|
||||
|
||||
let result: Result<serde_json::Value, _> = client.get("/test").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_requests_complete() {
|
||||
let client = HttpClient::new("https://httpbin.org", 30).unwrap();
|
||||
|
||||
let handles: Vec<_> = (0..5)
|
||||
.map(|i| {
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
client.get::<serde_json::Value>(&format!("/delay/{}", i % 2)).await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking with Mockall
|
||||
```rust
|
||||
// src/discovery/mod.rs
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
pub trait SystemDiscovery {
|
||||
fn get_cpu_info(&self) -> crate::error::Result<CpuInfo>;
|
||||
fn get_memory_info(&self) -> crate::error::Result<MemoryInfo>;
|
||||
}
|
||||
|
||||
// src/discovery/collector.rs
|
||||
pub struct Collector<D: SystemDiscovery> {
|
||||
discovery: D,
|
||||
}
|
||||
|
||||
impl<D: SystemDiscovery> Collector<D> {
|
||||
pub fn new(discovery: D) -> Self {
|
||||
Self { discovery }
|
||||
}
|
||||
|
||||
pub fn collect(&self) -> crate::error::Result<SystemSnapshot> {
|
||||
let cpu = self.discovery.get_cpu_info()?;
|
||||
let memory = self.discovery.get_memory_info()?;
|
||||
|
||||
Ok(SystemSnapshot { cpu, memory })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mockall::predicate::*;
|
||||
|
||||
#[test]
|
||||
fn test_collector_combines_cpu_and_memory() {
|
||||
let mut mock = MockSystemDiscovery::new();
|
||||
|
||||
mock.expect_get_cpu_info()
|
||||
.times(1)
|
||||
.returning(|| Ok(CpuInfo {
|
||||
cores: 4,
|
||||
usage_percent: 50.0,
|
||||
model: "Mock CPU".to_string(),
|
||||
}));
|
||||
|
||||
mock.expect_get_memory_info()
|
||||
.times(1)
|
||||
.returning(|| Ok(MemoryInfo {
|
||||
total_gb: 16.0,
|
||||
available_gb: 8.0,
|
||||
}));
|
||||
|
||||
let collector = Collector::new(mock);
|
||||
let snapshot = collector.collect().unwrap();
|
||||
|
||||
assert_eq!(snapshot.cpu.cores, 4);
|
||||
assert_eq!(snapshot.memory.total_gb, 16.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```rust
|
||||
// src/config.rs
|
||||
use crate::error::{AgentError, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server_url: String,
|
||||
pub timeout_secs: u64,
|
||||
pub poll_interval: Duration,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let server_url = std::env::var("SERVER_URL")
|
||||
.map_err(|_| AgentError::Config("SERVER_URL not set".to_string()))?;
|
||||
|
||||
let timeout_secs = std::env::var("TIMEOUT_SECS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.map_err(|_| AgentError::Config("Invalid TIMEOUT_SECS".to_string()))?;
|
||||
|
||||
let poll_interval_secs: u64 = std::env::var("POLL_INTERVAL_SECS")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.map_err(|_| AgentError::Config("Invalid POLL_INTERVAL_SECS".to_string()))?;
|
||||
|
||||
let agent_id = std::env::var("AGENT_ID")
|
||||
.unwrap_or_else(|_| hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string()));
|
||||
|
||||
Ok(Self {
|
||||
server_url,
|
||||
timeout_secs,
|
||||
poll_interval: Duration::from_secs(poll_interval_secs),
|
||||
agent_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Clippy Configuration
|
||||
|
||||
```toml
|
||||
# clippy.toml
|
||||
cognitive-complexity-threshold = 10
|
||||
too-many-arguments-threshold = 5
|
||||
type-complexity-threshold = 200
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Building
|
||||
cargo build # Debug build
|
||||
cargo build --release # Release build
|
||||
cargo build --target x86_64-unknown-linux-musl # Static binary
|
||||
|
||||
# Testing
|
||||
cargo test # Run all tests
|
||||
cargo test -- --nocapture # Show println! output
|
||||
cargo test test_name # Run specific test
|
||||
cargo test --test integration # Run integration tests only
|
||||
|
||||
# Linting
|
||||
cargo clippy # Run clippy
|
||||
cargo clippy -- -D warnings # Treat warnings as errors
|
||||
cargo clippy --fix # Auto-fix issues
|
||||
|
||||
# Formatting
|
||||
cargo fmt # Format code
|
||||
cargo fmt --check # Check formatting
|
||||
|
||||
# Other
|
||||
cargo doc --open # Generate and open docs
|
||||
cargo bench # Run benchmarks
|
||||
cargo tree # Show dependency tree
|
||||
cargo audit # Check for vulnerabilities
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```rust
|
||||
// BAD: Unwrap without context
|
||||
let config = Config::from_env().unwrap();
|
||||
|
||||
// GOOD: Provide context or use ?
|
||||
let config = Config::from_env()
|
||||
.expect("Failed to load configuration from environment");
|
||||
|
||||
// Or in functions returning Result:
|
||||
let config = Config::from_env()?;
|
||||
|
||||
|
||||
// BAD: Clone when not needed
|
||||
fn process(data: &Vec<String>) {
|
||||
let cloned = data.clone(); // Unnecessary allocation
|
||||
for item in cloned.iter() {
|
||||
println!("{}", item);
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Use references
|
||||
fn process(data: &[String]) {
|
||||
for item in data {
|
||||
println!("{}", item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// BAD: String concatenation in loop
|
||||
let mut result = String::new();
|
||||
for item in items {
|
||||
result = result + &item + ", "; // Allocates each iteration
|
||||
}
|
||||
|
||||
// GOOD: Use push_str or join
|
||||
let result = items.join(", ");
|
||||
|
||||
|
||||
// BAD: Blocking in async context
|
||||
async fn fetch_data() {
|
||||
std::thread::sleep(Duration::from_secs(1)); // Blocks runtime!
|
||||
}
|
||||
|
||||
// GOOD: Use async sleep
|
||||
async fn fetch_data() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
|
||||
// BAD: Ignoring errors silently
|
||||
let _ = file.write_all(data);
|
||||
|
||||
// GOOD: Handle or propagate errors
|
||||
file.write_all(data)?;
|
||||
// Or log if truly ignorable:
|
||||
if let Err(e) = file.write_all(data) {
|
||||
tracing::warn!(error = %e, "Failed to write data");
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user