@@ -68,8 +68,8 @@ def ListFiles(self, path:str, recursive:bool) -> Dict[str, Any]:
6868 self ._AddFileListEntry (files , rootPath , entryPath , entryName )
6969
7070 return {
71- "Path " : self ._ToResponseRelativePath (normalizedPath ),
72- "Files " : files ,
71+ "path " : self ._ToResponseRelativePath (normalizedPath ),
72+ "files " : files ,
7373 }
7474
7575
@@ -106,7 +106,11 @@ def ReadTextFile(self, path:str, textEncoding:Optional[str], startLine:Optional[
106106 fileSize = os .path .getsize (targetPath )
107107 lines , fullLineCount , readStartLine = self ._ReadTextLines (targetPath , textEncoding , startLine , maxLines , tailLines )
108108 linesRead = len (lines )
109- readEndLine = readStartLine + linesRead - 1
109+ if linesRead == 0 :
110+ readStartLine = 0
111+ readEndLine = 0
112+ else :
113+ readEndLine = readStartLine + linesRead - 1
110114 isPartialRead = fullLineCount > 0 and (readStartLine > 1 or readEndLine < fullLineCount )
111115
112116 # Note! These properties are used by the MCP server and explicitly deserialized by the server, so they must stay in sync!
@@ -147,7 +151,7 @@ def WriteFile(self, path:str, text:Optional[str], base64Data:Optional[str], text
147151 }
148152
149153
150- def MoveFile (self , path :str , newPath :str , copy :bool ) -> Dict [str , Any ]:
154+ def MoveFile (self , path :str , newPath :str , copy :bool , override : bool , expectedSha256 : Optional [ str ] ) -> Dict [str , Any ]:
151155 rootPath = self ._GetRootPath (True )
152156 sourceOperationName = "copy" if copy else "move"
153157 _ , targetPath = self ._ResolveExistingFilePath (rootPath , path , sourceOperationName )
@@ -157,6 +161,11 @@ def MoveFile(self, path:str, newPath:str, copy:bool) -> Dict[str, Any]:
157161 if os .path .realpath (targetPath ) == os .path .realpath (newTargetPath ):
158162 raise ValueError ("'NewPath' must be different from 'Path'." )
159163
164+ expectedSha256 = self ._NormalizeExpectedSha256 (expectedSha256 )
165+ self ._ValidateExpectedSha256 (targetPath , expectedSha256 )
166+ if override is False and os .path .exists (newTargetPath ):
167+ raise FileExistsError ("Destination file already exists and 'Override' is false." )
168+
160169 fileSize = os .path .getsize (targetPath )
161170 if copy :
162171 shutil .copy2 (targetPath , newTargetPath )
@@ -186,7 +195,7 @@ def PatchFile(self, path:str, unifiedDiffPatch:str, expectedSha256:Optional[str]
186195 try :
187196 applied = patchSet .apply ()
188197 if applied is False :
189- raise RuntimeError ("Patch could not be applied ." )
198+ raise RuntimeError ("Patch context did not match the current file ." )
190199 if os .path .exists (targetPath ) is False :
191200 raise RuntimeError ("Patch removed file unexpectedly." )
192201 self ._ReadUtf8TextFileBytesForPatch (targetPath , "Patched file" )
@@ -203,15 +212,26 @@ def PatchFile(self, path:str, unifiedDiffPatch:str, expectedSha256:Optional[str]
203212 }
204213
205214
206- def DeleteFile (self , path :str ) -> Dict [str , Any ]:
215+ def DeleteFile (self , path :str , recursive : bool ) -> Dict [str , Any ]:
207216 rootPath = self ._GetRootPath (True )
208- _ , targetPath = self ._ResolveExistingFilePath (rootPath , path , "remove" )
209-
210- os .remove (targetPath )
217+ _ , targetPath = self ._ResolveExistingPath (rootPath , path , "remove" )
218+
219+ isDirectory = os .path .isdir (targetPath )
220+ if isDirectory :
221+ if recursive :
222+ self ._ValidateDirectoryTreeForDelete (rootPath , targetPath )
223+ shutil .rmtree (targetPath )
224+ else :
225+ if len (os .listdir (targetPath )) > 0 :
226+ raise OSError ("Directory is not empty. Set 'Recursive' to true to delete it." )
227+ os .rmdir (targetPath )
228+ else :
229+ os .remove (targetPath )
211230
212231 # Note! These properties are used by the MCP server and explicitly deserialized by the server, so they must stay in sync!
213232 return {
214233 "deleted" : True ,
234+ "is_directory" : isDirectory ,
215235 }
216236
217237
@@ -234,15 +254,20 @@ def _ResolvePath(self, rootPath:str, rawPath:str, allowRoot:bool) -> Tuple[str,
234254
235255
236256 def _ResolveExistingFilePath (self , rootPath :str , rawPath :str , operationName :str ) -> Tuple [str , str ]:
257+ normalizedPath , targetPath = self ._ResolveExistingPath (rootPath , rawPath , operationName )
258+ if os .path .isfile (targetPath ) is False :
259+ raise ValueError ("'Path' must reference a file." )
260+ return normalizedPath , targetPath
261+
262+
263+ def _ResolveExistingPath (self , rootPath :str , rawPath :str , operationName :str ) -> Tuple [str , str ]:
237264 normalizedPath , targetPath = self ._ResolvePath (rootPath , rawPath , False )
238265 if self ._IsDeniedFileName (normalizedPath ):
239266 raise PermissionError ("Access to this file is denied." )
240267 if os .path .exists (targetPath ) is False :
241268 raise FileNotFoundError ("File does not exist." )
242269 if os .path .islink (targetPath ):
243270 raise PermissionError (f"Refusing to { operationName } a symbolic link." )
244- if os .path .isfile (targetPath ) is False :
245- raise ValueError ("'Path' must reference a file." )
246271 return normalizedPath , targetPath
247272
248273
@@ -291,9 +316,11 @@ def _GetTextEncoding(self, textEncoding:Optional[str]) -> str:
291316
292317
293318 def _GetTextStartLine (self , startLine :Optional [int ]) -> int :
294- # StartLine is 1-based for callers, but accept 0 as the beginning like the byte offset API did .
295- if startLine is None or startLine == 0 :
319+ # StartLine is 1-based for callers.
320+ if startLine is None :
296321 return 1
322+ if startLine <= 0 :
323+ raise ValueError ("'StartLine' must be greater than zero." )
297324 return startLine
298325
299326
@@ -339,15 +366,17 @@ def _ReadTextLineRange(self, targetPath:str, textEncoding:str, startLine:int, ma
339366
340367
341368 def _GetWriteContentBytes (self , text :Optional [str ], base64Data :Optional [str ], textEncoding :Optional [str ]) -> bytes :
369+ if text is not None and base64Data is not None :
370+ raise ValueError ("Only one of 'Text' or 'Base64Data' can be provided." )
371+ if text is None and base64Data is None :
372+ raise ValueError ("One of 'Text' or 'Base64Data' must be provided." )
342373 if text is not None :
343374 if textEncoding is None :
344375 textEncoding = "utf-8"
345376 try :
346377 return text .encode (textEncoding )
347378 except LookupError as e :
348379 raise ValueError (f"Unknown text encoding '{ textEncoding } '." ) from e
349- if base64Data is None :
350- raise ValueError ("'Base64Data' must be provided when 'Format' is 'data'." )
351380 try :
352381 return base64 .b64decode (base64Data , validate = True )
353382 except Exception as e :
@@ -423,6 +452,23 @@ def _RestoreFileBytesAfterFailedPatch(self, targetPath:str, fileBytes:bytes) ->
423452 f .write (fileBytes )
424453
425454
455+ def _ValidateDirectoryTreeForDelete (self , rootPath :str , targetPath :str ) -> None :
456+ for currentRoot , dirNames , fileNames in os .walk (targetPath ):
457+ if self ._IsPathInsideRoot (rootPath , currentRoot ) is False :
458+ raise PermissionError ("Directory contains a path outside of the Home Assistant config directory." )
459+ for dirName in dirNames :
460+ entryPath = os .path .join (currentRoot , dirName )
461+ if os .path .islink (entryPath ):
462+ raise PermissionError ("Refusing to delete a directory containing a symbolic link." )
463+ for fileName in fileNames :
464+ entryPath = os .path .join (currentRoot , fileName )
465+ normalizedPath = os .path .relpath (entryPath , rootPath )
466+ if os .path .islink (entryPath ):
467+ raise PermissionError ("Refusing to delete a directory containing a symbolic link." )
468+ if self ._IsDeniedFileName (normalizedPath ):
469+ raise PermissionError ("Refusing to delete a directory containing a denied file." )
470+
471+
426472 def _HashFile (self , targetPath :str ) -> str :
427473 sha256 = hashlib .sha256 ()
428474 with open (targetPath , "rb" ) as f :
0 commit comments