Coverage for src/prosemark/cli/main.py: 100%
471 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
1"""Main CLI entry point for prosemark.
3This module provides the main command-line interface for the prosemark
4writing project manager. It uses Typer for type-safe CLI generation
5and delegates all business logic to use case interactors.
6"""
8# Standard library imports
9from collections.abc import Callable
10from pathlib import Path
11from typing import Annotated, Any, Protocol
13# Third-party imports
14import typer
16# Adapter imports
17from prosemark.adapters.binder_repo_fs import BinderRepoFs
18from prosemark.adapters.clock_system import ClockSystem
19from prosemark.adapters.console_pretty import ConsolePretty
20from prosemark.adapters.editor_launcher_system import EditorLauncherSystem
21from prosemark.adapters.id_generator_uuid7 import IdGeneratorUuid7
22from prosemark.adapters.logger_stdout import LoggerStdout
23from prosemark.adapters.node_repo_fs import NodeRepoFs
25# Use case imports
26from prosemark.app.materialize_all_placeholders import MaterializeAllPlaceholders
27from prosemark.app.materialize_node import MaterializeNode
28from prosemark.app.materialize_node import MaterializeNode as MaterializeNodeUseCase
29from prosemark.app.use_cases import (
30 AddNode,
31 AuditBinder,
32 EditPart,
33 InitProject,
34 MoveNode,
35 RemoveNode,
36 ShowStructure,
37)
38from prosemark.domain.batch_materialize_result import BatchMaterializeResult
40# Domain model imports
41from prosemark.domain.binder import Item
42from prosemark.domain.models import BinderItem, NodeId
44# Exception imports
45from prosemark.exceptions import (
46 BinderFormatError,
47 BinderIntegrityError,
48 BinderNotFoundError,
49 EditorLaunchError,
50 FileSystemError,
51 NodeIdentityError,
52 NodeNotFoundError,
53 PlaceholderNotFoundError,
54)
56# Freewriting imports
57from prosemark.freewriting.container import run_freewriting_session
59# Port imports
60from prosemark.ports.config_port import ConfigPort, ProsemarkConfig
63# Protocol definitions
64class MaterializationResult(Protocol):
65 """Protocol for materialization process result objects.
67 Defines the expected interface for results of materialization operations,
68 capturing details about the process such as placeholders materialized,
69 failures encountered, and execution metadata.
70 """
72 total_placeholders: int
73 successful_materializations: list[Any]
74 failed_materializations: list[Any]
75 has_failures: bool
76 type: str | None
77 is_complete_success: bool
78 execution_time: float
79 message: str | None
80 summary_message: Callable[[], str]
83app = typer.Typer(
84 name='pmk',
85 help='Prosemark CLI - A hierarchical writing project manager',
86 add_completion=False,
87)
89# Alias for backward compatibility with tests
90cli = app
93class FileSystemConfigPort(ConfigPort):
94 """Temporary config port implementation."""
96 def create_default_config(self, config_path: Path) -> None:
97 """Create default configuration file."""
98 # For MVP, we don't need a config file
100 @staticmethod
101 def config_exists(config_path: Path) -> bool:
102 """Check if configuration file already exists."""
103 return config_path.exists()
105 @staticmethod
106 def get_default_config_values() -> ProsemarkConfig:
107 """Return default configuration values as dictionary."""
108 return {}
110 @staticmethod
111 def load_config(_config_path: Path) -> dict[str, Any]:
112 """Load configuration from file."""
113 return {}
116def _get_project_root() -> Path:
117 """Get the current project root directory."""
118 return Path.cwd()
121@app.command()
122def init(
123 title: Annotated[str, typer.Option('--title', '-t', help='Project title')],
124 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
125) -> None:
126 """Initialize a new prosemark project."""
127 try:
128 project_path = path or Path.cwd()
130 # Wire up dependencies
131 binder_repo = BinderRepoFs(project_path)
132 config_port = FileSystemConfigPort()
133 console_port = ConsolePretty()
134 logger = LoggerStdout()
135 clock = ClockSystem()
137 # Execute use case
138 interactor = InitProject(
139 binder_repo=binder_repo,
140 config_port=config_port,
141 console_port=console_port,
142 logger=logger,
143 clock=clock,
144 )
145 interactor.execute(project_path)
147 # Success output matching test expectations
148 typer.echo(f'Project "{title}" initialized successfully')
149 typer.echo('Created _binder.md with project structure')
151 except BinderIntegrityError:
152 typer.echo('Error: Directory already contains a prosemark project', err=True)
153 raise typer.Exit(1) from None
154 except FileSystemError as e:
155 typer.echo(f'Error: {e}', err=True)
156 raise typer.Exit(2) from e
157 except Exception as e:
158 typer.echo(f'Unexpected error: {e}', err=True)
159 raise typer.Exit(3) from e
162@app.command()
163def add(
164 title: Annotated[str, typer.Argument(help='Display title for the new node')],
165 parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID')] = None,
166 position: Annotated[int | None, typer.Option('--position', help="Position in parent's children")] = None,
167 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
168) -> None:
169 """Add a new node to the binder hierarchy."""
170 try:
171 project_root = path or _get_project_root()
173 # Wire up dependencies
174 binder_repo = BinderRepoFs(project_root)
175 clock = ClockSystem()
176 editor_port = EditorLauncherSystem()
177 node_repo = NodeRepoFs(project_root, editor_port, clock)
178 id_generator = IdGeneratorUuid7()
179 logger = LoggerStdout()
181 # Execute use case
182 interactor = AddNode(
183 binder_repo=binder_repo,
184 node_repo=node_repo,
185 id_generator=id_generator,
186 logger=logger,
187 clock=clock,
188 )
190 parent_id = NodeId(parent) if parent else None
191 node_id = interactor.execute(
192 title=title,
193 synopsis=None,
194 parent_id=parent_id,
195 position=position,
196 )
198 # Success output
199 typer.echo(f'Added "{title}" ({node_id})')
200 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
201 typer.echo('Updated binder structure')
203 except NodeNotFoundError:
204 typer.echo('Error: Parent node not found', err=True)
205 raise typer.Exit(1) from None
206 except ValueError:
207 typer.echo('Error: Invalid position index', err=True)
208 raise typer.Exit(2) from None
209 except FileSystemError as e:
210 typer.echo(f'Error: File creation failed - {e}', err=True)
211 raise typer.Exit(3) from e
214@app.command()
215def edit(
216 node_id: Annotated[str, typer.Argument(help='Node identifier')],
217 part: Annotated[str, typer.Option('--part', help='Content part to edit')] = 'draft',
218 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
219) -> None:
220 """Open node content in your preferred editor."""
221 try:
222 project_root = path or _get_project_root()
224 # Wire up dependencies
225 binder_repo = BinderRepoFs(project_root)
226 clock = ClockSystem()
227 editor_port = EditorLauncherSystem()
228 node_repo = NodeRepoFs(project_root, editor_port, clock)
229 logger = LoggerStdout()
231 # Execute use case
232 interactor = EditPart(
233 binder_repo=binder_repo,
234 node_repo=node_repo,
235 logger=logger,
236 )
238 interactor.execute(NodeId(node_id), part)
240 # Success output
241 if part == 'draft':
242 typer.echo(f'Opened {node_id}.md in editor')
243 elif part == 'notes':
244 typer.echo(f'Opened {node_id}.notes.md in editor')
245 else:
246 typer.echo(f'Opened {part} for {node_id} in editor')
248 except NodeNotFoundError:
249 typer.echo('Error: Node not found', err=True)
250 raise typer.Exit(1) from None
251 except EditorLaunchError:
252 typer.echo('Error: Editor not available', err=True)
253 raise typer.Exit(2) from None
254 except FileSystemError:
255 typer.echo('Error: File permission denied', err=True)
256 raise typer.Exit(3) from None
257 except ValueError as e:
258 typer.echo(f'Error: {e}', err=True)
259 raise typer.Exit(1) from e
262def _output_structure_as_json(binder_repo: BinderRepoFs, parsed_node_id: NodeId | None) -> None:
263 """Output structure in JSON format."""
264 import json
266 binder = binder_repo.load()
268 def item_to_dict(item: Item | BinderItem) -> dict[str, Any]:
269 result: dict[str, Any] = {
270 'display_title': item.display_title,
271 }
272 node_id = item.id if hasattr(item, 'id') else (item.node_id if hasattr(item, 'node_id') else None)
273 if node_id:
274 result['node_id'] = str(node_id)
275 item_children = item.children if hasattr(item, 'children') else []
276 if item_children:
277 result['children'] = [item_to_dict(child) for child in item_children]
278 return result
280 if parsed_node_id is None:
281 # Full tree
282 data: dict[str, list[dict[str, Any]]] = {'roots': [item_to_dict(item) for item in binder.roots]}
283 else:
284 # Subtree - find the specific node
285 target_item = binder.find_by_id(parsed_node_id)
286 if target_item is None:
287 typer.echo(f'Error: Node not found in binder: {parsed_node_id}', err=True)
288 raise typer.Exit(1)
289 data = {'roots': [item_to_dict(target_item)]}
291 typer.echo(json.dumps(data, indent=2))
294@app.command()
295def structure(
296 node_id: Annotated[str | None, typer.Argument(help='Node ID to display as subtree root')] = None,
297 output_format: Annotated[str, typer.Option('--format', '-f', help='Output format')] = 'tree',
298 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
299) -> None:
300 """Display project hierarchy.
302 If NODE_ID is provided, only show the subtree starting from that node.
303 """
304 try:
305 project_root = path or _get_project_root()
307 # Wire up dependencies
308 binder_repo = BinderRepoFs(project_root)
309 logger = LoggerStdout()
311 # Parse and validate node_id if provided
312 parsed_node_id = None
313 if node_id is not None:
314 parsed_node_id = NodeId(node_id)
316 # Execute use case
317 interactor = ShowStructure(
318 binder_repo=binder_repo,
319 logger=logger,
320 )
322 structure_str = interactor.execute(node_id=parsed_node_id)
324 if output_format == 'tree':
325 typer.echo('Project Structure:')
326 typer.echo(structure_str)
327 elif output_format == 'json':
328 _output_structure_as_json(binder_repo, parsed_node_id)
329 else:
330 typer.echo(f"Error: Unknown format '{output_format}'", err=True)
331 raise typer.Exit(1)
333 except NodeNotFoundError as e:
334 typer.echo(f'Error: {e}', err=True)
335 raise typer.Exit(1) from e
336 except (ValueError, NodeIdentityError) as e:
337 # Invalid node ID format
338 typer.echo(f'Error: Invalid node ID format: {e}', err=True)
339 raise typer.Exit(1) from e
340 except FileSystemError as e:
341 typer.echo(f'Error: {e}', err=True)
342 raise typer.Exit(1) from e
345@app.command()
346def write(
347 node_uuid: Annotated[str | None, typer.Argument(help='UUID of target node (optional)')] = None,
348 title: Annotated[str | None, typer.Option('--title', '-t', help='Session title')] = None,
349 word_count_goal: Annotated[int | None, typer.Option('--words', '-w', help='Word count goal')] = None,
350 time_limit: Annotated[int | None, typer.Option('--time', help='Time limit in minutes')] = None,
351 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
352) -> None:
353 """Start a freewriting session in a distraction-free TUI."""
354 try:
355 project_root = path or _get_project_root()
357 # Run freewriting session with dependency injection
358 run_freewriting_session(
359 node_uuid=node_uuid,
360 title=title,
361 word_count_goal=word_count_goal,
362 time_limit=time_limit,
363 project_path=project_root,
364 )
366 except Exception as e:
367 typer.echo(f'Error: {e}', err=True)
368 raise typer.Exit(1) from e
371def _validate_materialize_args(title: str | None, *, all_placeholders: bool) -> None:
372 """Validate mutual exclusion of materialize arguments."""
373 if title and all_placeholders:
374 typer.echo("Error: Cannot specify both 'title' and '--all' options", err=True)
375 raise typer.Exit(1) from None
377 if not title and not all_placeholders:
378 typer.echo("Error: Must specify either placeholder 'title' or '--all' flag", err=True)
379 raise typer.Exit(1) from None
382def _create_shared_dependencies(
383 project_root: Path,
384) -> tuple[BinderRepoFs, ClockSystem, EditorLauncherSystem, NodeRepoFs, IdGeneratorUuid7, LoggerStdout]:
385 """Create shared dependencies for materialization operations."""
386 binder_repo = BinderRepoFs(project_root)
387 clock = ClockSystem()
388 editor_port = EditorLauncherSystem()
389 node_repo = NodeRepoFs(project_root, editor_port, clock)
390 id_generator = IdGeneratorUuid7()
391 logger = LoggerStdout()
392 return binder_repo, clock, editor_port, node_repo, id_generator, logger
395def _generate_json_result(result: MaterializationResult | BatchMaterializeResult, output_type: str) -> dict[str, Any]:
396 """Generate JSON result dictionary for materialization process."""
397 json_result: dict[str, Any] = {
398 'type': output_type,
399 'total_placeholders': result.total_placeholders,
400 'successful_materializations': len(result.successful_materializations),
401 'failed_materializations': len(result.failed_materializations),
402 'execution_time': result.execution_time,
403 }
405 # Add overall message
406 if result.total_placeholders == 0:
407 json_result['message'] = 'No placeholders found in binder'
408 elif len(result.failed_materializations) == 0:
409 json_result['message'] = f'Successfully materialized all {result.total_placeholders} placeholders'
410 else:
411 success_count = len(result.successful_materializations)
412 failure_count = len(result.failed_materializations)
413 json_result['message'] = (
414 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
415 )
417 # Add results based on type
418 if output_type == 'batch_partial':
419 json_result['successes'] = [
420 {'placeholder_title': success.display_title, 'node_id': str(success.node_id.value)}
421 for success in result.successful_materializations
422 ]
423 json_result['failures'] = [
424 {
425 'placeholder_title': failure.display_title,
426 'error_type': failure.error_type,
427 'error_message': failure.error_message,
428 }
429 for failure in result.failed_materializations
430 ]
431 elif result.successful_materializations or result.failed_materializations:
432 details_list: list[dict[str, str]] = []
433 details_list.extend(
434 {
435 'placeholder_title': success.display_title,
436 'node_id': str(success.node_id.value),
437 'status': 'success',
438 }
439 for success in result.successful_materializations
440 )
442 details_list.extend(
443 {
444 'placeholder_title': failure.display_title,
445 'error_type': failure.error_type,
446 'error_message': failure.error_message,
447 'status': 'failed',
448 }
449 for failure in result.failed_materializations
450 )
452 json_result['details'] = details_list
454 return json_result
457def _check_result_failure_status(
458 result: MaterializationResult | BatchMaterializeResult, *, continue_on_error: bool = False
459) -> None:
460 """Check and handle result failure status."""
461 has_failures = len(result.failed_materializations) > 0
463 if has_failures:
464 if not continue_on_error:
465 raise typer.Exit(1) from None
466 if len(result.successful_materializations) == 0:
467 raise typer.Exit(1) from None
470def _report_materialization_progress(
471 result: MaterializationResult | BatchMaterializeResult,
472 *,
473 json_output: bool = False,
474 progress_messages: list[str] | None = None,
475) -> None:
476 """Report materialization progress with human-readable or JSON output."""
477 progress_messages = progress_messages or []
478 if not json_output and not progress_messages:
479 if result.total_placeholders == 0:
480 typer.echo('No placeholders found to materialize')
481 else:
482 typer.echo(f'Found {result.total_placeholders} placeholders to materialize')
483 for success in result.successful_materializations:
484 typer.echo(f"✓ Materialized '{success.display_title}'")
485 for failure in result.failed_materializations:
486 typer.echo(f"✗ Failed to materialize '{failure.display_title}'")
487 typer.echo(failure.error_message)
490def _materialize_all_placeholders(
491 project_root: Path,
492 binder_repo: BinderRepoFs,
493 node_repo: NodeRepoFs,
494 id_generator: IdGeneratorUuid7,
495 clock: ClockSystem,
496 logger: LoggerStdout,
497 *,
498 json_output: bool = False,
499 continue_on_error: bool = False,
500) -> None:
501 """Execute batch materialization of all placeholders."""
502 console = ConsolePretty()
504 # Create individual materialize use case for delegation
505 materialize_node_use_case = MaterializeNode(
506 binder_repo=binder_repo,
507 node_repo=node_repo,
508 id_generator=id_generator,
509 clock=clock,
510 console=console,
511 logger=logger,
512 )
514 # Create batch use case
515 batch_interactor = MaterializeAllPlaceholders(
516 materialize_node_use_case=materialize_node_use_case,
517 binder_repo=binder_repo,
518 node_repo=node_repo,
519 id_generator=id_generator,
520 clock=clock,
521 logger=logger,
522 )
524 # Execute with progress callback and track messages
525 progress_messages: list[str] = []
527 def progress_callback(message: str) -> None:
528 progress_messages.append(message)
529 typer.echo(message)
531 result = batch_interactor.execute(
532 project_path=project_root,
533 progress_callback=progress_callback,
534 )
536 # Report progress messages
537 _report_materialization_progress(result, json_output=json_output, progress_messages=progress_messages)
539 # Report final results
540 if json_output:
541 import json
543 # Determine type based on results
544 output_type: str = 'batch_partial' if result.failed_materializations else 'batch'
545 json_result = _generate_json_result(result, output_type)
546 typer.echo(json.dumps(json_result, indent=2))
548 # Check for specific interruption types
549 result_type: str | None = getattr(result, 'type', None)
550 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
551 raise typer.Exit(1) from None
553 # Handle failures
554 _check_result_failure_status(result, continue_on_error=continue_on_error)
555 else:
556 if result.total_placeholders == 0:
557 return
559 result_type = getattr(result, 'type', None)
560 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
561 raise typer.Exit(1) from None
563 success_count = len(result.successful_materializations)
564 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error)
567def _describe_materialization_result(
568 result: MaterializationResult | BatchMaterializeResult, success_count: int, *, continue_on_error: bool = False
569) -> None:
570 """Describe materialization results with appropriate messaging."""
571 is_complete_success = len(result.failed_materializations) == 0 and result.total_placeholders > 0
573 if is_complete_success:
574 typer.echo(f'Successfully materialized all {result.total_placeholders} placeholders')
575 else:
576 # Retrieve or generate summary message
577 summary_msg = _get_summary_message(result, success_count)
578 typer.echo(summary_msg)
580 # Check for interrupted operations
581 _check_result_failure_status(result, continue_on_error=continue_on_error)
584def _get_safe_attribute(
585 result: MaterializationResult | BatchMaterializeResult, attr_name: str, default: str = ''
586) -> str:
587 """Safely retrieve an attribute from a result object."""
588 try:
589 value = getattr(result, attr_name, default)
590 if callable(value):
591 value_result = value()
592 return value_result if isinstance(value_result, str) else default
593 return value if isinstance(value, str) else default
594 except (TypeError, ValueError, AttributeError):
595 return default
598def _get_summary_message(result: MaterializationResult | BatchMaterializeResult, success_count: int) -> str:
599 """Get summary message for materialization results."""
600 # First try standard message retrieval methods
601 summary_msg = _get_safe_attribute(result, 'message')
602 if not summary_msg:
603 summary_msg = _get_safe_attribute(result, 'summary_message')
605 # If no standard method works, generate a manual summary
606 if not summary_msg:
607 failure_count = len(result.failed_materializations)
608 if failure_count == 1:
609 summary_msg = (
610 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failure)'
611 )
612 else:
613 summary_msg = (
614 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
615 )
617 return summary_msg
620def _materialize_single_placeholder(
621 title: str,
622 binder_repo: BinderRepoFs,
623 node_repo: NodeRepoFs,
624 id_generator: IdGeneratorUuid7,
625 clock: ClockSystem,
626 console: ConsolePretty,
627 logger: LoggerStdout,
628) -> None:
629 """Execute single materialization."""
630 interactor = MaterializeNodeUseCase(
631 binder_repo=binder_repo,
632 node_repo=node_repo,
633 id_generator=id_generator,
634 clock=clock,
635 console=console,
636 logger=logger,
637 )
639 result = interactor.execute(title=title)
641 # Only output success messages if it was newly materialized
642 if not result.was_already_materialized:
643 typer.echo(f'Materialized "{title}" ({result.node_id})')
644 typer.echo(f'Created files: {result.node_id}.md, {result.node_id}.notes.md')
645 typer.echo('Updated binder structure')
648@app.command()
649def materialize(
650 title: Annotated[str | None, typer.Argument(help='Display title of placeholder to materialize')] = None,
651 all_placeholders: Annotated[bool, typer.Option('--all', help='Materialize all placeholders in binder')] = False, # noqa: FBT002
652 _parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID to search within')] = None,
653 json_output: Annotated[bool, typer.Option('--json', help='Output results in JSON format')] = False, # noqa: FBT002
654 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
655) -> None:
656 """Convert placeholder(s) to actual nodes."""
657 try:
658 _validate_materialize_args(title, all_placeholders=all_placeholders)
659 project_root = path or _get_project_root()
660 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
661 console = ConsolePretty()
663 if all_placeholders:
664 _materialize_all_placeholders(
665 project_root, binder_repo, node_repo, id_generator, clock, logger, json_output=json_output
666 )
667 else:
668 # Ensure title is not None for type safety
669 if title is None:
670 typer.echo('Error: Title is required for single materialization', err=True)
671 raise typer.Exit(1) from None
672 _materialize_single_placeholder(title, binder_repo, node_repo, id_generator, clock, console, logger)
674 except PlaceholderNotFoundError:
675 typer.echo('Error: Placeholder not found', err=True)
676 raise typer.Exit(1) from None
677 except BinderFormatError as e:
678 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
679 raise typer.Exit(1) from None
680 except BinderNotFoundError:
681 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
682 raise typer.Exit(1) from None
683 except FileSystemError:
684 typer.echo('Error: File creation failed', err=True)
685 raise typer.Exit(2) from None
688@app.command()
689def move(
690 node_id: Annotated[str, typer.Argument(help='Node to move')],
691 parent: Annotated[str | None, typer.Option('--parent', help='New parent node')] = None,
692 position: Annotated[int | None, typer.Option('--position', help="Position in new parent's children")] = None,
693 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
694) -> None:
695 """Reorganize binder hierarchy."""
696 try:
697 project_root = path or _get_project_root()
699 # Wire up dependencies
700 binder_repo = BinderRepoFs(project_root)
701 logger = LoggerStdout()
703 # Execute use case
704 interactor = MoveNode(
705 binder_repo=binder_repo,
706 logger=logger,
707 )
709 parent_id = NodeId(parent) if parent else None
710 interactor.execute(
711 node_id=NodeId(node_id),
712 parent_id=parent_id,
713 position=position,
714 )
716 # Success output
717 parent_str = 'root' if parent is None else f'parent {parent}'
718 position_str = f' at position {position}' if position is not None else ''
719 typer.echo(f'Moved node to {parent_str}{position_str}')
720 typer.echo('Updated binder structure')
722 except NodeNotFoundError as e:
723 typer.echo(f'Error: {e}', err=True)
724 raise typer.Exit(1) from e
725 except ValueError:
726 typer.echo('Error: Invalid parent or position', err=True)
727 raise typer.Exit(2) from None
728 except BinderIntegrityError:
729 typer.echo('Error: Would create circular reference', err=True)
730 raise typer.Exit(3) from None
733@app.command()
734def remove(
735 node_id: Annotated[str, typer.Argument(help='Node to remove')],
736 *,
737 delete_files: Annotated[bool, typer.Option('--delete-files', help='Also delete node files')] = False,
738 force: Annotated[bool, typer.Option('--force', '-f', help='Skip confirmation prompt')] = False,
739 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
740) -> None:
741 """Remove a node from the binder."""
742 try:
743 project_root = path or _get_project_root()
745 # Confirmation prompt if not forced
746 if not force and delete_files:
747 confirm = typer.confirm(f'Really delete node {node_id} and its files?')
748 if not confirm:
749 typer.echo('Operation cancelled')
750 raise typer.Exit(2)
752 # Wire up dependencies
753 binder_repo = BinderRepoFs(project_root)
754 clock = ClockSystem()
755 editor_port = EditorLauncherSystem()
756 node_repo = NodeRepoFs(project_root, editor_port, clock)
757 logger = LoggerStdout()
759 # Execute use case
760 interactor = RemoveNode(
761 binder_repo=binder_repo,
762 node_repo=node_repo,
763 logger=logger,
764 )
766 # Get node title for output
767 binder = binder_repo.load()
768 target_item = binder.find_by_id(NodeId(node_id))
769 title = target_item.display_title if target_item else node_id
771 interactor.execute(NodeId(node_id), delete_files=delete_files)
773 # Success output
774 typer.echo(f'Removed "{title}" from binder')
775 if delete_files:
776 typer.echo(f'Deleted files: {node_id}.md, {node_id}.notes.md')
777 else:
778 typer.echo(f'Files preserved: {node_id}.md, {node_id}.notes.md')
780 except NodeNotFoundError:
781 typer.echo('Error: Node not found', err=True)
782 raise typer.Exit(1) from None
783 except FileSystemError:
784 typer.echo('Error: File deletion failed', err=True)
785 raise typer.Exit(3) from None
788def _validate_materialize_all_options(
789 *,
790 dry_run: bool,
791 force: bool,
792 verbose: bool,
793 quiet: bool,
794 continue_on_error: bool,
795 batch_size: int,
796 timeout: int,
797) -> None:
798 """Validate options for materialize_all command."""
799 # Validate mutually exclusive options
800 if dry_run and force:
801 typer.echo('Error: Cannot use both --dry-run and --force options simultaneously', err=True)
802 raise typer.Exit(1)
804 if verbose and quiet:
805 typer.echo('Error: Cannot use both --verbose and --quiet options simultaneously', err=True)
806 raise typer.Exit(1)
808 if dry_run and continue_on_error:
809 typer.echo('Error: Cannot use --continue-on-error with --dry-run', err=True)
810 raise typer.Exit(1)
812 # Validate batch size
813 if batch_size <= 0:
814 typer.echo('Error: Batch size must be greater than zero', err=True)
815 raise typer.Exit(1)
817 # Validate timeout
818 if timeout <= 0:
819 typer.echo('Error: Timeout must be greater than zero', err=True)
820 raise typer.Exit(1)
823def _execute_dry_run_materialize(project_root: Path) -> None:
824 """Execute a dry run preview of placeholder materialization."""
825 binder_repo = BinderRepoFs(project_root)
826 binder = binder_repo.load()
827 placeholders = [item for item in binder.depth_first_traversal() if item.is_placeholder()]
829 if not placeholders:
830 typer.echo('No placeholders found in binder')
831 return
833 typer.echo(f'Would materialize {len(placeholders)} placeholders:')
834 for placeholder in placeholders:
835 typer.echo(f' - "{placeholder.display_title}"')
838def _execute_materialize_all(
839 project_root: Path,
840 *,
841 continue_on_error: bool = False,
842) -> None:
843 """Execute materialization of all placeholders."""
844 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
846 _materialize_all_placeholders(
847 project_root,
848 binder_repo,
849 node_repo,
850 id_generator,
851 clock,
852 logger,
853 json_output=False,
854 continue_on_error=continue_on_error,
855 )
858@app.command(name='materialize-all')
859def materialize_all(
860 *,
861 dry_run: Annotated[
862 bool, typer.Option('--dry-run', help='Preview what would be materialized without making changes')
863 ] = False,
864 force: Annotated[bool, typer.Option('--force', help='Force materialization even with warnings')] = False,
865 verbose: Annotated[bool, typer.Option('--verbose', help='Show detailed progress information')] = False,
866 quiet: Annotated[bool, typer.Option('--quiet', help='Suppress non-essential output')] = False,
867 continue_on_error: Annotated[
868 bool, typer.Option('--continue-on-error', help='Continue processing after errors')
869 ] = False,
870 batch_size: Annotated[
871 int, typer.Option('--batch-size', help='Number of placeholders to process in each batch')
872 ] = 10,
873 timeout: Annotated[int, typer.Option('--timeout', help='Timeout in seconds for each materialization')] = 60,
874 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
875) -> None:
876 """Materialize all placeholders in the binder."""
877 _validate_materialize_all_options(
878 dry_run=dry_run,
879 force=force,
880 verbose=verbose,
881 quiet=quiet,
882 continue_on_error=continue_on_error,
883 batch_size=batch_size,
884 timeout=timeout,
885 )
887 try:
888 project_root = path or _get_project_root()
890 # Validate that the path exists and is a directory
891 if not project_root.exists():
892 typer.echo('Error: Path does not exist', err=True)
893 raise typer.Exit(1)
895 if not project_root.is_dir():
896 typer.echo('Error: Path is not a directory', err=True)
897 raise typer.Exit(1)
899 if dry_run:
900 _execute_dry_run_materialize(project_root)
901 else:
902 _execute_materialize_all(project_root, continue_on_error=continue_on_error)
904 except PlaceholderNotFoundError:
905 typer.echo('Error: No placeholders found', err=True)
906 raise typer.Exit(1) from None
907 except RuntimeError as e:
908 typer.echo(f'Error: {e}', err=True)
909 raise typer.Exit(1) from None
910 except TimeoutError as e:
911 typer.echo(f'Error: Operation timed out - {e}', err=True)
912 raise typer.Exit(1) from None
913 except BinderFormatError as e:
914 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
915 raise typer.Exit(1) from None
916 except BinderNotFoundError:
917 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
918 raise typer.Exit(1) from None
919 except FileSystemError:
920 typer.echo('Error: File creation failed', err=True)
921 raise typer.Exit(2) from None
924@app.command()
925def audit( # noqa: C901, PLR0912
926 *,
927 fix: Annotated[bool, typer.Option('--fix', help='Attempt to fix discovered issues')] = False,
928 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
929) -> None:
930 """Check project integrity."""
931 try:
932 project_root = path or _get_project_root()
934 # Wire up dependencies
935 binder_repo = BinderRepoFs(project_root)
936 clock = ClockSystem()
937 editor_port = EditorLauncherSystem()
938 node_repo = NodeRepoFs(project_root, editor_port, clock)
939 logger = LoggerStdout()
941 # Execute use case
942 interactor = AuditBinder(
943 binder_repo=binder_repo,
944 node_repo=node_repo,
945 logger=logger,
946 )
948 report = interactor.execute()
950 # Always report placeholders if they exist (informational)
951 if report.placeholders:
952 for placeholder in report.placeholders:
953 typer.echo(f'⚠ PLACEHOLDER: "{placeholder.display_title}" (no associated files)')
955 # Report actual issues if they exist
956 has_real_issues = report.missing or report.orphans or report.mismatches
957 if has_real_issues:
958 if report.placeholders:
959 typer.echo('') # Add spacing after placeholders
960 typer.echo('Project integrity issues found:')
962 if report.missing:
963 for missing in report.missing:
964 typer.echo(f'⚠ MISSING: Node {missing.node_id} referenced but files not found')
966 if report.orphans:
967 for orphan in report.orphans:
968 typer.echo(f'⚠ ORPHAN: File {orphan.file_path} exists but not in binder')
970 if report.mismatches:
971 for mismatch in report.mismatches:
972 typer.echo(f'⚠ MISMATCH: File {mismatch.file_path} ID mismatch')
973 else:
974 # Show success messages for real issues when none exist
975 if report.placeholders:
976 typer.echo('') # Add spacing after placeholders
977 typer.echo('Project integrity check completed')
978 typer.echo('✓ All nodes have valid files')
979 typer.echo('✓ All references are consistent')
980 typer.echo('✓ No orphaned files found')
982 # Only exit with error code for real issues, not placeholders
983 if has_real_issues:
984 if fix:
985 typer.echo('\nNote: Auto-fix not implemented in MVP')
986 raise typer.Exit(2)
987 # Exit with code 1 when issues are found (standard audit behavior)
988 raise typer.Exit(1)
990 except FileSystemError as e:
991 typer.echo(f'Error: {e}', err=True)
992 raise typer.Exit(2) from e
995def main() -> None:
996 """Main entry point for the CLI."""
997 app()
1000if __name__ == '__main__': # pragma: no cover
1001 main()
1004@app.command(name='compile')
1005def compile_cmd(
1006 node_id: Annotated[str, typer.Argument(help='Node ID to compile')],
1007 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
1008) -> None:
1009 """Compile a node and its subtree into concatenated plain text."""
1010 from prosemark.cli.compile import compile_command
1012 compile_command(node_id, path)