22
33import json
44import sys
5- from typing import Annotated , List , Optional
5+ from typing import Annotated , Any , List , Optional
66
77import typer
8+ import yaml
89from loguru import logger
910from rich import print as rprint
1011
2829 recent_activity_prompt as recent_activity_prompt ,
2930)
3031from basic_memory .mcp .tools import build_context as mcp_build_context
32+ from basic_memory .mcp .tools import edit_note as mcp_edit_note
3133from basic_memory .mcp .tools import read_note as mcp_read_note
3234from basic_memory .mcp .tools import recent_activity as mcp_recent_activity
3335from basic_memory .mcp .tools import search_notes as mcp_search
3638tool_app = typer .Typer ()
3739app .add_typer (tool_app , name = "tool" , help = "Access to MCP tools via CLI" )
3840
41+ VALID_EDIT_OPERATIONS = ["append" , "prepend" , "find_replace" , "replace_section" ]
42+
43+
44+ # --- Frontmatter helpers ---
45+
46+
47+ def _parse_opening_frontmatter (content : str ) -> tuple [str , dict [str , Any ] | None ]:
48+ """Parse and strip an opening YAML frontmatter block if valid.
49+
50+ Returns a tuple of (body_content_or_original, parsed_frontmatter_or_none).
51+
52+ Behavior:
53+ - Only parses frontmatter if the first line is an opening '---' delimiter.
54+ - Requires a closing '---' delimiter.
55+ - Accepts mapping YAML only; malformed or non-mapping YAML is ignored.
56+ - Supports UTF-8 BOM at document start.
57+ """
58+ if not content :
59+ return content , None
60+
61+ original_content = content
62+ if content .startswith ("\ufeff " ):
63+ content = content [1 :]
64+
65+ lines = content .splitlines (keepends = True )
66+ if not lines :
67+ return original_content , None
68+
69+ if lines [0 ].rstrip ("\r \n " ).strip () != "---" :
70+ return original_content , None
71+
72+ closing_index = None
73+ for index in range (1 , len (lines )):
74+ if lines [index ].rstrip ("\r \n " ).strip () == "---" :
75+ closing_index = index
76+ break
77+
78+ if closing_index is None :
79+ return original_content , None
80+
81+ frontmatter_text = "" .join (lines [1 :closing_index ])
82+ try :
83+ parsed = yaml .safe_load (frontmatter_text ) if frontmatter_text else {}
84+ except yaml .YAMLError :
85+ return original_content , None
86+
87+ if parsed is None :
88+ parsed = {}
89+ if not isinstance (parsed , dict ):
90+ return original_content , None
91+
92+ body_content = "" .join (lines [closing_index + 1 :])
93+ return body_content , parsed
94+
3995
4096# --- JSON output helpers ---
4197# These async functions bypass the MCP tool (which returns formatted strings)
@@ -111,6 +167,61 @@ async def _read_note_json(
111167 }
112168
113169
170+ async def _edit_note_json (
171+ identifier : str ,
172+ operation : str ,
173+ content : str ,
174+ project_name : Optional [str ],
175+ section : Optional [str ],
176+ find_text : Optional [str ],
177+ expected_replacements : int ,
178+ ) -> dict :
179+ """Edit a note and return structured JSON metadata."""
180+ async with get_client (project_name = project_name ) as client :
181+ active_project = await get_active_project (client , project_name )
182+ knowledge_client = KnowledgeClient (client , active_project .external_id )
183+
184+ entity_id = await knowledge_client .resolve_entity (identifier )
185+
186+ edit_data : dict [str , Any ] = {
187+ "operation" : operation ,
188+ "content" : content ,
189+ "expected_replacements" : expected_replacements ,
190+ }
191+ if section :
192+ edit_data ["section" ] = section
193+ if find_text :
194+ edit_data ["find_text" ] = find_text
195+
196+ result = await knowledge_client .patch_entity (entity_id , edit_data , fast = False )
197+ return {
198+ "title" : result .title ,
199+ "permalink" : result .permalink ,
200+ "file_path" : result .file_path ,
201+ "operation" : operation ,
202+ "checksum" : result .checksum ,
203+ }
204+
205+
206+ def _validate_edit_note_args (
207+ operation : str , find_text : Optional [str ], section : Optional [str ]
208+ ) -> None :
209+ """Validate operation-specific required arguments for edit-note."""
210+ if operation not in VALID_EDIT_OPERATIONS :
211+ raise ValueError (
212+ f"Invalid operation '{ operation } '. Must be one of: { ', ' .join (VALID_EDIT_OPERATIONS )} "
213+ )
214+ if operation == "find_replace" and not find_text :
215+ raise ValueError ("find_text parameter is required for find_replace operation" )
216+ if operation == "replace_section" and not section :
217+ raise ValueError ("section parameter is required for replace_section operation" )
218+
219+
220+ def _is_edit_note_failure_response (result : str ) -> bool :
221+ """Check whether the MCP edit_note text response indicates a failed edit."""
222+ return result .lstrip ().startswith ("# Edit Failed" )
223+
224+
114225async def _recent_activity_json (
115226 type : Optional [List [SearchItemType ]],
116227 depth : Optional [int ],
@@ -281,6 +392,14 @@ def read_note(
281392 page : int = 1 ,
282393 page_size : int = 10 ,
283394 format : str = typer .Option ("text" , "--format" , help = "Output format: text or json" ),
395+ strip_frontmatter : bool = typer .Option (
396+ False ,
397+ "--strip-frontmatter" ,
398+ help = (
399+ "Strip opening YAML frontmatter from content. "
400+ "JSON output includes parsed frontmatter under 'frontmatter'."
401+ ),
402+ ),
284403 local : bool = typer .Option (
285404 False , "--local" , help = "Force local API routing (ignore cloud mode)"
286405 ),
@@ -290,6 +409,7 @@ def read_note(
290409
291410 Use --local to force local routing when cloud mode is enabled.
292411 Use --cloud to force cloud routing when cloud mode is disabled.
412+ Use --strip-frontmatter to return body-only markdown content.
293413 """
294414 try :
295415 validate_routing_flags (local , cloud )
@@ -311,9 +431,15 @@ def read_note(
311431 result = run_with_cleanup (
312432 _read_note_json (identifier , project_name , page , page_size )
313433 )
434+ stripped_content , parsed_frontmatter = _parse_opening_frontmatter (result ["content" ])
435+ result ["frontmatter" ] = parsed_frontmatter
436+ if strip_frontmatter :
437+ result ["content" ] = stripped_content
314438 print (json .dumps (result , indent = 2 , ensure_ascii = True , default = str ))
315439 else :
316440 note = run_with_cleanup (mcp_read_note .fn (identifier , project_name , page , page_size ))
441+ if strip_frontmatter :
442+ note , _ = _parse_opening_frontmatter (note )
317443 rprint (note )
318444 except ValueError as e :
319445 typer .echo (f"Error: { e } " , err = True )
@@ -325,6 +451,95 @@ def read_note(
325451 raise
326452
327453
454+ @tool_app .command ()
455+ def edit_note (
456+ identifier : str ,
457+ operation : Annotated [str , typer .Option ("--operation" , help = "Edit operation to apply" )],
458+ content : Annotated [str , typer .Option ("--content" , help = "Content for the edit operation" )],
459+ project : Annotated [
460+ Optional [str ],
461+ typer .Option (
462+ help = "The project to edit. If not provided, the default project will be used."
463+ ),
464+ ] = None ,
465+ find_text : Annotated [
466+ Optional [str ], typer .Option ("--find-text" , help = "Text to find for find_replace operation" )
467+ ] = None ,
468+ section : Annotated [
469+ Optional [str ],
470+ typer .Option ("--section" , help = "Section heading for replace_section operation" ),
471+ ] = None ,
472+ expected_replacements : int = typer .Option (
473+ 1 ,
474+ "--expected-replacements" ,
475+ help = "Expected replacement count for find_replace operation" ,
476+ ),
477+ format : str = typer .Option ("text" , "--format" , help = "Output format: text or json" ),
478+ local : bool = typer .Option (
479+ False , "--local" , help = "Force local API routing (ignore cloud mode)"
480+ ),
481+ cloud : bool = typer .Option (False , "--cloud" , help = "Force cloud API routing" ),
482+ ):
483+ """Edit an existing markdown note using append/prepend/find_replace/replace_section.
484+
485+ Use --local to force local routing when cloud mode is enabled.
486+ Use --cloud to force cloud routing when cloud mode is disabled.
487+ """
488+ try :
489+ validate_routing_flags (local , cloud )
490+ _validate_edit_note_args (operation , find_text , section )
491+
492+ # look for the project in the config
493+ config_manager = ConfigManager ()
494+ project_name = None
495+ if project is not None :
496+ project_name , _ = config_manager .get_project (project )
497+ if not project_name :
498+ typer .echo (f"No project found named: { project } " , err = True )
499+ raise typer .Exit (1 )
500+
501+ # use the project name, or the default from the config
502+ project_name = project_name or config_manager .default_project
503+
504+ with force_routing (local = local , cloud = cloud ):
505+ if format == "json" :
506+ result = run_with_cleanup (
507+ _edit_note_json (
508+ identifier = identifier ,
509+ operation = operation ,
510+ content = content ,
511+ project_name = project_name ,
512+ section = section ,
513+ find_text = find_text ,
514+ expected_replacements = expected_replacements ,
515+ )
516+ )
517+ print (json .dumps (result , indent = 2 , ensure_ascii = True , default = str ))
518+ else :
519+ result = run_with_cleanup (
520+ mcp_edit_note .fn (
521+ identifier = identifier ,
522+ operation = operation ,
523+ content = content ,
524+ project = project_name ,
525+ section = section ,
526+ find_text = find_text ,
527+ expected_replacements = expected_replacements ,
528+ )
529+ )
530+ rprint (result )
531+ if _is_edit_note_failure_response (result ):
532+ raise typer .Exit (1 )
533+ except ValueError as e :
534+ typer .echo (f"Error: { e } " , err = True )
535+ raise typer .Exit (1 )
536+ except Exception as e : # pragma: no cover
537+ if not isinstance (e , typer .Exit ):
538+ typer .echo (f"Error during edit_note: { e } " , err = True )
539+ raise typer .Exit (1 )
540+ raise
541+
542+
328543@tool_app .command ()
329544def build_context (
330545 url : MemoryUrl ,
0 commit comments