@@ -42,6 +42,16 @@ struct InstalledPackageJson {
4242 name : String ,
4343 #[ serde( default ) ]
4444 bin : serde_json:: Value ,
45+ #[ serde( default ) ]
46+ main : Option < String > ,
47+ #[ serde( default ) ]
48+ exports : serde_json:: Value ,
49+ }
50+
51+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
52+ enum PackageSmokeTarget {
53+ Import ( PathBuf ) ,
54+ SyntaxCheck ( PathBuf ) ,
4555}
4656
4757#[ derive( Debug , Serialize ) ]
@@ -523,7 +533,7 @@ async fn prepare_local_tool_source_to_root(
523533 validate_bridge_entrypoint ( & project_dir, & manifest) ?;
524534 validate_platform_binary ( tool, & project_dir, spec) ?;
525535 validate_dependency_tree ( node_runtime, & project_dir, & npm_cache_dir, tool) . await ?;
526- validate_package_import_smoke ( node_runtime, & project_dir, tool) . await ?;
536+ validate_package_smoke ( node_runtime, & project_dir, tool) . await ?;
527537
528538 let manifest_path = project_dir. join ( "manifest.json" ) ;
529539 fs:: write (
@@ -536,7 +546,7 @@ async fn prepare_local_tool_source_to_root(
536546 managed_resources:: materialize_directory ( & project_dir, target_root) . map_err ( ManagedAcpToolError :: io) ?;
537547 let resolved = validate_tool_root ( tool, target_root, None ) ?;
538548 validate_dependency_tree ( node_runtime, target_root, & npm_cache_dir, tool) . await ?;
539- validate_package_import_smoke ( node_runtime, target_root, tool) . await ?;
549+ validate_package_smoke ( node_runtime, target_root, tool) . await ?;
540550 info ! (
541551 tool = tool. slug( ) ,
542552 version = tool. version( ) ,
@@ -689,23 +699,39 @@ async fn validate_dependency_tree(
689699 . await
690700}
691701
692- async fn validate_package_import_smoke (
702+ async fn validate_package_smoke (
693703 node_runtime : & crate :: ResolvedNodeRuntime ,
694704 project_dir : & Path ,
695705 tool : ManagedAcpToolId ,
696706) -> Result < ( ) , ManagedAcpToolError > {
707+ let package_json_path = package_json_path ( project_dir, tool. package_name ( ) ) ;
708+ let contents = fs:: read_to_string ( & package_json_path) . map_err ( ManagedAcpToolError :: io) ?;
709+ let package_json: InstalledPackageJson = serde_json:: from_str ( & contents) . map_err ( |error| {
710+ ManagedAcpToolError :: invalid ( format ! (
711+ "parse installed package manifest failed for {}: {error}" ,
712+ package_json_path. display( )
713+ ) )
714+ } ) ?;
715+ let smoke_target = resolve_package_smoke_target ( project_dir, & package_json) ?;
697716 let mut builder = Builder :: clean_cli ( node_runtime. node_path . clone ( ) ) ;
698- builder. current_dir ( project_dir) . args ( [
699- "--input-type=module" ,
700- "-e" ,
701- "await import(process.argv[1]);" ,
702- tool. package_name ( ) ,
703- ] ) ;
717+ builder. current_dir ( project_dir) ;
718+ match & smoke_target {
719+ PackageSmokeTarget :: Import ( path) => {
720+ builder
721+ . arg ( "--input-type=module" )
722+ . arg ( "-e" )
723+ . arg ( "import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.argv[1]).href);" )
724+ . arg ( path) ;
725+ }
726+ PackageSmokeTarget :: SyntaxCheck ( path) => {
727+ builder. arg ( "--check" ) . arg ( path) ;
728+ }
729+ }
704730 let output = tokio:: time:: timeout ( MANAGED_ACP_SMOKE_TIMEOUT , builder. output ( ) )
705731 . await
706732 . map_err ( |_| {
707733 ManagedAcpToolError :: invalid ( format ! (
708- "smoke test for managed {} package import timed out after {}s" ,
734+ "smoke test for managed {} package timed out after {}s" ,
709735 tool. display_name( ) ,
710736 MANAGED_ACP_SMOKE_TIMEOUT . as_secs( )
711737 ) )
@@ -725,18 +751,22 @@ async fn validate_package_import_smoke(
725751 format ! ( "{stderr}; stdout: {stdout}" )
726752 } ;
727753 Err ( ManagedAcpToolError :: invalid ( format ! (
728- "smoke test for managed {} package import failed with exit code {:?}: {detail}" ,
754+ "smoke test for managed {} package failed with exit code {:?}: {detail}" ,
729755 tool. display_name( ) ,
730756 output. status. code( )
731757 ) ) )
732758}
733759
734760fn package_json_path ( project_dir : & Path , package_name : & str ) -> PathBuf {
761+ package_root ( project_dir, package_name) . join ( "package.json" )
762+ }
763+
764+ fn package_root ( project_dir : & Path , package_name : & str ) -> PathBuf {
735765 let mut path = project_dir. join ( "node_modules" ) ;
736766 for segment in package_path_segments ( package_name) {
737767 path. push ( segment) ;
738768 }
739- path. join ( "package.json" )
769+ path
740770}
741771
742772fn package_path_segments ( package_name : & str ) -> Vec < & str > {
@@ -774,6 +804,47 @@ fn resolve_package_bin_entry(package_name: &str, bin_field: &serde_json::Value)
774804 }
775805}
776806
807+ fn resolve_package_smoke_target (
808+ project_dir : & Path ,
809+ package_json : & InstalledPackageJson ,
810+ ) -> Result < PackageSmokeTarget , ManagedAcpToolError > {
811+ if let Some ( entry) = resolve_package_import_entry ( & package_json. exports , package_json. main . as_deref ( ) ) {
812+ return Ok ( PackageSmokeTarget :: Import (
813+ package_root ( project_dir, & package_json. name ) . join ( entry) ,
814+ ) ) ;
815+ }
816+
817+ let bin_entry = resolve_package_bin_entry ( package_json. name . as_str ( ) , & package_json. bin ) ?;
818+ Ok ( PackageSmokeTarget :: SyntaxCheck (
819+ package_root ( project_dir, & package_json. name ) . join ( bin_entry) ,
820+ ) )
821+ }
822+
823+ fn resolve_package_import_entry ( exports_field : & serde_json:: Value , main_field : Option < & str > ) -> Option < String > {
824+ let exports_entry = match exports_field {
825+ serde_json:: Value :: String ( value) if !value. is_empty ( ) => Some ( value. clone ( ) ) ,
826+ serde_json:: Value :: Object ( entries) => entries. get ( "." ) . and_then ( |root| match root {
827+ serde_json:: Value :: String ( value) if !value. is_empty ( ) => Some ( value. clone ( ) ) ,
828+ serde_json:: Value :: Object ( root_entries) => root_entries
829+ . get ( "import" )
830+ . and_then ( |value| match value {
831+ serde_json:: Value :: String ( value) if !value. is_empty ( ) => Some ( value. clone ( ) ) ,
832+ _ => None ,
833+ } )
834+ . or_else ( || {
835+ root_entries. get ( "default" ) . and_then ( |value| match value {
836+ serde_json:: Value :: String ( value) if !value. is_empty ( ) => Some ( value. clone ( ) ) ,
837+ _ => None ,
838+ } )
839+ } ) ,
840+ _ => None ,
841+ } ) ,
842+ _ => None ,
843+ } ;
844+
845+ exports_entry. or_else ( || main_field. and_then ( |value| if value. is_empty ( ) { None } else { Some ( value. to_owned ( ) ) } ) )
846+ }
847+
777848fn normalize_slashes ( path : & Path ) -> String {
778849 path. to_string_lossy ( ) . replace ( '\\' , "/" )
779850}
@@ -893,6 +964,7 @@ fn format_error_with_causes(error: &(dyn StdError + 'static)) -> String {
893964#[ cfg( test) ]
894965mod tests {
895966 use super :: * ;
967+ use serde_json:: json;
896968 use std:: fmt;
897969
898970 #[ test]
@@ -1033,6 +1105,67 @@ mod tests {
10331105 assert_eq ! ( entry, "dist/index.js" ) ;
10341106 }
10351107
1108+ #[ test]
1109+ fn resolve_package_smoke_target_prefers_importable_entry_for_exported_package ( ) {
1110+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1111+ let project_dir = tmp. path ( ) ;
1112+ let package_json = InstalledPackageJson {
1113+ name : "@agentclientprotocol/claude-agent-acp" . into ( ) ,
1114+ bin : json ! ( {
1115+ "claude-agent-acp" : "dist/index.js" ,
1116+ } ) ,
1117+ main : Some ( "dist/lib.js" . into ( ) ) ,
1118+ exports : json ! ( {
1119+ "." : {
1120+ "types" : "./dist/lib.d.ts" ,
1121+ "import" : "./dist/lib.js"
1122+ }
1123+ } ) ,
1124+ } ;
1125+
1126+ let target = resolve_package_smoke_target ( project_dir, & package_json) . expect ( "smoke target" ) ;
1127+
1128+ assert_eq ! (
1129+ target,
1130+ PackageSmokeTarget :: Import (
1131+ project_dir
1132+ . join( "node_modules" )
1133+ . join( "@agentclientprotocol" )
1134+ . join( "claude-agent-acp" )
1135+ . join( "dist" )
1136+ . join( "lib.js" )
1137+ )
1138+ ) ;
1139+ }
1140+
1141+ #[ test]
1142+ fn resolve_package_smoke_target_falls_back_to_bin_check_for_cli_only_package ( ) {
1143+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1144+ let project_dir = tmp. path ( ) ;
1145+ let package_json = InstalledPackageJson {
1146+ name : "@zed-industries/codex-acp" . into ( ) ,
1147+ bin : json ! ( {
1148+ "codex-acp" : "bin/codex-acp.js" ,
1149+ } ) ,
1150+ main : None ,
1151+ exports : serde_json:: Value :: Null ,
1152+ } ;
1153+
1154+ let target = resolve_package_smoke_target ( project_dir, & package_json) . expect ( "smoke target" ) ;
1155+
1156+ assert_eq ! (
1157+ target,
1158+ PackageSmokeTarget :: SyntaxCheck (
1159+ project_dir
1160+ . join( "node_modules" )
1161+ . join( "@zed-industries" )
1162+ . join( "codex-acp" )
1163+ . join( "bin" )
1164+ . join( "codex-acp.js" )
1165+ )
1166+ ) ;
1167+ }
1168+
10361169 #[ test]
10371170 fn package_path_segments_preserve_scoped_package_structure ( ) {
10381171 assert_eq ! (
0 commit comments