11//! Custom agents API endpoints.
22
3+ use std:: io:: ErrorKind ;
4+ use std:: path:: Path as StdPath ;
35use std:: sync:: Arc ;
46
57use axum:: {
68 Json ,
79 extract:: { Path , State } ,
810} ;
11+ use tokio:: fs;
912
1013use crate :: error:: { AppError , AppResult } ;
1114use crate :: state:: AppState ;
@@ -16,12 +19,12 @@ use super::types::{
1619} ;
1720
1821/// Read agent file from disk.
19- fn read_agent_file ( path : & std :: path :: Path , scope : & str ) -> Option < AgentDefinition > {
22+ async fn read_agent_file ( path : & StdPath , scope : & str ) -> Option < AgentDefinition > {
2023 if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
2124 return None ;
2225 }
2326
24- let content = std :: fs:: read_to_string ( path) . ok ( ) ?;
27+ let content = fs:: read_to_string ( path) . await . ok ( ) ?;
2528 let name = path. file_stem ( ) ?. to_str ( ) ?. to_string ( ) ;
2629
2730 // Parse YAML frontmatter
@@ -70,34 +73,34 @@ fn read_agent_file(path: &std::path::Path, scope: &str) -> Option<AgentDefinitio
7073 } )
7174}
7275
76+ async fn list_agents_in_dir ( dir : & StdPath , scope : & str ) -> Vec < AgentDefinition > {
77+ let mut agents = Vec :: new ( ) ;
78+ let mut entries = match fs:: read_dir ( dir) . await {
79+ Ok ( entries) => entries,
80+ Err ( _) => return agents,
81+ } ;
82+
83+ while let Ok ( Some ( entry) ) = entries. next_entry ( ) . await {
84+ if let Some ( agent) = read_agent_file ( & entry. path ( ) , scope) . await {
85+ agents. push ( agent) ;
86+ }
87+ }
88+
89+ agents
90+ }
91+
7392/// List all agents.
7493pub async fn list_agents ( ) -> AppResult < Json < Vec < AgentDefinition > > > {
7594 let mut agents = Vec :: new ( ) ;
7695
7796 // Project agents (.factory/agents/)
78- let project_dir = std:: path:: Path :: new ( ".factory/agents" ) ;
79- if project_dir. exists ( )
80- && let Ok ( entries) = std:: fs:: read_dir ( project_dir)
81- {
82- for entry in entries. flatten ( ) {
83- if let Some ( agent) = read_agent_file ( & entry. path ( ) , "project" ) {
84- agents. push ( agent) ;
85- }
86- }
87- }
97+ let project_dir = StdPath :: new ( ".factory/agents" ) ;
98+ agents. extend ( list_agents_in_dir ( project_dir, "project" ) . await ) ;
8899
89100 // User agents (~/.factory/agents/)
90101 if let Some ( home) = dirs:: home_dir ( ) {
91102 let user_dir = home. join ( ".factory/agents" ) ;
92- if user_dir. exists ( )
93- && let Ok ( entries) = std:: fs:: read_dir ( & user_dir)
94- {
95- for entry in entries. flatten ( ) {
96- if let Some ( agent) = read_agent_file ( & entry. path ( ) , "user" ) {
97- agents. push ( agent) ;
98- }
99- }
100- }
103+ agents. extend ( list_agents_in_dir ( & user_dir, "user" ) . await ) ;
101104 }
102105
103106 Ok ( Json ( agents) )
@@ -106,15 +109,15 @@ pub async fn list_agents() -> AppResult<Json<Vec<AgentDefinition>>> {
106109/// Get a specific agent.
107110pub async fn get_agent ( Path ( name) : Path < String > ) -> AppResult < Json < AgentDefinition > > {
108111 // Check project first
109- let project_path = std :: path :: Path :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
110- if let Some ( agent) = read_agent_file ( & project_path, "project" ) {
112+ let project_path = StdPath :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
113+ if let Some ( agent) = read_agent_file ( & project_path, "project" ) . await {
111114 return Ok ( Json ( agent) ) ;
112115 }
113116
114117 // Check user
115118 if let Some ( home) = dirs:: home_dir ( ) {
116119 let user_path = home. join ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
117- if let Some ( agent) = read_agent_file ( & user_path, "user" ) {
120+ if let Some ( agent) = read_agent_file ( & user_path, "user" ) . await {
118121 return Ok ( Json ( agent) ) ;
119122 }
120123 }
@@ -132,7 +135,8 @@ pub async fn create_agent(Json(req): Json<CreateAgentRequest>) -> AppResult<Json
132135 . join ( ".factory/agents" )
133136 } ;
134137
135- std:: fs:: create_dir_all ( & dir)
138+ fs:: create_dir_all ( & dir)
139+ . await
136140 . map_err ( |e| AppError :: Internal ( format ! ( "Failed to create directory: {}" , e) ) ) ?;
137141
138142 let path = dir. join ( format ! ( "{}.md" , req. name) ) ;
@@ -153,7 +157,8 @@ pub async fn create_agent(Json(req): Json<CreateAgentRequest>) -> AppResult<Json
153157 req. prompt
154158 ) ;
155159
156- std:: fs:: write ( & path, & content)
160+ fs:: write ( & path, & content)
161+ . await
157162 . map_err ( |e| AppError :: Internal ( format ! ( "Failed to write agent file: {}" , e) ) ) ?;
158163
159164 Ok ( Json ( AgentDefinition {
@@ -170,20 +175,24 @@ pub async fn create_agent(Json(req): Json<CreateAgentRequest>) -> AppResult<Json
170175/// Delete an agent.
171176pub async fn delete_agent ( Path ( name) : Path < String > ) -> AppResult < Json < serde_json:: Value > > {
172177 // Try project first
173- let project_path = std:: path:: Path :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
174- if project_path. exists ( ) {
175- std:: fs:: remove_file ( & project_path)
176- . map_err ( |e| AppError :: Internal ( format ! ( "Failed to delete: {}" , e) ) ) ?;
177- return Ok ( Json ( serde_json:: json!( { "deleted" : true } ) ) ) ;
178+ let project_path = StdPath :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
179+ match fs:: remove_file ( & project_path) . await {
180+ Ok ( ( ) ) => return Ok ( Json ( serde_json:: json!( { "deleted" : true } ) ) ) ,
181+ Err ( err) if err. kind ( ) != ErrorKind :: NotFound => {
182+ return Err ( AppError :: Internal ( format ! ( "Failed to delete: {}" , err) ) ) ;
183+ }
184+ Err ( _) => { }
178185 }
179186
180187 // Try user
181188 if let Some ( home) = dirs:: home_dir ( ) {
182189 let user_path = home. join ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
183- if user_path. exists ( ) {
184- std:: fs:: remove_file ( & user_path)
185- . map_err ( |e| AppError :: Internal ( format ! ( "Failed to delete: {}" , e) ) ) ?;
186- return Ok ( Json ( serde_json:: json!( { "deleted" : true } ) ) ) ;
190+ match fs:: remove_file ( & user_path) . await {
191+ Ok ( ( ) ) => return Ok ( Json ( serde_json:: json!( { "deleted" : true } ) ) ) ,
192+ Err ( err) if err. kind ( ) != ErrorKind :: NotFound => {
193+ return Err ( AppError :: Internal ( format ! ( "Failed to delete: {}" , err) ) ) ;
194+ }
195+ Err ( _) => { }
187196 }
188197 }
189198
@@ -229,14 +238,14 @@ pub async fn update_agent(
229238 Json ( req) : Json < UpdateAgentRequest > ,
230239) -> AppResult < Json < AgentDefinition > > {
231240 // Find existing agent
232- let project_path = std :: path :: Path :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
241+ let project_path = StdPath :: new ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ;
233242 let user_path =
234243 dirs:: home_dir ( ) . map ( |h| h. join ( ".factory/agents" ) . join ( format ! ( "{}.md" , name) ) ) ;
235244
236- let ( existing, path) = if let Some ( agent) = read_agent_file ( & project_path, "project" ) {
245+ let ( existing, path) = if let Some ( agent) = read_agent_file ( & project_path, "project" ) . await {
237246 ( agent, project_path)
238247 } else if let Some ( ref user_path) = user_path {
239- if let Some ( agent) = read_agent_file ( user_path, "user" ) {
248+ if let Some ( agent) = read_agent_file ( user_path, "user" ) . await {
240249 ( agent, user_path. clone ( ) )
241250 } else {
242251 return Err ( AppError :: NotFound ( format ! ( "Agent not found: {}" , name) ) ) ;
@@ -273,7 +282,8 @@ pub async fn update_agent(
273282 updated. prompt
274283 ) ;
275284
276- std:: fs:: write ( & path, & content)
285+ fs:: write ( & path, & content)
286+ . await
277287 . map_err ( |e| AppError :: Internal ( format ! ( "Failed to update agent: {}" , e) ) ) ?;
278288
279289 Ok ( Json ( updated) )
@@ -360,13 +370,15 @@ pub async fn import_agent(Json(req): Json<ImportAgentRequest>) -> AppResult<Json
360370 . join ( ".factory/agents" )
361371 } ;
362372
363- std:: fs:: create_dir_all ( & dir)
373+ fs:: create_dir_all ( & dir)
374+ . await
364375 . map_err ( |e| AppError :: Internal ( format ! ( "Failed to create directory: {}" , e) ) ) ?;
365376
366377 let path = dir. join ( format ! ( "{}.md" , name) ) ;
367378
368379 // Write the file
369- std:: fs:: write ( & path, & req. content )
380+ fs:: write ( & path, & req. content )
381+ . await
370382 . map_err ( |e| AppError :: Internal ( format ! ( "Failed to write agent file: {}" , e) ) ) ?;
371383
372384 Ok ( Json ( AgentDefinition {
@@ -409,3 +421,62 @@ pub async fn generate_agent_prompt(
409421 permission_mode : "default" . to_string ( ) ,
410422 } ) )
411423}
424+
425+ #[ cfg( test) ]
426+ mod tests {
427+ use super :: * ;
428+ use std:: path:: PathBuf ;
429+ use std:: time:: { SystemTime , UNIX_EPOCH } ;
430+
431+ fn temp_test_dir ( name : & str ) -> PathBuf {
432+ let unique = SystemTime :: now ( )
433+ . duration_since ( UNIX_EPOCH )
434+ . unwrap ( )
435+ . as_nanos ( ) ;
436+ std:: env:: temp_dir ( ) . join ( format ! ( "cortex-app-server-{name}-{unique}" ) )
437+ }
438+
439+ #[ tokio:: test]
440+ async fn read_agent_file_parses_frontmatter_async ( ) {
441+ let dir = temp_test_dir ( "frontmatter" ) ;
442+ fs:: create_dir_all ( & dir) . await . unwrap ( ) ;
443+ let path = dir. join ( "reviewer.md" ) ;
444+ let content = r#"---
445+ description: Reviews code
446+ tools: ["Read", "Grep"]
447+ model: claude
448+ permissionMode: default
449+ ---
450+
451+ Review this patch.
452+ "# ;
453+
454+ fs:: write ( & path, content) . await . unwrap ( ) ;
455+
456+ let agent = read_agent_file ( & path, "project" ) . await . unwrap ( ) ;
457+ assert_eq ! ( agent. name, "reviewer" ) ;
458+ assert_eq ! ( agent. description, "Reviews code" ) ;
459+ assert_eq ! ( agent. tools, vec![ "Read" , "Grep" ] ) ;
460+ assert_eq ! ( agent. model, "claude" ) ;
461+ assert_eq ! ( agent. prompt, "Review this patch." ) ;
462+ assert_eq ! ( agent. scope, "project" ) ;
463+
464+ let _ = fs:: remove_dir_all ( & dir) . await ;
465+ }
466+
467+ #[ tokio:: test]
468+ async fn list_agents_in_dir_ignores_non_markdown_files ( ) {
469+ let dir = temp_test_dir ( "list" ) ;
470+ fs:: create_dir_all ( & dir) . await . unwrap ( ) ;
471+ fs:: write ( dir. join ( "first.md" ) , "Prompt one" ) . await . unwrap ( ) ;
472+ fs:: write ( dir. join ( "notes.txt" ) , "ignore me" ) . await . unwrap ( ) ;
473+
474+ let agents = list_agents_in_dir ( & dir, "user" ) . await ;
475+
476+ assert_eq ! ( agents. len( ) , 1 ) ;
477+ assert_eq ! ( agents[ 0 ] . name, "first" ) ;
478+ assert_eq ! ( agents[ 0 ] . scope, "user" ) ;
479+
480+ let _ = fs:: remove_dir_all ( & dir) . await ;
481+ }
482+ }
0 commit comments