Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["agentflare-workspace-hack","crates/flare-code", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/flare-output", "crates/agentflare-artifacts", "crates/agentflare-backend", "crates/agentflare-db-kit", "crates/flare-search-kit", "crates/agentflare-store", "crates/flare-proxy", "crates/agentflare-shim", "crates/flare-git-core", "crates/flare-process", "crates/flare-git-shim", "crates/agentflare-jobs", "crates/flare-docs", "crates/flare-vault", "crates/agentflare-resource-gate", "crates/flare-workflow", "crates/flare-sandbox"]
members = ["agentflare-workspace-hack","crates/flare-code", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/flare-output", "crates/agentflare-artifacts", "crates/agentflare-backend", "crates/agentflare-db-kit", "crates/flare-search-kit", "crates/agentflare-store", "crates/flare-proxy", "crates/agentflare-shim", "crates/flare-git-core", "crates/flare-process", "crates/flare-git-shim", "crates/agentflare-jobs", "crates/flare-docs", "crates/flare-vault", "crates/agentflare-resource-gate", "crates/flare-workflow", "crates/flare-sandbox", "crates/agentflare-apps"]
resolver = "2"

[package]
Expand Down
18 changes: 18 additions & 0 deletions crates/agentflare-apps/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "agentflare-apps"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
gateway-registry = { package = "agentflare-gateway-registry", path = "../gateway-registry" }
agentflare-workspace-hack = { version = "0.1", path = "../../agentflare-workspace-hack" }

[dev-dependencies]
tempfile = "3"

[lints.rust]
unsafe_code = "warn"
4 changes: 4 additions & 0 deletions crates/agentflare-apps/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod manifest;
pub mod project;

pub use manifest::{AppManifest, ToolsManifest, load_app_manifest, load_tools_manifest};
86 changes: 86 additions & 0 deletions crates/agentflare-apps/src/manifest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize)]
struct RawAppManifest {
name: String,
version: String,
workflow: String,
#[serde(default)]
sandbox_profile: Option<String>,
}

#[derive(Debug, Clone)]
pub struct AppManifest {
pub name: String,
pub version: String,
pub workflow: PathBuf,
pub sandbox_profile: Option<String>,
}

pub fn load_app_manifest(app_dir: &Path) -> Result<AppManifest, String> {
let path = app_dir.join("app.toml");
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
let raw: RawAppManifest =
toml::from_str(&text).map_err(|e| format!("{}: invalid app.toml: {e}", path.display()))?;
Ok(AppManifest {
name: raw.name,
version: raw.version,
workflow: app_dir.join(raw.workflow),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
sandbox_profile: raw.sandbox_profile,
})
}

#[derive(Debug, Deserialize)]
pub struct ToolsManifest {
#[serde(default)]
pub servers: HashMap<String, gateway_registry::ServerConfig>,
}

pub fn load_tools_manifest(app_dir: &Path) -> Result<Option<ToolsManifest>, String> {
let path = app_dir.join("tools.toml");
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let parsed: ToolsManifest =
toml::from_str(&text).map_err(|e| format!("{}: invalid tools.toml: {e}", path.display()))?;
Ok(Some(parsed))
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;

#[test]
fn loads_a_minimal_app_toml() {
let dir = tempfile::tempdir().unwrap();
let mut f = std::fs::File::create(dir.path().join("app.toml")).unwrap();
writeln!(
f,
r#"
name = "auto-company"
version = "0.1.0"
workflow = "workflow.json"
"#
)
.unwrap();

let manifest = load_app_manifest(dir.path()).unwrap();
assert_eq!(manifest.name, "auto-company");
assert_eq!(manifest.version, "0.1.0");
assert_eq!(manifest.workflow, dir.path().join("workflow.json"));
assert_eq!(manifest.sandbox_profile, None);
}

#[test]
fn missing_app_toml_is_a_clear_error() {
let dir = tempfile::tempdir().unwrap();
let err = load_app_manifest(dir.path()).unwrap_err();
assert!(err.contains("app.toml"), "error should name the missing file: {err}");
}
}
148 changes: 148 additions & 0 deletions crates/agentflare-apps/src/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use crate::manifest::ToolsManifest;
use gateway_registry::ServerConfig;
use std::path::Path;

