Coverage for src/prosemark/cli/main.py: 100%

410 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-30 23:09 +0000

1"""Main CLI entry point for prosemark. 

2 

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""" 

7 

8# Standard library imports 

9from collections.abc import Callable 

10from pathlib import Path 

11from typing import Annotated, Any, Protocol 

12 

13# Third-party imports 

14import typer 

15 

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 

24 

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 

39 

40# Domain model imports 

41from prosemark.domain.binder import Item 

42from prosemark.domain.models import BinderItem, NodeId 

43 

44# Exception imports 

45from prosemark.exceptions import ( 

46 BinderFormatError, 

47 BinderIntegrityError, 

48 BinderNotFoundError, 

49 EditorLaunchError, 

50 FileSystemError, 

51 NodeIdentityError, 

52 NodeNotFoundError, 

53 PlaceholderNotFoundError, 

54) 

55 

56# Freewriting imports 

57from prosemark.freewriting.container import run_freewriting_session 

58 

59# Port imports 

60from prosemark.ports.config_port import ConfigPort, ProsemarkConfig 

61 

62 

63# Protocol definitions 

64class MaterializationResult(Protocol): 

65 """Protocol for materialization process result objects. 

66 

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 """ 

71 

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] 

81 

82 

83app = typer.Typer( 

84 name='pmk', 

85 help='Prosemark CLI - A hierarchical writing project manager', 

86 add_completion=False, 

87) 

88 

89# Alias for backward compatibility with tests 

90cli = app 

91 

92 

93class FileSystemConfigPort(ConfigPort): 

94 """Temporary config port implementation.""" 

95 

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 

99 

100 @staticmethod 

101 def config_exists(config_path: Path) -> bool: 

102 """Check if configuration file already exists.""" 

103 return config_path.exists() 

104 

105 @staticmethod 

106 def get_default_config_values() -> ProsemarkConfig: 

107 """Return default configuration values as dictionary.""" 

108 return {} 

109 

110 @staticmethod 

111 def load_config(_config_path: Path) -> dict[str, Any]: 

112 """Load configuration from file.""" 

113 return {} 

114 

115 

116def _get_project_root() -> Path: 

117 """Get the current project root directory.""" 

118 return Path.cwd() 

119 

120 

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() 

129 

130 # Wire up dependencies 

131 binder_repo = BinderRepoFs(project_path) 

132 config_port = FileSystemConfigPort() 

133 console_port = ConsolePretty() 

134 logger = LoggerStdout() 

135 clock = ClockSystem() 

136 

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) 

146 

147 # Success output matching test expectations 

148 typer.echo(f'Project "{title}" initialized successfully') 

149 typer.echo('Created _binder.md with project structure') 

150 

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 

160 

161 

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() 

172 

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() 

180 

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 ) 

189 

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 ) 

197 

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') 

202 

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 

212 

213 

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() 

223 

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() 

230 

231 # Execute use case 

232 interactor = EditPart( 

233 binder_repo=binder_repo, 

234 node_repo=node_repo, 

235 logger=logger, 

236 ) 

237 

238 interactor.execute(NodeId(node_id), part) 

239 

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') 

247 

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 

260 

261 

262def _output_structure_as_json(binder_repo: BinderRepoFs, parsed_node_id: NodeId | None) -> None: 

263 """Output structure in JSON format.""" 

264 import json 

265 

266 binder = binder_repo.load() 

267 

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 

279 

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)]} 

290 

291 typer.echo(json.dumps(data, indent=2)) 

292 

293 

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. 

301 

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() 

306 

307 # Wire up dependencies 

308 binder_repo = BinderRepoFs(project_root) 

309 logger = LoggerStdout() 

310 

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) 

315 

316 # Execute use case 

317 interactor = ShowStructure( 

318 binder_repo=binder_repo, 

319 logger=logger, 

320 ) 

321 

322 structure_str = interactor.execute(node_id=parsed_node_id) 

323 

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) 

332 

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 

343 

344 

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() 

356 

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 ) 

365 

366 except Exception as e: 

367 typer.echo(f'Error: {e}', err=True) 

368 raise typer.Exit(1) from e 

369 

370 

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 

376 

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 

380 

381 

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 

393 

394 

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 } 

404 

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 ) 

416 

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 ) 

441 

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 ) 

451 

452 json_result['details'] = details_list 

453 

454 return json_result 

455 

456 

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 

462 

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 

468 

469 

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) 

488 

489 

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() 

503 

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 ) 

513 

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 ) 

523 

524 # Execute with progress callback and track messages 

525 progress_messages: list[str] = [] 

526 

527 def progress_callback(message: str) -> None: 

528 progress_messages.append(message) 

529 typer.echo(message) 

530 

531 result = batch_interactor.execute( 

532 project_path=project_root, 

533 progress_callback=progress_callback, 

534 ) 

535 

536 # Report progress messages 

