Add Sprig all-in-one agent binary (#605)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
tlongwell-block
2026-05-17 13:31:58 -04:00
committed by GitHub
parent 17eea2d267
commit 70cb53e2c7
16 changed files with 3680 additions and 3598 deletions
@@ -1,9 +1,6 @@
name: Sprout Agent Bundle
name: Sprig
# Builds and publishes the Sprout Agent Bundle — a Linux tarball containing
# the three binaries an external service (e.g. sprout-backend-blox) needs to
# run a Sprout agent end-to-end:
#
# Builds and publishes Sprig — one deploy-anywhere Linux multicall binary for:
# sprout-acp ACP harness that bridges Sprout events to the LLM agent
# sprout-agent ACP-compliant agent (spawns MCP, calls LLMs)
# sprout-dev-mcp Developer MCP server (multicall: rg, tree, sprout,
@@ -13,14 +10,14 @@ name: Sprout Agent Bundle
# musl so the tarball runs on any modern Linux without libc surprises).
#
# Triggers:
# - push to main → updates rolling `sprout-agent-bundle-latest` release
# - tag `sprout-agent-bundle-v*` → versioned release
# - workflow_dispatch → manual canary build (no release publish)
# - push to main → updates rolling `sprig-latest` release
# - tag `sprig-v*` → versioned release
# - workflow_dispatch → manual canary build (no release publish unless asked)
on:
push:
branches: [main]
tags: ["sprout-agent-bundle-v*"]
tags: ["sprig-v*"]
workflow_dispatch:
inputs:
publish:
@@ -59,16 +56,11 @@ jobs:
run: |
set -euo pipefail
WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \
| jq -r '.workspace_default_members[0] as $m | .packages[] | select(.id==$m) | .version')
if [[ -z "$WORKSPACE_VERSION" || "$WORKSPACE_VERSION" == "null" ]]; then
# Fallback: read sprout-acp's version directly.
WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \
| jq -r '.packages[] | select(.name=="sprout-acp") | .version')
fi
| jq -r '.packages[] | select(.name=="sprig") | .version')
REF="${GITHUB_REF#refs/tags/}"
if [[ "$GITHUB_REF" == refs/tags/sprout-agent-bundle-v* ]]; then
VERSION="${REF#sprout-agent-bundle-v}"
if [[ "$GITHUB_REF" == refs/tags/sprig-v* ]]; then
VERSION="${REF#sprig-v}"
CHANNEL="tag"
else
SHORT_SHA="${GITHUB_SHA::7}"
@@ -82,7 +74,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
echo "Resolved version=$VERSION channel=$CHANNEL"
- name: Build & package bundle
- name: Build & package Sprig
id: pkg
env:
TARGET: ${{ matrix.target }}
@@ -91,17 +83,13 @@ jobs:
GIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Rolling releases use stable, version-less filenames so the
# asset overwrites cleanly on every push to main. Tagged
# releases keep the version in the filename for traceability.
# The git SHA + version always live inside bundle.json.
if [[ "$CHANNEL" == "tag" ]]; then
ARCHIVE_BASENAME="sprout-agent-bundle-${VERSION}-${TARGET}"
ARCHIVE_BASENAME="sprig-${VERSION}-${TARGET}"
else
ARCHIVE_BASENAME="sprout-agent-bundle-${TARGET}"
ARCHIVE_BASENAME="sprig-${TARGET}"
fi
ARCHIVE_BASENAME="$ARCHIVE_BASENAME" \
./scripts/build-agent-bundle.sh "$VERSION" "$TARGET"
./scripts/build-sprig.sh "$VERSION" "$TARGET"
ARCHIVE="dist/${ARCHIVE_BASENAME}.tar.gz"
test -f "$ARCHIVE"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
@@ -110,7 +98,7 @@ jobs:
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sprout-agent-bundle-${{ matrix.target }}
name: sprig-${{ matrix.target }}
path: |
${{ steps.pkg.outputs.archive }}
${{ steps.pkg.outputs.archive }}.sha256
@@ -130,11 +118,11 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download all bundle artifacts
- name: Download all Sprig artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: dist
pattern: sprout-agent-bundle-*
pattern: sprig-*
merge-multiple: true
- name: List assets
@@ -147,14 +135,10 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -euo pipefail
TAG="sprout-agent-bundle-latest"
TITLE="Sprout Agent Bundle (rolling)"
NOTES="Rolling Linux build of the Sprout Agent Bundle (sprout-acp + sprout-agent + sprout-dev-mcp), tracking \`main\` (\`${SHA}\`)."
TAG="sprig-latest"
TITLE="Sprig (rolling)"
NOTES="Rolling Linux build of Sprig (all-in-one sprout-acp + sprout-agent + sprout-dev-mcp), tracking \`main\` (\`${SHA}\`)."
# Force-move the underlying git tag to the current SHA before
# we touch the release. `gh release edit` updates release
# metadata but does *not* move the tag, so without this the
# tag would stick to whatever SHA first created the release.
if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
gh api -X PATCH "repos/${REPO}/git/refs/tags/${TAG}" \
-f sha="${SHA}" -F force=true >/dev/null
@@ -163,7 +147,6 @@ jobs:
-f ref="refs/tags/${TAG}" -f sha="${SHA}" >/dev/null
fi
# Create the release if it doesn't exist; otherwise reuse it.
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" \
--prerelease \
@@ -177,15 +160,12 @@ jobs:
--title "$TITLE" \
--notes "$NOTES"
fi
# Asset filenames are stable for rolling builds (no version
# in filename — see the package step), so --clobber overwrites
# them in place. No stale-asset accumulation.
gh release upload "$TAG" dist/* --clobber
publish-tag:
name: Publish tagged release
needs: build
if: startsWith(github.ref, 'refs/tags/sprout-agent-bundle-v')
if: startsWith(github.ref, 'refs/tags/sprig-v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -193,18 +173,18 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download all bundle artifacts
- name: Download all Sprig artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: dist
pattern: sprout-agent-bundle-*
pattern: sprig-*
merge-multiple: true
- name: Resolve tag version
id: ver
run: |
REF="${GITHUB_REF#refs/tags/}"
VERSION="${REF#sprout-agent-bundle-v}"
VERSION="${REF#sprig-v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$REF" >> "$GITHUB_OUTPUT"
@@ -216,6 +196,6 @@ jobs:
run: |
set -euo pipefail
gh release create "$TAG" \
--title "Sprout Agent Bundle v${VERSION}" \
--notes "Sprout Agent Bundle v${VERSION} — Linux builds of sprout-acp + sprout-agent + sprout-dev-mcp." \
--title "Sprig v${VERSION}" \
--notes "Sprig v${VERSION} — Linux all-in-one builds of sprout-acp + sprout-agent + sprout-dev-mcp." \
dist/*
Generated
+9
View File
@@ -3799,6 +3799,15 @@ dependencies = [
"der",
]
[[package]]
name = "sprig"
version = "0.1.0"
dependencies = [
"sprout-acp",
"sprout-agent",
"sprout-dev-mcp",
]
[[package]]
name = "sprout-acp"
version = "0.1.0"
+14
View File
@@ -10,6 +10,7 @@ members = [
"crates/sprout-mcp",
"crates/sprout-acp",
"crates/sprout-agent",
"crates/sprig",
"crates/sprout-proxy",
"crates/sprout-test-client",
"crates/sprout-admin",
@@ -125,3 +126,16 @@ sprout-sdk = { path = "crates/sprout-sdk" }
[profile.ci]
inherits = "release"
lto = false
# Sprig profile — optimized for deploy-anywhere Sprig release artifacts.
# Sprig is distributed over the network and installed on fresh hosts, so binary
# size matters more than compile speed here. Keep this separate from the normal
# `release` profile so desktop/dev release builds do not inherit the slower
# size-focused settings unless they opt in explicitly.
[profile.sprig]
inherits = "release"
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "sprig"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "All-in-one Sprout ACP harness, agent, and developer MCP"
[[bin]]
name = "sprig"
path = "src/main.rs"
[dependencies]
sprout-acp = { path = "../sprout-acp" }
sprout-agent = { path = "../sprout-agent" }
sprout-dev-mcp = { path = "../sprout-dev-mcp" }
+53
View File
@@ -0,0 +1,53 @@
fn main() {
if let Err(e) = dispatch() {
eprintln!("{e}");
std::process::exit(1);
}
}
fn dispatch() -> Result<(), String> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = std::path::Path::new(&argv0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match cmd.as_str() {
"sprout-acp" => sprout_acp::run().map_err(|e| e.to_string()),
"sprout-agent" => sprout_agent::run().map_err(|e| e.to_string()),
"sprig" => match std::env::args().nth(1).as_deref() {
Some("-V") | Some("--version") => {
println!("sprig {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("-h") | Some("--help") | None => {
print_usage();
if std::env::args().len() <= 1 {
Err("error: invoke Sprig via a personality symlink".into())
} else {
Ok(())
}
}
Some(other) => {
print_usage();
Err(format!(
"error: unknown Sprig option or personality: {other}"
))
}
},
// sprout-dev-mcp also handles its own multicall names: rg, tree,
// sprout, git-credential-nostr, and git-sign-nostr.
_ => sprout_dev_mcp::run().map_err(|e| e.to_string()),
}
}
fn print_usage() {
println!(
"Sprig — all-in-one Sprout ACP harness, agent, and developer MCP\n\n\
Sprig is a multicall binary. Invoke it through one of the personality names:\n\n\
sprout-acp ACP harness\n sprout-agent ACP-compliant agent\n sprout-dev-mcp Developer MCP server\n\n\
Developer MCP helper names are also supported: rg, tree, sprout, git-credential-nostr, git-sign-nostr.\n\n\
Installers can create links with:\n ln -s sprig sprout-acp\n ln -s sprig sprout-agent\n ln -s sprig sprout-dev-mcp"
);
}
+4
View File
@@ -7,6 +7,10 @@ license.workspace = true
repository.workspace = true
description = "ACP harness that bridges Sprout events to AI agents"
[lib]
name = "sprout_acp"
path = "src/lib.rs"
[[bin]]
name = "sprout-acp"
path = "src/main.rs"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -10,6 +10,10 @@ readme = "README.md"
keywords = ["acp", "agent", "llm", "mcp", "minimal"]
categories = ["command-line-utilities", "web-programming"]
[lib]
name = "sprout_agent"
path = "src/lib.rs"
[[bin]]
name = "sprout-agent"
path = "src/main.rs"
+368
View File
@@ -0,0 +1,368 @@
#![forbid(unsafe_code)]
mod agent;
mod config;
mod handoff;
mod llm;
mod mcp;
mod types;
mod wire;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::BufReader;
use tokio::sync::{mpsc, watch, Mutex};
use crate::agent::RunCtx;
use crate::config::{Config, PROTOCOL_VERSION};
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::types::HistoryItem;
use crate::wire::{
classify, Inbound, InitializeParams, SessionCancelParams, SessionNewParams,
SessionPromptParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR,
};
struct App {
cfg: Config,
llm: Arc<Llm>,
sessions: Mutex<HashMap<String, Session>>,
}
struct Session {
id: String,
mcp: Arc<McpRegistry>,
history: Vec<HistoryItem>,
cancel_tx: watch::Sender<bool>,
busy: bool,
original_task: Option<String>,
handoff_count: usize,
stop_rejections: u32,
}
fn die(msg: String) -> ! {
tracing::error!("{msg}");
std::process::exit(2);
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async_main());
Ok(())
}
async fn async_main() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cfg = Config::from_env().unwrap_or_else(|e| die(e));
let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string())));
let max_line = cfg.max_line_bytes;
let app = Arc::new(App {
cfg,
llm,
sessions: Mutex::new(HashMap::new()),
});
let (wire_tx, wire_rx) = mpsc::channel::<WireMsg>(64);
let writer = tokio::spawn(wire::writer_task(wire_rx));
if let Err(e) = read_loop(
BufReader::new(tokio::io::stdin()),
app.clone(),
wire_tx,
max_line,
)
.await
{
tracing::error!("io: reader: {e}");
}
for session in app.sessions.lock().await.values() {
let _ = session.cancel_tx.send(true);
}
let _ = writer.await;
}
async fn read_loop<R: tokio::io::AsyncBufRead + Unpin>(
mut stdin: R,
app: Arc<App>,
wire_tx: WireSender,
max_line: usize,
) -> std::io::Result<()> {
while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(msg) => dispatch(&app, msg, &wire_tx).await,
Err(e) => {
wire::send(
&wire_tx,
wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")),
)
.await;
}
}
}
Ok(())
}
async fn dispatch(app: &Arc<App>, msg: Value, wire_tx: &WireSender) {
match classify(&msg) {
Inbound::Request { id, method, params } => {
handle_request(app, id, method, params, wire_tx).await
}
Inbound::Notification { method, params } => handle_notification(app, &method, params).await,
Inbound::Ignored => {}
Inbound::Invalid { id, code, message } => {
wire::send(wire_tx, wire::err(id, code, &message)).await
}
}
}
async fn handle_request(
app: &Arc<App>,
id: Value,
method: String,
params: Value,
wire_tx: &WireSender,
) {
match method.as_str() {
"initialize" => initialize(id, params, wire_tx).await,
"session/new" => {
let app = app.clone();
let wire_tx = wire_tx.clone();
tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await });
}
"session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()),
"session/cancel" => {
cancel_session(app, params).await;
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
}
_ => {
wire::send(
wire_tx,
wire::err(
id,
METHOD_NOT_FOUND,
&format!("jsonrpc: method not found: {method}"),
),
)
.await
}
}
}
async fn handle_notification(app: &Arc<App>, method: &str, params: Value) {
if method == "session/cancel" {
cancel_session(app, params).await;
}
}
async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
let p: InitializeParams = match decode(params, "initialize") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
let _ = p.protocol_version;
wire::send(
wire_tx,
wire::ok(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
"mcpCapabilities": { "http": false, "sse": false },
},
"agentInfo": { "name": "sprout-agent", "version": env!("CARGO_PKG_VERSION") },
}),
),
)
.await;
}
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionNewParams = match decode(params, "session/new") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: cwd must be an absolute path",
)
.await;
}
// Check cap without holding lock across MCP spawn (which may be slow).
{
let sessions = app.sessions.lock().await;
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
}
let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
Ok(m) => Arc::new(m),
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
};
let session_id = match session_token() {
Ok(t) => format!("ses_{t}"),
Err(e) => return reject(wire_tx, id, -32000, &e).await,
};
let (cancel_tx, _) = watch::channel(false);
let mut sessions = app.sessions.lock().await;
// Re-check cap (another session may have been created while we spawned MCP).
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
sessions.insert(
session_id.clone(),
Session {
id: session_id.clone(),
mcp,
history: Vec::new(),
cancel_tx,
busy: false,
original_task: None,
handoff_count: 0,
stop_rejections: 0,
},
);
drop(sessions);
wire::send(wire_tx, wire::ok(id, json!({ "sessionId": session_id }))).await;
}
fn decode<T: serde::de::DeserializeOwned>(params: Value, stage: &str) -> Result<T, String> {
serde_json::from_value(params).map_err(|e| format!("{stage}: {e}"))
}
async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) {
wire::send(wire_tx, wire::err(id, code, message)).await;
}
async fn cancel_session(app: &Arc<App>, params: Value) {
if let Ok(p) = serde_json::from_value::<SessionCancelParams>(params) {
if let Some(s) = app.sessions.lock().await.get(&p.session_id) {
let _ = s.cancel_tx.send(true);
}
}
}
fn spawn_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await });
}
async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
let p: SessionPromptParams = match decode(params, "session/prompt") {
Ok(p) => p,
Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await,
};
let (
sid,
mcp,
mut history,
mut original_task,
mut handoff_count,
mut stop_rejections,
mut cancel_rx,
) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v,
Err(reason) => {
return reject(
&wire_tx,
id,
INVALID_PARAMS,
&format!("session/prompt: {reason}"),
)
.await
}
};
let mut ctx = RunCtx {
cfg: &app.cfg,
session_id: &sid,
llm: &app.llm,
mcp: &mcp,
wire: &wire_tx,
cancel: &mut cancel_rx,
history: &mut history,
original_task: &mut original_task,
handoff_count: &mut handoff_count,
stop_rejections: &mut stop_rejections,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
s.busy = false;
s.history = history;
s.original_task = original_task;
s.handoff_count = handoff_count;
s.stop_rejections = stop_rejections;
}
match result {
Ok(stop) => {
wire::send(
&wire_tx,
wire::ok(id, json!({ "stopReason": stop.as_wire() })),
)
.await
}
Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await,
}
}
async fn acquire_session(
app: &Arc<App>,
session_id: &str,
) -> Result<
(
String,
Arc<McpRegistry>,
Vec<HistoryItem>,
Option<String>,
usize,
u32,
watch::Receiver<bool>,
),
&'static str,
> {
let mut sessions = app.sessions.lock().await;
let s = sessions.get_mut(session_id).ok_or("unknown session")?;
if s.busy {
return Err("prompt already in flight");
}
s.busy = true;
let (tx, rx) = watch::channel(false);
s.cancel_tx = tx;
Ok((
s.id.clone(),
s.mcp.clone(),
std::mem::take(&mut s.history),
s.original_task.take(),
s.handoff_count,
s.stop_rejections,
rx,
))
}
fn session_token() -> Result<String, String> {
let mut b = [0u8; 8];
getrandom::getrandom(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?;
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}
+4 -359
View File
@@ -1,361 +1,6 @@
#![forbid(unsafe_code)]
mod agent;
mod config;
mod handoff;
mod llm;
mod mcp;
mod types;
mod wire;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::BufReader;
use tokio::sync::{mpsc, watch, Mutex};
use crate::agent::RunCtx;
use crate::config::{Config, PROTOCOL_VERSION};
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::types::HistoryItem;
use crate::wire::{
classify, Inbound, InitializeParams, SessionCancelParams, SessionNewParams,
SessionPromptParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR,
};
struct App {
cfg: Config,
llm: Arc<Llm>,
sessions: Mutex<HashMap<String, Session>>,
}
struct Session {
id: String,
mcp: Arc<McpRegistry>,
history: Vec<HistoryItem>,
cancel_tx: watch::Sender<bool>,
busy: bool,
original_task: Option<String>,
handoff_count: usize,
stop_rejections: u32,
}
fn die(msg: String) -> ! {
tracing::error!("{msg}");
std::process::exit(2);
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cfg = Config::from_env().unwrap_or_else(|e| die(e));
let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string())));
let max_line = cfg.max_line_bytes;
let app = Arc::new(App {
cfg,
llm,
sessions: Mutex::new(HashMap::new()),
});
let (wire_tx, wire_rx) = mpsc::channel::<WireMsg>(64);
let writer = tokio::spawn(wire::writer_task(wire_rx));
if let Err(e) = read_loop(
BufReader::new(tokio::io::stdin()),
app.clone(),
wire_tx,
max_line,
)
.await
{
tracing::error!("io: reader: {e}");
}
for session in app.sessions.lock().await.values() {
let _ = session.cancel_tx.send(true);
}
let _ = writer.await;
}
async fn read_loop<R: tokio::io::AsyncBufRead + Unpin>(
mut stdin: R,
app: Arc<App>,
wire_tx: WireSender,
max_line: usize,
) -> std::io::Result<()> {
while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(msg) => dispatch(&app, msg, &wire_tx).await,
Err(e) => {
wire::send(
&wire_tx,
wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")),
)
.await;
}
}
}
Ok(())
}
async fn dispatch(app: &Arc<App>, msg: Value, wire_tx: &WireSender) {
match classify(&msg) {
Inbound::Request { id, method, params } => {
handle_request(app, id, method, params, wire_tx).await
}
Inbound::Notification { method, params } => handle_notification(app, &method, params).await,
Inbound::Ignored => {}
Inbound::Invalid { id, code, message } => {
wire::send(wire_tx, wire::err(id, code, &message)).await
}
fn main() {
if let Err(e) = sprout_agent::run() {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
async fn handle_request(
app: &Arc<App>,
id: Value,
method: String,
params: Value,
wire_tx: &WireSender,
) {
match method.as_str() {
"initialize" => initialize(id, params, wire_tx).await,
"session/new" => {
let app = app.clone();
let wire_tx = wire_tx.clone();
tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await });
}
"session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()),
"session/cancel" => {
cancel_session(app, params).await;
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
}
_ => {
wire::send(
wire_tx,
wire::err(
id,
METHOD_NOT_FOUND,
&format!("jsonrpc: method not found: {method}"),
),
)
.await
}
}
}
async fn handle_notification(app: &Arc<App>, method: &str, params: Value) {
if method == "session/cancel" {
cancel_session(app, params).await;
}
}
async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
let p: InitializeParams = match decode(params, "initialize") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
let _ = p.protocol_version;
wire::send(
wire_tx,
wire::ok(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
"mcpCapabilities": { "http": false, "sse": false },
},
"agentInfo": { "name": "sprout-agent", "version": env!("CARGO_PKG_VERSION") },
}),
),
)
.await;
}
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionNewParams = match decode(params, "session/new") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: cwd must be an absolute path",
)
.await;
}
// Check cap without holding lock across MCP spawn (which may be slow).
{
let sessions = app.sessions.lock().await;
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
}
let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
Ok(m) => Arc::new(m),
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
};
let session_id = match session_token() {
Ok(t) => format!("ses_{t}"),
Err(e) => return reject(wire_tx, id, -32000, &e).await,
};
let (cancel_tx, _) = watch::channel(false);
let mut sessions = app.sessions.lock().await;
// Re-check cap (another session may have been created while we spawned MCP).
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
sessions.insert(
session_id.clone(),
Session {
id: session_id.clone(),
mcp,
history: Vec::new(),
cancel_tx,
busy: false,
original_task: None,
handoff_count: 0,
stop_rejections: 0,
},
);
drop(sessions);
wire::send(wire_tx, wire::ok(id, json!({ "sessionId": session_id }))).await;
}
fn decode<T: serde::de::DeserializeOwned>(params: Value, stage: &str) -> Result<T, String> {
serde_json::from_value(params).map_err(|e| format!("{stage}: {e}"))
}
async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) {
wire::send(wire_tx, wire::err(id, code, message)).await;
}
async fn cancel_session(app: &Arc<App>, params: Value) {
if let Ok(p) = serde_json::from_value::<SessionCancelParams>(params) {
if let Some(s) = app.sessions.lock().await.get(&p.session_id) {
let _ = s.cancel_tx.send(true);
}
}
}
fn spawn_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await });
}
async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
let p: SessionPromptParams = match decode(params, "session/prompt") {
Ok(p) => p,
Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await,
};
let (
sid,
mcp,
mut history,
mut original_task,
mut handoff_count,
mut stop_rejections,
mut cancel_rx,
) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v,
Err(reason) => {
return reject(
&wire_tx,
id,
INVALID_PARAMS,
&format!("session/prompt: {reason}"),
)
.await
}
};
let mut ctx = RunCtx {
cfg: &app.cfg,
session_id: &sid,
llm: &app.llm,
mcp: &mcp,
wire: &wire_tx,
cancel: &mut cancel_rx,
history: &mut history,
original_task: &mut original_task,
handoff_count: &mut handoff_count,
stop_rejections: &mut stop_rejections,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
s.busy = false;
s.history = history;
s.original_task = original_task;
s.handoff_count = handoff_count;
s.stop_rejections = stop_rejections;
}
match result {
Ok(stop) => {
wire::send(
&wire_tx,
wire::ok(id, json!({ "stopReason": stop.as_wire() })),
)
.await
}
Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await,
}
}
async fn acquire_session(
app: &Arc<App>,
session_id: &str,
) -> Result<
(
String,
Arc<McpRegistry>,
Vec<HistoryItem>,
Option<String>,
usize,
u32,
watch::Receiver<bool>,
),
&'static str,
> {
let mut sessions = app.sessions.lock().await;
let s = sessions.get_mut(session_id).ok_or("unknown session")?;
if s.busy {
return Err("prompt already in flight");
}
s.busy = true;
let (tx, rx) = watch::channel(false);
s.cancel_tx = tx;
Ok((
s.id.clone(),
s.mcp.clone(),
std::mem::take(&mut s.history),
s.original_task.take(),
s.handoff_count,
s.stop_rejections,
rx,
))
}
fn session_token() -> Result<String, String> {
let mut b = [0u8; 8];
getrandom::getrandom(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?;
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lib]
name = "sprout_dev_mcp"
path = "src/lib.rs"
[[bin]]
name = "sprout-dev-mcp"
path = "src/main.rs"
+169
View File
@@ -0,0 +1,169 @@
#![forbid(unsafe_code)]
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServerHandler, ServiceExt,
};
use std::path::Path;
use std::sync::Arc;
mod paths;
mod rg;
mod shell;
mod shim;
mod str_replace;
mod todo;
mod tree;
mod view_image;
#[derive(Clone)]
struct DevMcp {
state: Arc<shell::SharedState>,
todos: Arc<todo::TodoState>,
tool_router: ToolRouter<DevMcp>,
}
#[tool_router]
impl DevMcp {
fn new(state: Arc<shell::SharedState>) -> Self {
Self {
state,
todos: Arc::new(todo::TodoState::new()),
tool_router: Self::tool_router(),
}
}
#[tool(
name = "shell",
description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and sprout (Sprout relay CLI — run sprout --help for commands)."
)]
async fn shell(
&self,
Parameters(p): Parameters<shell::ShellParams>,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, ErrorData> {
shell::run(&self.state, p, context.ct).await
}
#[tool(
name = "view_image",
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
)]
async fn view_image(
&self,
Parameters(p): Parameters<view_image::ViewImageParams>,
) -> Result<CallToolResult, ErrorData> {
view_image::run(&self.state, p).await
}
#[tool(
name = "str_replace",
description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
)]
async fn str_replace(
&self,
Parameters(p): Parameters<str_replace::StrReplaceParams>,
) -> Result<String, ErrorData> {
str_replace::run(&self.state, p)
}
#[tool(
name = "todo",
description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
)]
async fn todo(
&self,
Parameters(p): Parameters<todo::TodoParams>,
) -> Result<CallToolResult, ErrorData> {
match self.todos.handle_todo(p) {
Ok(text) => todo::text_result(text),
Err(e) => todo::error_result(format!("Error: {e}")),
}
}
/// Hook: called by the agent before honoring end_turn. Returns
/// non-empty objection text iff items remain open.
#[tool(
name = "_Stop",
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
)]
async fn stop_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.stop_objection())
}
/// Hook: called by the agent after context compaction/handoff so the
/// todo list survives history truncation.
#[tool(
name = "_PostCompact",
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
)]
async fn post_compact_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.post_compact())
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DevMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"sprout-dev-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(self.state.bootstrap_instructions.clone())
}
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// Multicall dispatch — sync personalities exit before any runtime is built.
// No tracing, no tokio, no allocations beyond argv parsing.
match cmd.as_str() {
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
_ => {}
}
// Async personalities and MCP server mode — build the runtime.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async_main(cmd))
}
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
// sprout CLI needs tokio (async HTTP client).
if cmd == "sprout" {
std::process::exit(sprout_cli::run_from_args(std::env::args()).await);
}
// MCP server mode — safe to init tracing now.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cwd = std::env::current_dir()?;
let shim = shim::Shim::install()?;
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
let service = DevMcp::new(state).serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
+1 -167
View File
@@ -1,169 +1,3 @@
#![forbid(unsafe_code)]
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServerHandler, ServiceExt,
};
use std::path::Path;
use std::sync::Arc;
mod paths;
mod rg;
mod shell;
mod shim;
mod str_replace;
mod todo;
mod tree;
mod view_image;
#[derive(Clone)]
struct DevMcp {
state: Arc<shell::SharedState>,
todos: Arc<todo::TodoState>,
tool_router: ToolRouter<DevMcp>,
}
#[tool_router]
impl DevMcp {
fn new(state: Arc<shell::SharedState>) -> Self {
Self {
state,
todos: Arc::new(todo::TodoState::new()),
tool_router: Self::tool_router(),
}
}
#[tool(
name = "shell",
description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and sprout (Sprout relay CLI — run sprout --help for commands)."
)]
async fn shell(
&self,
Parameters(p): Parameters<shell::ShellParams>,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, ErrorData> {
shell::run(&self.state, p, context.ct).await
}
#[tool(
name = "view_image",
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
)]
async fn view_image(
&self,
Parameters(p): Parameters<view_image::ViewImageParams>,
) -> Result<CallToolResult, ErrorData> {
view_image::run(&self.state, p).await
}
#[tool(
name = "str_replace",
description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
)]
async fn str_replace(
&self,
Parameters(p): Parameters<str_replace::StrReplaceParams>,
) -> Result<String, ErrorData> {
str_replace::run(&self.state, p)
}
#[tool(
name = "todo",
description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
)]
async fn todo(
&self,
Parameters(p): Parameters<todo::TodoParams>,
) -> Result<CallToolResult, ErrorData> {
match self.todos.handle_todo(p) {
Ok(text) => todo::text_result(text),
Err(e) => todo::error_result(format!("Error: {e}")),
}
}
/// Hook: called by the agent before honoring end_turn. Returns
/// non-empty objection text iff items remain open.
#[tool(
name = "_Stop",
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
)]
async fn stop_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.stop_objection())
}
/// Hook: called by the agent after context compaction/handoff so the
/// todo list survives history truncation.
#[tool(
name = "_PostCompact",
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
)]
async fn post_compact_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.post_compact())
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DevMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"sprout-dev-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(self.state.bootstrap_instructions.clone())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// Multicall dispatch — sync personalities exit before any runtime is built.
// No tracing, no tokio, no allocations beyond argv parsing.
match cmd.as_str() {
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
_ => {}
}
// Async personalities and MCP server mode — build the runtime.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async_main(cmd))
}
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
// sprout CLI needs tokio (async HTTP client).
if cmd == "sprout" {
std::process::exit(sprout_cli::run_from_args(std::env::args()).await);
}
// MCP server mode — safe to init tracing now.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cwd = std::env::current_dir()?;
let shim = shim::Shim::install()?;
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
let service = DevMcp::new(state).serve(stdio()).await?;
service.waiting().await?;
Ok(())
sprout_dev_mcp::run()
}
-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/env bash
# Build the Sprout Agent Bundle — a tarball containing the three binaries
# needed to run a Sprout agent end-to-end:
#
# sprout-acp ACP harness
# sprout-agent ACP-compliant agent (spawns MCP, calls LLMs)
# sprout-dev-mcp Developer MCP server (multicall: rg/tree/sprout/git-*)
#
# Usage:
# ./scripts/build-agent-bundle.sh [version] [target]
#
# Environment overrides:
# TARGET cross-compile target (defaults to host)
# USE_CROSS=1 use `cross` instead of `cargo` for the build
# SKIP_BUILD=1 skip the cargo/cross build (use prebuilt binaries
# already present in target/[<target>/]release)
# ARCHIVE_BASENAME override the archive basename (sans .tar.gz). Useful
# for rolling releases where the asset filename should
# be stable across builds (e.g. `sprout-agent-bundle-
# <target>`). Defaults to
# `sprout-agent-bundle-<version>-<target>`.
# DIST_DIR output directory (default: dist)
#
# Output:
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz.sha256
#
# The tarball contains:
# sprout-acp
# sprout-agent
# sprout-dev-mcp
# README.md
# bundle.json { version, git_sha, target, binaries: [{name, sha256, size}] }
set -euo pipefail
VERSION="${1:-${VERSION:-0.0.0-dev}}"
HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')"
TARGET="${2:-${TARGET:-$HOST_TARGET}}"
DIST_DIR="${DIST_DIR:-dist}"
# Resolve git SHA (best effort — works in CI checkout and local clones).
if GIT_SHA="$(git rev-parse HEAD 2>/dev/null)"; then
:
else
GIT_SHA="unknown"
fi
BINARIES=(sprout-acp sprout-agent sprout-dev-mcp)
echo "==> Building Sprout Agent Bundle v${VERSION} for ${TARGET}"
echo " git_sha=${GIT_SHA}"
echo " binaries=${BINARIES[*]}"
# Pick build driver. `cross` is required for cross-compilation in CI;
# for host builds we use plain `cargo` so contributors don't need Docker.
if [[ "${USE_CROSS:-0}" == "1" ]] || [[ "$TARGET" != "$HOST_TARGET" ]]; then
if ! command -v cross >/dev/null 2>&1; then
echo "error: cross-compiling to $TARGET requires \`cross\` (install: cargo install cross --version 0.2.5)" >&2
exit 1
fi
BUILDER=(cross build --release --target "$TARGET")
BIN_DIR="target/${TARGET}/release"
else
BUILDER=(cargo build --release)
BIN_DIR="target/release"
fi
PKG_ARGS=()
for bin in "${BINARIES[@]}"; do
PKG_ARGS+=(-p "$bin")
done
if [[ "${SKIP_BUILD:-0}" == "1" ]]; then
echo " (SKIP_BUILD=1 set — expecting prebuilt binaries in ${BIN_DIR}/)"
else
"${BUILDER[@]}" "${PKG_ARGS[@]}"
fi
# Verify all binaries exist.
for bin in "${BINARIES[@]}"; do
if [[ ! -f "${BIN_DIR}/${bin}" ]]; then
echo "error: ${BIN_DIR}/${bin} not found after build" >&2
exit 1
fi
done
# Stage into a tempdir.
mkdir -p "${DIST_DIR}"
STAGING="$(mktemp -d)"
trap 'rm -rf "${STAGING}"' EXIT
# sha256 helper: prefer sha256sum (linux), fall back to shasum -a 256 (macos).
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
# Copy + strip binaries, collect manifest entries.
MANIFEST_ENTRIES=()
for bin in "${BINARIES[@]}"; do
cp "${BIN_DIR}/${bin}" "${STAGING}/${bin}"
chmod 0755 "${STAGING}/${bin}"
# Best-effort strip. `cross` images include the cross-target strip; on host
# we use the system strip. Skip silently if unavailable (e.g. cross-arch
# local builds on macOS).
if command -v strip >/dev/null 2>&1; then
strip "${STAGING}/${bin}" 2>/dev/null || true
fi
sha="$(sha256_of "${STAGING}/${bin}")"
size="$(wc -c < "${STAGING}/${bin}" | tr -d ' ')"
MANIFEST_ENTRIES+=("{\"name\":\"${bin}\",\"sha256\":\"${sha}\",\"size\":${size}}")
done
# bundle.json — machine-readable manifest.
ENTRIES_JSON="$(IFS=,; echo "${MANIFEST_ENTRIES[*]}")"
cat > "${STAGING}/bundle.json" <<JSON
{
"name": "sprout-agent-bundle",
"version": "${VERSION}",
"git_sha": "${GIT_SHA}",
"target": "${TARGET}",
"binaries": [${ENTRIES_JSON}]
}
JSON
# README — human-readable.
cat > "${STAGING}/README.md" <<'EOF'
# Sprout Agent Bundle
Linux build of the three binaries needed to run a Sprout agent end-to-end:
- `sprout-acp` — ACP harness that bridges Sprout channel events to an
ACP-compliant agent over stdio.
- `sprout-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs).
- `sprout-dev-mcp` — Developer MCP server (shell, str_replace, todo) and
multicall entrypoint for `rg`, `tree`, `sprout`, `git-credential-nostr`,
`git-sign-nostr`.
See `bundle.json` for binary SHA-256s, sizes, and the source git SHA.
## Install
```bash
tar -xzf sprout-agent-bundle-*.tar.gz -C /opt/sprout-agent
export PATH="/opt/sprout-agent:$PATH"
```
## Configure
```bash
# Agent provider
export SPROUT_AGENT_PROVIDER=anthropic # or openai
export ANTHROPIC_API_KEY=sk-...
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Nostr identity (shared by sprout-acp, git auth, signing, and sprout CLI)
export NOSTR_PRIVATE_KEY=nsec1...
export SPROUT_PRIVATE_KEY="$NOSTR_PRIVATE_KEY"
export SPROUT_RELAY_URL=https://your-relay.example.com
```
## Git Integration
When `NOSTR_PRIVATE_KEY` is set, `sprout-dev-mcp` automatically configures
git to use nostr-based credential auth and commit signing for all shell
commands. This is ephemeral (session-scoped via `GIT_CONFIG_*` env vars)
your persistent git config is never modified.
The nostr credential helper is additive: it silently declines non-Sprout
remotes so git falls through to your system credential helpers for GitHub,
GitLab, etc. `NOSTR_PRIVATE_KEY` is written to a 0600 keyfile and removed
from the process environment — shell commands cannot read it from env.
## Multicall Binary
`sprout-dev-mcp` is a multicall binary. When symlinked/invoked as:
- `rg` — ripgrep-compatible search
- `tree` — directory tree with line counts
- `sprout` — Sprout relay CLI
- `git-credential-nostr` — NIP-98 git credential helper
- `git-sign-nostr` — NIP-GS git commit/tag signing
…it dispatches to the corresponding subcommand. The installer is free to
symlink these names next to `sprout-dev-mcp` on the PATH.
EOF
# Tar.
ARCHIVE_BASENAME="${ARCHIVE_BASENAME:-sprout-agent-bundle-${VERSION}-${TARGET}}"
ARCHIVE_NAME="${ARCHIVE_BASENAME}.tar.gz"
ARCHIVE_PATH="${DIST_DIR}/${ARCHIVE_NAME}"
# Deterministic-ish tar: sorted entries, no owner/group info.
tar \
--sort=name \
--owner=0 --group=0 --numeric-owner \
-czf "${ARCHIVE_PATH}" \
-C "${STAGING}" \
. 2>/dev/null || \
tar -czf "${ARCHIVE_PATH}" -C "${STAGING}" . # fallback for BSD tar (macOS)
# Sidecar checksum.
sha256_of "${ARCHIVE_PATH}" > "${ARCHIVE_PATH}.sha256"
# Pretty-print the form `<sha> <filename>` like sha256sum -c expects.
echo "$(cat "${ARCHIVE_PATH}.sha256") ${ARCHIVE_NAME}" > "${ARCHIVE_PATH}.sha256"
echo ""
echo "==> Built: ${ARCHIVE_PATH}"
ls -lh "${ARCHIVE_PATH}" "${ARCHIVE_PATH}.sha256"
echo ""
echo "==> bundle.json:"
sed 's/^/ /' "${STAGING}/bundle.json"
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# Build Sprig — one deploy-anywhere multicall binary for the Sprout ACP
# harness, agent, and developer MCP. The archive exposes these command names:
#
# sprig implementation binary
# sprout-acp link to sprig (ACP harness)
# sprout-agent link to sprig (ACP-compliant agent)
# sprout-dev-mcp link to sprig (developer MCP server; also dispatches
# rg/tree/sprout/git-credential-nostr/git-sign-nostr)
#
# Usage:
# ./scripts/build-sprig.sh [version] [target]
#
# Environment overrides:
# TARGET cross-compile target (defaults to host)
# USE_CROSS=1 use `cross` instead of `cargo` for the build
# BUILD_PROFILE Cargo profile to build/package (default: sprig).
# Set BUILD_PROFILE=release to use Cargo's default release
# profile. BUILD_PROFILE=dev/debug is rejected because Cargo
# writes dev builds to target/debug, not target/dev.
# SKIP_BUILD=1 skip the cargo/cross build (use a prebuilt sprig already
# present in target/[<target>/]<profile>)
# ARCHIVE_BASENAME override the archive basename (sans .tar.gz). Useful for
# rolling releases where the asset filename should be stable
# across builds (e.g. `sprig-<target>`). Defaults to
# `sprig-<version>-<target>`.
# DIST_DIR output directory (default: dist)
#
# Output:
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz.sha256
#
# The tarball contains:
# sprig
# sprout-acp
# sprout-agent
# sprout-dev-mcp
# README.md
# sprig.json { version, git_sha, target, binaries: [{name, sha256, size}] }
set -euo pipefail
VERSION="${1:-${VERSION:-0.0.0-dev}}"
HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')"
TARGET="${2:-${TARGET:-$HOST_TARGET}}"
DIST_DIR="${DIST_DIR:-dist}"
BUILD_PROFILE="${BUILD_PROFILE:-sprig}"
case "$BUILD_PROFILE" in
dev|debug)
echo "error: BUILD_PROFILE=$BUILD_PROFILE is not supported by this script; Cargo writes dev builds to target/debug. Use a release-like profile such as 'sprig' or 'release'." >&2
exit 1
;;
esac
if GIT_SHA="$(git rev-parse HEAD 2>/dev/null)"; then
:
else
GIT_SHA="unknown"
fi
BUNDLE_BIN="sprig"
COMMANDS=(sprout-acp sprout-agent sprout-dev-mcp)
echo "==> Building Sprig v${VERSION} for ${TARGET}"
echo " git_sha=${GIT_SHA}"
echo " binary=${BUNDLE_BIN}"
echo " commands=${COMMANDS[*]}"
echo " cargo_profile=${BUILD_PROFILE}"
if [[ "${USE_CROSS:-0}" == "1" ]] || [[ "$TARGET" != "$HOST_TARGET" ]]; then
if ! command -v cross >/dev/null 2>&1; then
echo "error: cross-compiling to $TARGET requires \`cross\` (install: cargo install cross --version 0.2.5)" >&2
exit 1
fi
BUILDER=(cross build --profile "$BUILD_PROFILE" --target "$TARGET")
BIN_DIR="target/${TARGET}/${BUILD_PROFILE}"
else
BUILDER=(cargo build --profile "$BUILD_PROFILE")
BIN_DIR="target/${BUILD_PROFILE}"
fi
if [[ "${SKIP_BUILD:-0}" == "1" ]]; then
echo " (SKIP_BUILD=1 set — expecting prebuilt ${BUNDLE_BIN} in ${BIN_DIR}/)"
else
"${BUILDER[@]}" -p "$BUNDLE_BIN"
fi
if [[ ! -f "${BIN_DIR}/${BUNDLE_BIN}" ]]; then
echo "error: ${BIN_DIR}/${BUNDLE_BIN} not found after build" >&2
exit 1
fi
mkdir -p "${DIST_DIR}"
STAGING="$(mktemp -d)"
trap 'rm -rf "${STAGING}"' EXIT
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
cp "${BIN_DIR}/${BUNDLE_BIN}" "${STAGING}/${BUNDLE_BIN}"
chmod 0755 "${STAGING}/${BUNDLE_BIN}"
if command -v strip >/dev/null 2>&1; then
strip "${STAGING}/${BUNDLE_BIN}" 2>/dev/null || true
fi
MANIFEST_ENTRIES=()
ALL_NAMES=("${BUNDLE_BIN}" "${COMMANDS[@]}")
for bin in "${ALL_NAMES[@]}"; do
if [[ "$bin" != "$BUNDLE_BIN" ]]; then
ln -s "${BUNDLE_BIN}" "${STAGING}/${bin}"
fi
sha="$(sha256_of "${STAGING}/${bin}")"
size="$(wc -c < "${STAGING}/${bin}" | tr -d ' ')"
MANIFEST_ENTRIES+=("{\"name\":\"${bin}\",\"sha256\":\"${sha}\",\"size\":${size}}")
done
ENTRIES_JSON="$(IFS=,; echo "${MANIFEST_ENTRIES[*]}")"
cat > "${STAGING}/sprig.json" <<JSON
{
"name": "sprig",
"version": "${VERSION}",
"git_sha": "${GIT_SHA}",
"target": "${TARGET}",
"binaries": [${ENTRIES_JSON}]
}
JSON
cat > "${STAGING}/README.md" <<'README'
# Sprig
Sprig is the all-in-one Sprout agent binary for deploy-anywhere environments.
It exposes the ACP harness, ACP agent, and developer MCP command names as symlinks
to one multicall binary so shared Rust runtime/TLS code is stored only once.
Commands:
- `sprig` — prints usage/version. Invoke a personality by one of the links below.
- `sprout-acp` — ACP harness that bridges Sprout channel events to an
ACP-compliant agent over stdio.
- `sprout-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs).
- `sprout-dev-mcp` — Developer MCP server (shell, str_replace, todo) and
multicall entrypoint for `rg`, `tree`, `sprout`, `git-credential-nostr`,
`git-sign-nostr`.
See `sprig.json` for SHA-256s, sizes, target, and source git SHA.
## Install
```bash
tar -xzf sprig-*.tar.gz -C /opt/sprig
export PATH="/opt/sprig:$PATH"
```
## Configure
```bash
# Agent provider
export SPROUT_AGENT_PROVIDER=anthropic # or openai
export ANTHROPIC_API_KEY=sk-...
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Nostr identity (shared by sprout-acp, git auth, signing, and sprout CLI)
export NOSTR_PRIVATE_KEY=nsec1...
export SPROUT_PRIVATE_KEY="$NOSTR_PRIVATE_KEY"
export SPROUT_RELAY_URL=https://your-relay.example.com
```
README
ARCHIVE_BASENAME="${ARCHIVE_BASENAME:-sprig-${VERSION}-${TARGET}}"
ARCHIVE_NAME="${ARCHIVE_BASENAME}.tar.gz"
ARCHIVE_PATH="${DIST_DIR}/${ARCHIVE_NAME}"
tar \
--sort=name \
--owner=0 --group=0 --numeric-owner \
-czf "${ARCHIVE_PATH}" \
-C "${STAGING}" \
. 2>/dev/null || \
tar -czf "${ARCHIVE_PATH}" -C "${STAGING}" .
sha256_of "${ARCHIVE_PATH}" > "${ARCHIVE_PATH}.sha256"
echo "$(cat "${ARCHIVE_PATH}.sha256") ${ARCHIVE_NAME}" > "${ARCHIVE_PATH}.sha256"
echo ""
echo "==> Built: ${ARCHIVE_PATH}"
ls -lh "${ARCHIVE_PATH}" "${ARCHIVE_PATH}.sha256"
echo ""
echo "==> sprig.json:"
sed 's/^/ /' "${STAGING}/sprig.json"