pub fn project_for_claude_code(
app_dir: &Path,
scratch_dir: &Path,
tools: Option<&ToolsManifest>,
) -> Result<(), String> {
project_dir(
&app_dir.join("personas"),
&scratch_dir.join(".claude/agents"),
"md",
)?;
project_skills(&app_dir.join("skills"), &scratch_dir.join(".claude/skills"))?;

let settings_dir = scratch_dir.join(".claude");
std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
std::fs::write(
settings_dir.join("settings.json"),
serde_json::to_vec_pretty(&serde_json::json!({ "enableAllProjectMcpServers": true }))
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;

if let Some(tools) = tools {
write_mcp_json(scratch_dir, tools)?;
}
Ok(())
}

fn project_dir(src: &Path, dst: &Path, ext: &str) -> Result<(), String> {
if !src.is_dir() {
return Ok(());
}
std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(src).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().extension().is_some_and(|e| e == ext) {
let target = dst.join(entry.file_name());
std::fs::copy(entry.path(), target).map_err(|e| e.to_string())?;
}
}
Ok(())
}

fn project_skills(src: &Path, dst: &Path) -> Result<(), String> {
if !src.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(src).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
let stem = path.file_stem().unwrap().to_string_lossy().into_owned();
let skill_dir = dst.join(&stem);
std::fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
std::fs::copy(&path, skill_dir.join("SKILL.md")).map_err(|e| e.to_string())?;
}
}
Ok(())
}

fn write_mcp_json(scratch_dir: &Path, tools: &ToolsManifest) -> Result<(), String> {
let mut servers = serde_json::Map::new();
for (name, cfg) in &tools.servers {
let entry = match cfg {
ServerConfig::McpStdio { command, args, .. } => serde_json::json!({
"command": command,
"args": args,
}),
ServerConfig::McpHttp { url, .. } => serde_json::json!({ "url": url }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
servers.insert(name.clone(), entry);
}
let doc = serde_json::json!({ "mcpServers": serde_json::Value::Object(servers) });
std::fs::write(
scratch_dir.join(".mcp.json"),
serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn projects_personas_and_skills_into_claude_layout() {
let app_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(app_dir.path().join("personas")).unwrap();
std::fs::write(
app_dir.path().join("personas/ceo.md"),
"# CEO\nYou lead the company.",
)
.unwrap();
std::fs::create_dir_all(app_dir.path().join("skills")).unwrap();
std::fs::write(app_dir.path().join("skills/pricing.md"), "# Pricing skill").unwrap();

let scratch = tempfile::tempdir().unwrap();
project_for_claude_code(app_dir.path(), scratch.path(), None).unwrap();

let persona =
std::fs::read_to_string(scratch.path().join(".claude/agents/ceo.md")).unwrap();
assert_eq!(persona, "# CEO\nYou lead the company.");

let skill = std::fs::read_to_string(scratch.path().join(".claude/skills/pricing/SKILL.md"))
.unwrap();
assert_eq!(skill, "# Pricing skill");

let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(scratch.path().join(".claude/settings.json")).unwrap(),
)
.unwrap();
assert_eq!(
settings["enableAllProjectMcpServers"],
serde_json::json!(true)
);
}

#[test]
fn writes_mcp_json_when_tools_manifest_present() {
let app_dir = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let mut servers = std::collections::HashMap::new();
servers.insert(
"pricing-api".to_string(),
gateway_registry::ServerConfig::McpStdio {
command: "pricing-mcp".to_string(),
args: vec![],
auth_ref: None,
auth_env: None,
},
);
let tools = ToolsManifest { servers };

project_for_claude_code(app_dir.path(), scratch.path(), Some(&tools)).unwrap();

let mcp_json: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(scratch.path().join(".mcp.json")).unwrap(),
)
.unwrap();
assert_eq!(
mcp_json["mcpServers"]["pricing-api"]["command"],
"pricing-mcp"
);
}
}
Loading