use std::path::{Path, PathBuf};
use crate::account::{get_accounts_path, AccountStore};
use crate::project::Project;
const IDENTITY: &str = "\
You are Procyon, a terminal development harness for Stellar and Soroban smart contracts. You \
work inside the user's workspace through the tools you are given.";
const WORKING_RULES: &str = "\
## Working rules
- Locate code with `glob` and `grep` before reading it. Do not guess file paths.
- When the answer depends on how Stellar or Soroban actually behaves — storage and TTL, auth, \
SEPs, CLI flags, fees, XDR — or on explaining a contract or host error, look it up with a \
connected MCP tool and cite what you found. Prefer looking it up over recalling from memory, \
and say so plainly when no source is available rather than guessing.
- Every file tool is confined to the workspace; paths outside it are rejected, so do not try \
absolute paths elsewhere.
- `write_file` and `edit_file` change the user's files immediately and are not undoable by you. \
Read a file before editing it, and prefer `edit_file` over rewriting a whole file.
- In a project that has `caatinga.config.ts`, use the `caatinga_*` tools for build, deploy and \
invoke rather than raw `stellar` CLI commands. They take contract *names* from that config, never \
a wasm path or a contract id: Caatinga resolves those from `caatinga.artifacts.json`, deploys in \
dependency order, and regenerates bindings afterwards. Do not copy a contract id out of a deploy \
log — read it from the artifacts, or let the tool report it. In a project without that config the \
`caatinga_*` tools will refuse, and the `stellar` CLI is the right path.
- Signing is always by Stellar CLI identity alias, such as `alice`. Never pass a secret key, seed \
phrase or raw address as `source`: it would reach the process list and the session log.
- Any tool that signs and submits requires the network as an explicit argument; there is no \
default. State the network you are about to act on before calling it.
- Mainnet is refused unless the operator enabled it in the config or the environment. You cannot \
enable it — if a mainnet operation is wanted, say what the user has to set and stop. Prefer \
`caatinga_read` over `caatinga_invoke` whenever you only need to read a value: it simulates, so it \
signs nothing and costs nothing.
- When a tool fails, read its error before retrying. Do not repeat an identical failing call.";
pub struct WorkspaceContext {
pub cwd: PathBuf,
pub project: Option<Project>,
pub accounts: Vec<String>,
pub stellar_cli: Option<String>,
pub npx: bool,
pub mcp_servers: Vec<String>,
}
impl WorkspaceContext {
pub async fn gather(cwd: &Path, mcp_servers: &[String]) -> Self {
let project_dir = Project::find_project_dir(cwd).await.ok();
let project = match &project_dir {
Some(dir) => Project::load(&dir.join(".procyon").join("project.toml"))
.await
.ok(),
None => None,
};
let accounts = match &project_dir {
Some(dir) => AccountStore::load(&get_accounts_path(dir))
.await
.map(|store| {
store
.list()
.iter()
.map(|a| format!("{} ({}) [{}]", a.name, a.address, a.network))
.collect()
})
.unwrap_or_default(),
None => Vec::new(),
};
Self {
cwd: cwd.to_path_buf(),
project,
accounts,
stellar_cli: stellar_cli_version().await,
npx: which::which("npx").is_ok(),
mcp_servers: mcp_servers.to_vec(),
}
}
pub fn system_prompt(&self) -> String {
let mut out = String::from(IDENTITY);
out.push_str("\n\n## Environment\n\n");
out.push_str(&format!("- Workspace: {}\n", self.cwd.display()));
match &self.project {
Some(project) => {
out.push_str(&format!(
"- Project: {} v{}\n- Default network: {}\n",
project.name, project.version, project.default_network
));
if project.contracts.is_empty() {
out.push_str("- Contracts: none registered yet\n");
} else {
out.push_str("- Contracts:\n");
for contract in &project.contracts {
out.push_str(&format!(
" - {} ({})\n",
contract.name,
contract.address.as_deref().unwrap_or("not deployed")
));
}
}
}
None => out.push_str(
"- Project: no .procyon/project.toml found. Use `project_init` before tools that \
need project state.\n",
),
}
if self.accounts.is_empty() {
out.push_str("- Accounts: none configured\n");
} else {
out.push_str("- Accounts:\n");
for account in &self.accounts {
out.push_str(&format!(" - {}\n", account));
}
}
out.push_str(&format!(
"- stellar CLI: {}\n- npx: {}\n",
self.stellar_cli.as_deref().unwrap_or("not installed"),
if self.npx {
"available"
} else {
"not installed"
}
));
if self.mcp_servers.is_empty() {
out.push_str(
"- MCP servers: none connected, so you have no way to look up Stellar facts\n",
);
} else {
out.push_str("- MCP servers:\n");
for server in &self.mcp_servers {
out.push_str(&format!(" - {}\n", server));
}
}
out.push('\n');
out.push_str(WORKING_RULES);
out
}
}
async fn stellar_cli_version() -> Option<String> {
if which::which("stellar").is_err() {
return None;
}
let output = tokio::process::Command::new("stellar")
.arg("--version")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|line| line.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::{Contract, Network};
fn context() -> WorkspaceContext {
WorkspaceContext {
cwd: PathBuf::from("/w/demo"),
project: None,
accounts: Vec::new(),
stellar_cli: None,
npx: false,
mcp_servers: Vec::new(),
}
}
#[test]
fn states_the_identity_and_the_workspace() {
let prompt = context().system_prompt();
assert!(prompt.contains("Stellar and Soroban"));
assert!(prompt.contains("/w/demo"));
}
#[test]
fn says_plainly_when_there_is_no_project() {
let prompt = context().system_prompt();
assert!(prompt.contains("no .procyon/project.toml found"));
assert!(prompt.contains("Accounts: none configured"));
}
#[test]
fn reports_missing_tooling_instead_of_staying_silent() {
let prompt = context().system_prompt();
assert!(prompt.contains("stellar CLI: not installed"));
assert!(prompt.contains("npx: not installed"));
}
#[test]
fn includes_project_network_and_contract_addresses() {
let mut ctx = context();
ctx.project = Some(Project {
name: "demo".to_string(),
version: "0.2.0".to_string(),
default_network: Network::Testnet,
contracts: vec![
Contract {
name: "token".to_string(),
address: Some("CDLZ".to_string()),
wasm_path: None,
},
Contract {
name: "pending".to_string(),
address: None,
wasm_path: None,
},
],
});
ctx.accounts = vec!["alice (GALICE) [testnet]".to_string()];
let prompt = ctx.system_prompt();
assert!(prompt.contains("demo v0.2.0"));
assert!(prompt.contains("Default network: testnet"));
assert!(prompt.contains("token (CDLZ)"));
assert!(prompt.contains("pending (not deployed)"));
assert!(prompt.contains("alice (GALICE) [testnet]"));
}
#[test]
fn says_plainly_when_no_mcp_server_is_connected() {
let prompt = context().system_prompt();
assert!(prompt.contains("none connected"), "got {}", prompt);
}
#[test]
fn lists_connected_mcp_servers_and_their_tools() {
let mut ctx = context();
ctx.mcp_servers = vec!["raven (https://raven.stellar.org/mcp): raven__search".to_string()];
let prompt = ctx.system_prompt();
assert!(prompt.contains("raven__search"), "got {}", prompt);
assert!(prompt.contains("https://raven.stellar.org/mcp"));
}
#[test]
fn directs_the_model_to_look_up_rather_than_recall() {
let prompt = context().system_prompt();
assert!(prompt.contains("Prefer looking it up over recalling from memory"));
assert!(prompt.contains("host error"));
}
#[test]
fn tells_the_model_to_search_before_reading() {
let prompt = context().system_prompt();
assert!(prompt.contains("`glob`"));
assert!(prompt.contains("`grep`"));
assert!(prompt.contains("Do not guess file paths"));
}
#[test]
fn warns_about_mainnet_and_unrecoverable_writes() {
let prompt = context().system_prompt();
assert!(prompt.contains("mainnet"));
assert!(prompt.contains("not undoable"));
}
#[tokio::test]
async fn gathering_in_a_directory_without_a_project_does_not_fail() {
let temp = tempfile::tempdir().unwrap();
let ctx = WorkspaceContext::gather(temp.path(), &[]).await;
assert!(ctx.project.is_none());
assert!(ctx.accounts.is_empty());
assert!(ctx.system_prompt().contains("no .procyon/project.toml"));
}
}
#[cfg(test)]
mod dump {
use super::*;
#[tokio::test]
#[ignore]
async fn dump_real_prompt() {
let cwd = std::env::current_dir().unwrap();
let servers = match crate::config::AppConfig::load() {
Ok(cfg) => crate::mcp::load_servers(&cfg.mcp_servers).await.1,
Err(_) => Vec::new(),
};
let prompt = WorkspaceContext::gather(&cwd, &servers)
.await
.system_prompt();
println!("{}", prompt);
assert!(
prompt.contains(&cwd.display().to_string()),
"the prompt must name the real workspace"
);
assert!(prompt.contains("## Environment"));
assert!(prompt.contains("## Working rules"));
}
}