Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
119 changes: 119 additions & 0 deletions src/cli/apps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! `agentflare apps` — run a self-contained AgentFlare App directory
//! end-to-end through the embedded workflow engine, projecting the App's
//! agent-neutral personas/skills/tools into a fresh scratch dir per step
//! (mirrors `cli::workflow`'s pattern, using `app_send_hook` in place of
//! `agent_send_hook`).

use std::path::{Path, PathBuf};

use clap::{Args, Subcommand};

use crate::ui;

#[derive(Args)]
pub struct AppsArgs {
#[command(subcommand)]
pub command: AppsCommand,
}

#[derive(Subcommand)]
pub enum AppsCommand {
/// Run an AgentFlare App end-to-end from its self-contained directory.
Run(RunArgs),
}

#[derive(Args)]
pub struct RunArgs {
/// Path to the App's own directory (contains app.toml, workflow.json, ...).
pub dir: String,
/// Initial input for the first step.
#[arg(long, default_value = "")]
pub input: String,
/// SQLite store path (defaults to ~/.agentflare/workflows.db).
#[arg(long)]
pub db_path: Option<PathBuf>,
}

impl AppsArgs {
pub fn run(&self) {
match &self.command {
AppsCommand::Run(args) => {
let db_path = args
.db_path
.clone()
.unwrap_or_else(crate::workflow::default_db_path);
match run_app(Path::new(&args.dir), &args.input, &db_path) {
Ok(run_id) => println!("{run_id}"),
Err(e) => {
ui::error(&format!("apps run failed: {e}"));
std::process::exit(1);
}
}
}
}
}
}

/// Loads the App's manifest (and optional tools manifest) from `app_dir`,
/// then registers and starts its workflow with `app_send_hook` as the
/// dispatch sender so each step runs against a projected persona/skill/tool
/// scratch directory instead of the caller's own `.claude/` setup. Returns
/// the new run's id.
pub(crate) fn run_app(app_dir: &Path, input: &str, db_path: &Path) -> Result<String, String> {
let manifest = agentflare_apps::load_app_manifest(app_dir)?;
let tools = agentflare_apps::load_tools_manifest(app_dir)?;
let workflow_json = std::fs::read_to_string(&manifest.workflow)
.map_err(|e| format!("could not read {}: {e}", manifest.workflow.display()))?;
let send = crate::workflow::app_send_hook(app_dir.to_path_buf(), tools);
let (run_id, _workflow_id) =
crate::workflow::run_workflow_json_with_sender(&workflow_json, input, db_path, send)?;
Ok(run_id.to_string())
}

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

#[test]
fn run_end_to_end_against_a_fixture_app_prints_a_run_id() {
let app_dir = tempfile::tempdir().unwrap();
std::fs::write(
app_dir.path().join("app.toml"),
r#"name = "fixture-app"
version = "0.1.0"
workflow = "workflow.json""#,
)
.unwrap();
std::fs::write(
app_dir.path().join("workflow.json"),
r#"{
"name": "fixture-workflow",
"steps": [
{ "name": "step1", "agent": "fixture-agent", "prompt": "{{input}}" }
]
}"#,
)
.unwrap();
std::fs::create_dir_all(app_dir.path().join("personas")).unwrap();
std::fs::write(
app_dir.path().join("personas/fixture-agent.md"),
"# Fixture",
)
.unwrap();

let db_dir = tempfile::tempdir().unwrap();
let run_id = run_app(app_dir.path(), "hello", &db_dir.path().join("apps.db")).unwrap();
assert!(!run_id.is_empty());
}

#[test]
fn missing_app_toml_is_a_clear_error() {
let app_dir = tempfile::tempdir().unwrap();
let db_dir = tempfile::tempdir().unwrap();
let err = run_app(app_dir.path(), "hi", &db_dir.path().join("apps.db")).unwrap_err();
assert!(
err.contains("app.toml"),
"error should name the missing file: {err}"
);
}
}
4 changes: 4 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod agents;
mod alias;
mod apps;
mod artifacts;
mod auth;
mod channel;
Expand Down Expand Up @@ -142,6 +143,8 @@ pub enum Commands {
Docs(docs::DocsArgs),
/// Run and inspect durable agent pipelines through the workflow engine.
Workflow(workflow::WorkflowArgs),
/// Run and manage AgentFlare Apps — self-contained agentic domain modules.
Apps(apps::AppsArgs),
}

impl Commands {
Expand Down Expand Up @@ -181,6 +184,7 @@ impl Commands {
Self::Work(cmd) => cmd.run(),
Self::Docs(cmd) => docs::run(cmd),
Self::Workflow(cmd) => cmd.run(),
Self::Apps(cmd) => cmd.run(),
}
}
}
2 changes: 0 additions & 2 deletions src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ pub(crate) fn agent_send_hook() -> SendMessage {
/// agent's own native project-local discovery (`.claude/agents`,
/// `.claude/skills`, `.mcp.json`) picks it up. The scratch directory is
/// torn down after the step completes, win or lose.
#[allow(dead_code)]
pub(crate) fn app_send_hook(
app_dir: PathBuf,
tools: Option<agentflare_apps::ToolsManifest>,
Expand Down Expand Up @@ -334,7 +333,6 @@ pub(crate) async fn run_workflow_json_with_params_async(
/// Same as [`run_workflow_json`] with an injectable `SendMessage` hook — the
/// actual hook-in point for [`app_send_hook`] (via `agentflare apps run`) as
/// well as tests that drive steps without an installed agent binary.
#[allow(dead_code)]
pub(crate) fn run_workflow_json_with_sender(
definition_json: &str,
input: &str,
Expand Down
Loading