537 _report_materialization_progress(result, json_output=json_output, progress_messages=progress_messages) 

538 

539 # Report final results 

540 if json_output: 

541 import json 

542 

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)) 

547 

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 

552 

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 

558 

559 result_type = getattr(result, 'type', None) 

560 if result_type in {'batch_interrupted', 'batch_critical_failure'}: 

561 raise typer.Exit(1) from None 

562 

563 success_count = len(result.successful_materializations) 

564 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error) 

565 

566 

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 

572 

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) 

579 

580 # Check for interrupted operations 

581 _check_result_failure_status(result, continue_on_error=continue_on_error) 

582 

583 

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 

596 

597 

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') 

604 

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 ) 

616 

617 return summary_msg 

618 

619 

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 ) 

638 

639 result = interactor.execute(title=title) 

640 

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') 

646 

647 

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() 

662 

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) 

673 

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 

686 

687 

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() 

698 

699 # Wire up dependencies 

700 binder_repo = BinderRepoFs(project_root) 

701 logger = LoggerStdout() 

702 

703 # Execute use case 

704 interactor = MoveNode( 

705 binder_repo=binder_repo, 

706 logger=logger, 

707 ) 

708 

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 ) 

715 

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') 

721 

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 

731 

732 

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() 

744 

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) 

751 

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() 

758 

759 # Execute use case 

760 interactor = RemoveNode( 

761 binder_repo=binder_repo, 

762 node_repo=node_repo, 

763 logger=logger, 

764 ) 

765 

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 

770 

771 interactor.execute(NodeId(node_id), delete_files=delete_files) 

772 

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') 

779 

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 

786 

787 

788@app.command() 

789def audit( # noqa: C901, PLR0912 

790 *, 

791 fix: Annotated[bool, typer.Option('--fix', help='Attempt to fix discovered issues')] = False, 

792 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None, 

793) -> None: 

794 """Check project integrity.""" 

795 try: 

796 project_root = path or _get_project_root() 

797 

798 # Wire up dependencies 

799 binder_repo = BinderRepoFs(project_root) 

800 clock = ClockSystem() 

801 editor_port = EditorLauncherSystem() 

802 node_repo = NodeRepoFs(project_root, editor_port, clock) 

803 logger = LoggerStdout() 

804 

805 # Execute use case 

806 interactor = AuditBinder( 

807 binder_repo=binder_repo, 

808 node_repo=node_repo, 

809 logger=logger, 

810 ) 

811 

812 report = interactor.execute() 

813 

814 # Always report placeholders if they exist (informational) 

815 if report.placeholders: 

816 for placeholder in report.placeholders: 

817 typer.echo(f'⚠ PLACEHOLDER: "{placeholder.display_title}" (no associated files)') 

818 

819 # Report actual issues if they exist 

820 has_real_issues = report.missing or report.orphans or report.mismatches 

821 if has_real_issues: 

822 if report.placeholders: 

823 typer.echo('') # Add spacing after placeholders 

824 typer.echo('Project integrity issues found:') 

825 

826 if report.missing: 

827 for missing in report.missing: 

828 typer.echo(f'⚠ MISSING: Node {missing.node_id} referenced but files not found') 

829 

830 if report.orphans: 

831 for orphan in report.orphans: 

832 typer.echo(f'⚠ ORPHAN: File {orphan.file_path} exists but not in binder') 

833 

834 if report.mismatches: 

835 for mismatch in report.mismatches: 

836 typer.echo(f'⚠ MISMATCH: File {mismatch.file_path} ID mismatch') 

837 else: 

838 # Show success messages for real issues when none exist 

839 if report.placeholders: 

840 typer.echo('') # Add spacing after placeholders 

841 typer.echo('Project integrity check completed') 

842 typer.echo('✓ All nodes have valid files') 

843 typer.echo('✓ All references are consistent') 

844 typer.echo('✓ No orphaned files found') 

845 

846 # Only exit with error code for real issues, not placeholders 

847 if has_real_issues: 

848 if fix: 

849 typer.echo('\nNote: Auto-fix not implemented in MVP') 

850 raise typer.Exit(2) 

851 # Exit with code 1 when issues are found (standard audit behavior) 

852 raise typer.Exit(1) 

853 

854 except FileSystemError as e: 

855 typer.echo(f'Error: {e}', err=True) 

856 raise typer.Exit(2) from e 

857 

858 

859def main() -> None: 

860 """Main entry point for the CLI.""" 

861 app() 

862 

863 

864if __name__ == '__main__': # pragma: no cover 

865 main() 

866 

867 

868@app.command(name='compile') 

869def compile_cmd( 

870 node_id: Annotated[str, typer.Argument(help='Node ID to compile')], 

871 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None, 

872) -> None: 

873 """Compile a node and its subtree into concatenated plain text.""" 

874 from prosemark.cli.compile import compile_command 

875 

876 compile_command(node_id, path)