mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add Sprig all-in-one agent binary (#605)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
+2
-2811
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Executable
+194
@@ -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"
|
||||
Reference in New Issue
Block a user