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

451 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +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.use_cases import ( 

29 AddNode, 

30 AuditBinder, 

31 EditPart, 

32 InitProject, 

33 MoveNode, 

34 RemoveNode, 

35 ShowStructure, 

36) 

37from prosemark.app.use_cases import MaterializeNode as MaterializeNodeUseCase 

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 AlreadyMaterializedError, 

47 BinderFormatError, 

48 BinderIntegrityError, 

49 BinderNotFoundError, 

50 EditorLaunchError, 

51 FileSystemError, 

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 

262@app.command() 

263def structure( 

264 output_format: Annotated[str, typer.Option('--format', '-f', help='Output format')] = 'tree', 

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

266) -> None: 

267 """Display project hierarchy.""" 

268 try: 

269 project_root = path or _get_project_root() 

270 

271 # Wire up dependencies 

272 binder_repo = BinderRepoFs(project_root) 

273 logger = LoggerStdout() 

274 

275 # Execute use case 

276 interactor = ShowStructure( 

277 binder_repo=binder_repo, 

278 logger=logger, 

279 ) 

280 

281 structure_str = interactor.execute() 

282 

283 if output_format == 'tree': 

284 typer.echo('Project Structure:') 

285 typer.echo(structure_str) 

286 elif output_format == 'json': 

287 # For JSON format, we need to convert the tree to JSON 

288 # This is a simplified version for MVP 

289 import json 

290 

291 binder = binder_repo.load() 

292 

293 def item_to_dict(item: Item | BinderItem) -> dict[str, Any]: 

294 result: dict[str, Any] = { 

295 'display_title': item.display_title, 

296 } 

297 node_id = item.id if hasattr(item, 'id') else (item.node_id if hasattr(item, 'node_id') else None) 

298 if node_id: 

299 result['node_id'] = str(node_id) 

300 item_children = item.children if hasattr(item, 'children') else [] 

301 if item_children: 

302 result['children'] = [item_to_dict(child) for child in item_children] 

303 return result 

304 

305 data: dict[str, list[dict[str, Any]]] = {'roots': [item_to_dict(item) for item in binder.roots]} 

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

307 else: 

308 typer.echo(f"Error: Unknown format '{output_format}'", err=True) 

309 raise typer.Exit(1) 

310 

311 except FileSystemError as e: 

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

313 raise typer.Exit(1) from e 

314 

315 

316@app.command() 

317def write( 

318 node_uuid: Annotated[str | None, typer.Argument(help='UUID of target node (optional)')] = None, 

319 title: Annotated[str | None, typer.Option('--title', '-t', help='Session title')] = None, 

320 word_count_goal: Annotated[int | None, typer.Option('--words', '-w', help='Word count goal')] = None, 

321 time_limit: Annotated[int | None, typer.Option('--time', help='Time limit in minutes')] = None, 

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

323) -> None: 

324 """Start a freewriting session in a distraction-free TUI.""" 

325 try: 

326 project_root = path or _get_project_root() 

327 

328 # Run freewriting session with dependency injection 

329 run_freewriting_session( 

330 node_uuid=node_uuid, 

331 title=title, 

332 word_count_goal=word_count_goal, 

333 time_limit=time_limit, 

334 project_path=project_root, 

335 ) 

336 

337 except Exception as e: 

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

339 raise typer.Exit(1) from e 

340 

341 

342def _validate_materialize_args(title: str | None, *, all_placeholders: bool) -> None: 

343 """Validate mutual exclusion of materialize arguments.""" 

344 if title and all_placeholders: 

345 typer.echo("Error: Cannot specify both 'title' and '--all' options", err=True) 

346 raise typer.Exit(1) from None 

347 

348 if not title and not all_placeholders: 

349 typer.echo("Error: Must specify either placeholder 'title' or '--all' flag", err=True) 

350 raise typer.Exit(1) from None 

351 

352 

353def _create_shared_dependencies( 

354 project_root: Path, 

355) -> tuple[BinderRepoFs, ClockSystem, EditorLauncherSystem, NodeRepoFs, IdGeneratorUuid7, LoggerStdout]: 

356 """Create shared dependencies for materialization operations.""" 

357 binder_repo = BinderRepoFs(project_root) 

358 clock = ClockSystem() 

359 editor_port = EditorLauncherSystem() 

360 node_repo = NodeRepoFs(project_root, editor_port, clock) 

361 id_generator = IdGeneratorUuid7() 

362 logger = LoggerStdout() 

363 return binder_repo, clock, editor_port, node_repo, id_generator, logger 

364 

365 

366def _generate_json_result(result: MaterializationResult | BatchMaterializeResult, output_type: str) -> dict[str, Any]: 

367 """Generate JSON result dictionary for materialization process.""" 

368 json_result: dict[str, Any] = { 

369 'type': output_type, 

370 'total_placeholders': result.total_placeholders, 

371 'successful_materializations': len(result.successful_materializations), 

372 'failed_materializations': len(result.failed_materializations), 

373 'execution_time': result.execution_time, 

374 } 

375 

376 # Add overall message 

377 if result.total_placeholders == 0: 

378 json_result['message'] = 'No placeholders found in binder' 

379 elif len(result.failed_materializations) == 0: 

380 json_result['message'] = f'Successfully materialized all {result.total_placeholders} placeholders' 

381 else: 

382 success_count = len(result.successful_materializations) 

383 failure_count = len(result.failed_materializations) 

384 json_result['message'] = ( 

385 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)' 

386 ) 

387 

388 # Add results based on type 

389 if output_type == 'batch_partial': 

390 json_result['successes'] = [ 

391 {'placeholder_title': success.display_title, 'node_id': str(success.node_id.value)} 

392 for success in result.successful_materializations 

393 ] 

394 json_result['failures'] = [ 

395 { 

396 'placeholder_title': failure.display_title, 

397 'error_type': failure.error_type, 

398 'error_message': failure.error_message, 

399 } 

400 for failure in result.failed_materializations 

401 ] 

402 elif result.successful_materializations or result.failed_materializations: 

403 details_list: list[dict[str, str]] = [] 

404 details_list.extend( 

405 { 

406 'placeholder_title': success.display_title, 

407 'node_id': str(success.node_id.value), 

408 'status': 'success', 

409 } 

410 for success in result.successful_materializations 

411 ) 

412 

413 details_list.extend( 

414 { 

415 'placeholder_title': failure.display_title, 

416 'error_type': failure.error_type, 

417 'error_message': failure.error_message, 

418 'status': 'failed', 

419 } 

420 for failure in result.failed_materializations 

421 ) 

422 

423 json_result['details'] = details_list 

424 

425 return json_result 

426 

427 

428def _check_result_failure_status( 

429 result: MaterializationResult | BatchMaterializeResult, *, continue_on_error: bool = False 

430) -> None: 

431 """Check and handle result failure status.""" 

432 has_failures = len(result.failed_materializations) > 0 

433 

434 if has_failures: 

435 if not continue_on_error: 

436 raise typer.Exit(1) from None 

437 if len(result.successful_materializations) == 0: 

438 raise typer.Exit(1) from None 

439 

440 

441def _report_materialization_progress( 

442 result: MaterializationResult | BatchMaterializeResult, 

443 *, 

444 json_output: bool = False, 

445 progress_messages: list[str] | None = None, 

446) -> None: 

447 """Report materialization progress with human-readable or JSON output.""" 

448 progress_messages = progress_messages or [] 

449 if not json_output and not progress_messages: 

450 if result.total_placeholders == 0: 

451 typer.echo('No placeholders found to materialize') 

452 else: 

453 typer.echo(f'Found {result.total_placeholders} placeholders to materialize') 

454 for success in result.successful_materializations: 

455 typer.echo(f"✓ Materialized '{success.display_title}'") 

456 for failure in result.failed_materializations: 

457 typer.echo(f"✗ Failed to materialize '{failure.display_title}'") 

458 typer.echo(failure.error_message) 

459 

460 

461def _materialize_all_placeholders( 

462 project_root: Path, 

463 binder_repo: BinderRepoFs, 

464 node_repo: NodeRepoFs, 

465 id_generator: IdGeneratorUuid7, 

466 clock: ClockSystem, 

467 logger: LoggerStdout, 

468 *, 

469 json_output: bool = False, 

470 continue_on_error: bool = False, 

471) -> None: 

472 """Execute batch materialization of all placeholders.""" 

473 console = ConsolePretty() 

474 

475 # Create individual materialize use case for delegation 

476 materialize_node_use_case = MaterializeNode( 

477 binder_repo=binder_repo, 

478 node_repo=node_repo, 

479 id_generator=id_generator, 

480 clock=clock, 

481 console=console, 

482 logger=logger, 

483 ) 

484 

485 # Create batch use case 

486 batch_interactor = MaterializeAllPlaceholders( 

487 materialize_node_use_case=materialize_node_use_case, 

488 binder_repo=binder_repo, 

489 node_repo=node_repo, 

490 id_generator=id_generator, 

491 clock=clock, 

492 logger=logger, 

493 ) 

494 

495 # Execute with progress callback and track messages 

496 progress_messages: list[str] = [] 

497 

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

499 progress_messages.append(message) 

500 typer.echo(message) 

501 

502 result = batch_interactor.execute( 

503 project_path=project_root, 

504 progress_callback=progress_callback, 

505 ) 

506 

507 # Report progress messages 

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

509 

510 # Report final results 

511 if json_output: 

512 import json 

513 

514 # Determine type based on results 

515 output_type: str = 'batch_partial' if result.failed_materializations else 'batch' 

516 json_result = _generate_json_result(result, output_type) 

517 typer.echo(json.dumps(json_result, indent=2)) 

518 

519 # Check for specific interruption types 

520 result_type: str | None = getattr(result, 'type', None) 

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

522 raise typer.Exit(1) from None 

523 

524 # Handle failures 

525 _check_result_failure_status(result, continue_on_error=continue_on_error) 

526 else: 

527 if result.total_placeholders == 0: 

528 return 

529 

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

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

532 raise typer.Exit(1) from None 

533 

534 success_count = len(result.successful_materializations) 

535 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error) 

536 

537 

538def _describe_materialization_result( 

539 result: MaterializationResult | BatchMaterializeResult, success_count: int, *, continue_on_error: bool = False 

540) -> None: 

541 """Describe materialization results with appropriate messaging.""" 

542 is_complete_success = len(result.failed_materializations) == 0 and result.total_placeholders > 0 

543 

544 if is_complete_success: 

545 typer.echo(f'Successfully materialized all {result.total_placeholders} placeholders') 

546 else: 

547 # Retrieve or generate summary message 

548 summary_msg = _get_summary_message(result, success_count) 

549 typer.echo(summary_msg) 

550 

551 # Check for interrupted operations 

552 _check_result_failure_status(result, continue_on_error=continue_on_error) 

553 

554 

555def _get_safe_attribute( 

556 result: MaterializationResult | BatchMaterializeResult, attr_name: str, default: str = '' 

557) -> str: 

558 """Safely retrieve an attribute from a result object.""" 

559 try: 

560 value = getattr(result, attr_name, default) 

561 if callable(value): 

562 value_result = value() 

563 return value_result if isinstance(value_result, str) else default 

564 return value if isinstance(value, str) else default 

565 except (TypeError, ValueError, AttributeError): 

566 return default 

567 

568 

569def _get_summary_message(result: MaterializationResult | BatchMaterializeResult, success_count: int) -> str: 

570 """Get summary message for materialization results.""" 

571 # First try standard message retrieval methods 

572 summary_msg = _get_safe_attribute(result, 'message') 

573 if not summary_msg: 

574 summary_msg = _get_safe_attribute(result, 'summary_message') 

575 

576 # If no standard method works, generate a manual summary 

577 if not summary_msg: 

578 failure_count = len(result.failed_materializations) 

579 if failure_count == 1: 

580 summary_msg = ( 

581 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failure)' 

582 ) 

583 else: 

584 summary_msg = ( 

585 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)' 

586 ) 

587 

588 return summary_msg 

589 

590 

591def _materialize_single_placeholder( 

592 title: str, 

593 binder_repo: BinderRepoFs, 

594 node_repo: NodeRepoFs, 

595 id_generator: IdGeneratorUuid7, 

596 logger: LoggerStdout, 

597) -> None: 

598 """Execute single materialization.""" 

599 interactor = MaterializeNodeUseCase( 

600 binder_repo=binder_repo, 

601 node_repo=node_repo, 

602 id_generator=id_generator, 

603 logger=logger, 

604 ) 

605 

606 node_id = interactor.execute(display_title=title, synopsis=None) 

607 

608 # Success output 

609 typer.echo(f'Materialized "{title}" ({node_id})') 

610 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md') 

611 typer.echo('Updated binder structure') 

612 

613 

614@app.command() 

615def materialize( 

616 title: Annotated[str | None, typer.Argument(help='Display title of placeholder to materialize')] = None, 

617 all_placeholders: Annotated[bool, typer.Option('--all', help='Materialize all placeholders in binder')] = False, # noqa: FBT002 

618 _parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID to search within')] = None, 

619 json_output: Annotated[bool, typer.Option('--json', help='Output results in JSON format')] = False, # noqa: FBT002 

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

621) -> None: 

622 """Convert placeholder(s) to actual nodes.""" 

623 try: 

624 _validate_materialize_args(title, all_placeholders=all_placeholders) 

625 project_root = path or _get_project_root() 

626 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root) 

627 

628 if all_placeholders: 

629 _materialize_all_placeholders( 

630 project_root, binder_repo, node_repo, id_generator, clock, logger, json_output=json_output 

631 ) 

632 else: 

633 # Ensure title is not None for type safety 

634 if title is None: 

635 typer.echo('Error: Title is required for single materialization', err=True) 

636 raise typer.Exit(1) from None 

637 _materialize_single_placeholder(title, binder_repo, node_repo, id_generator, logger) 

638 

639 except PlaceholderNotFoundError: 

640 typer.echo('Error: Placeholder not found', err=True) 

641 raise typer.Exit(1) from None 

642 except AlreadyMaterializedError: 

643 typer.echo(f"Error: '{title}' is already materialized", err=True) 

644 raise typer.Exit(1) from None 

645 except BinderFormatError as e: 

646 typer.echo(f'Error: Malformed binder structure - {e}', err=True) 

647 raise typer.Exit(1) from None 

648 except BinderNotFoundError: 

649 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True) 

650 raise typer.Exit(1) from None 

651 except FileSystemError: 

652 typer.echo('Error: File creation failed', err=True) 

653 raise typer.Exit(2) from None 

654 

655 

656@app.command() 

657def move( 

658 node_id: Annotated[str, typer.Argument(help='Node to move')], 

659 parent: Annotated[str | None, typer.Option('--parent', help='New parent node')] = None, 

660 position: Annotated[int | None, typer.Option('--position', help="Position in new parent's children")] = None, 

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

662) -> None: 

663 """Reorganize binder hierarchy.""" 

664 try: 

665 project_root = path or _get_project_root() 

666 

667 # Wire up dependencies 

668 binder_repo = BinderRepoFs(project_root) 

669 logger = LoggerStdout() 

670 

671 # Execute use case 

672 interactor = MoveNode( 

673 binder_repo=binder_repo, 

674 logger=logger, 

675 ) 

676 

677 parent_id = NodeId(parent) if parent else None 

678 interactor.execute( 

679 node_id=NodeId(node_id), 

680 parent_id=parent_id, 

681 position=position, 

682 ) 

683 

684 # Success output 

685 parent_str = 'root' if parent is None else f'parent {parent}' 

686 position_str = f' at position {position}' if position is not None else '' 

687 typer.echo(f'Moved node to {parent_str}{position_str}') 

688 typer.echo('Updated binder structure') 

689 

690 except NodeNotFoundError as e: 

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

692 raise typer.Exit(1) from e 

693 except ValueError: 

694 typer.echo('Error: Invalid parent or position', err=True) 

695 raise typer.Exit(2) from None 

696 except BinderIntegrityError: 

697 typer.echo('Error: Would create circular reference', err=True) 

698 raise typer.Exit(3) from None 

699 

700 

701@app.command() 

702def remove( 

703 node_id: Annotated[str, typer.Argument(help='Node to remove')], 

704 *, 

705 delete_files: Annotated[bool, typer.Option('--delete-files', help='Also delete node files')] = False, 

706 force: Annotated[bool, typer.Option('--force', '-f', help='Skip confirmation prompt')] = False, 

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

708) -> None: 

709 """Remove a node from the binder.""" 

710 try: 

711 project_root = path or _get_project_root() 

712 

713 # Confirmation prompt if not forced 

714 if not force and delete_files: 

715 confirm = typer.confirm(f'Really delete node {node_id} and its files?') 

716 if not confirm: 

717 typer.echo('Operation cancelled') 

718 raise typer.Exit(2) 

719 

720 # Wire up dependencies 

721 binder_repo = BinderRepoFs(project_root) 

722 clock = ClockSystem() 

723 editor_port = EditorLauncherSystem() 

724 node_repo = NodeRepoFs(project_root, editor_port, clock) 

725 logger = LoggerStdout() 

726 

727 # Execute use case 

728 interactor = RemoveNode( 

729 binder_repo=binder_repo, 

730 node_repo=node_repo, 

731 logger=logger, 

732 ) 

733 

734 # Get node title for output 

735 binder = binder_repo.load() 

736 target_item = binder.find_by_id(NodeId(node_id)) 

737 title = target_item.display_title if target_item else node_id 

738 

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

740 

741 # Success output 

742 typer.echo(f'Removed "{title}" from binder') 

743 if delete_files: 

744 typer.echo(f'Deleted files: {node_id}.md, {node_id}.notes.md') 

745 else: 

746 typer.echo(f'Files preserved: {node_id}.md, {node_id}.notes.md') 

747 

748 except NodeNotFoundError: 

749 typer.echo('Error: Node not found', err=True) 

750 raise typer.Exit(1) from None 

751 except FileSystemError: 

752 typer.echo('Error: File deletion failed', err=True) 

753 raise typer.Exit(3) from None 

754 

755 

756def _validate_materialize_all_options( 

757 *, 

758 dry_run: bool, 

759 force: bool, 

760 verbose: bool, 

761 quiet: bool, 

762 continue_on_error: bool, 

763 batch_size: int, 

764 timeout: int, 

765) -> None: 

766 """Validate options for materialize_all command.""" 

767 # Validate mutually exclusive options 

768 if dry_run and force: 

769 typer.echo('Error: Cannot use both --dry-run and --force options simultaneously', err=True) 

770 raise typer.Exit(1) 

771 

772 if verbose and quiet: 

773 typer.echo('Error: Cannot use both --verbose and --quiet options simultaneously', err=True) 

774 raise typer.Exit(1) 

775 

776 if dry_run and continue_on_error: 

777 typer.echo('Error: Cannot use --continue-on-error with --dry-run', err=True) 

778 raise typer.Exit(1) 

779 

780 # Validate batch size 

781 if batch_size <= 0: 

782 typer.echo('Error: Batch size must be greater than zero', err=True) 

783 raise typer.Exit(1) 

784 

785 # Validate timeout 

786 if timeout <= 0: 

787 typer.echo('Error: Timeout must be greater than zero', err=True) 

788 raise typer.Exit(1) 

789 

790 

791def _execute_dry_run_materialize(project_root: Path) -> None: 

792 """Execute a dry run preview of placeholder materialization.""" 

793 binder_repo = BinderRepoFs(project_root) 

794 binder = binder_repo.load() 

795 placeholders = [item for item in binder.depth_first_traversal() if item.is_placeholder()] 

796 

797 if not placeholders: 

798 typer.echo('No placeholders found in binder') 

799 return 

800 

801 typer.echo(f'Would materialize {len(placeholders)} placeholders:') 

802 for placeholder in placeholders: 

803 typer.echo(f' - "{placeholder.display_title}"') 

804 

805 

806def _execute_materialize_all( 

807 project_root: Path, 

808 *, 

809 continue_on_error: bool = False, 

810) -> None: 

811 """Execute materialization of all placeholders.""" 

812 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root) 

813 

814 _materialize_all_placeholders( 

815 project_root, 

816 binder_repo, 

817 node_repo, 

818 id_generator, 

819 clock, 

820 logger, 

821 json_output=False, 

822 continue_on_error=continue_on_error, 

823 ) 

824 

825 

826@app.command(name='materialize-all') 

827def materialize_all( 

828 *, 

829 dry_run: Annotated[ 

830 bool, typer.Option('--dry-run', help='Preview what would be materialized without making changes') 

831 ] = False, 

832 force: Annotated[bool, typer.Option('--force', help='Force materialization even with warnings')] = False, 

833 verbose: Annotated[bool, typer.Option('--verbose', help='Show detailed progress information')] = False, 

834 quiet: Annotated[bool, typer.Option('--quiet', help='Suppress non-essential output')] = False, 

835 continue_on_error: Annotated[ 

836 bool, typer.Option('--continue-on-error', help='Continue processing after errors') 

837 ] = False, 

838 batch_size: Annotated[ 

839 int, typer.Option('--batch-size', help='Number of placeholders to process in each batch') 

840 ] = 10, 

841 timeout: Annotated[int, typer.Option('--timeout', help='Timeout in seconds for each materialization')] = 60, 

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

843) -> None: 

844 """Materialize all placeholders in the binder.""" 

845 _validate_materialize_all_options( 

846 dry_run=dry_run, 

847 force=force, 

848 verbose=verbose, 

849 quiet=quiet, 

850 continue_on_error=continue_on_error, 

851 batch_size=batch_size, 

852 timeout=timeout, 

853 ) 

854 

855 try: 

856 project_root = path or _get_project_root() 

857 

858 # Validate that the path exists and is a directory 

859 if not project_root.exists(): 

860 typer.echo('Error: Path does not exist', err=True) 

861 raise typer.Exit(1) 

862 

863 if not project_root.is_dir(): 

864 typer.echo('Error: Path is not a directory', err=True) 

865 raise typer.Exit(1) 

866 

867 if dry_run: 

868 _execute_dry_run_materialize(project_root) 

869 else: 

870 _execute_materialize_all(project_root, continue_on_error=continue_on_error) 

871 

872 except PlaceholderNotFoundError: 

873 typer.echo('Error: No placeholders found', err=True) 

874 raise typer.Exit(1) from None 

875 except RuntimeError as e: 

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

877 raise typer.Exit(1) from None 

878 except TimeoutError as e: 

879 typer.echo(f'Error: Operation timed out - {e}', err=True) 

880 raise typer.Exit(1) from None 

881 except BinderFormatError as e: 

882 typer.echo(f'Error: Malformed binder structure - {e}', err=True) 

883 raise typer.Exit(1) from None 

884 except BinderNotFoundError: 

885 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True) 

886 raise typer.Exit(1) from None 

887 except FileSystemError: 

888 typer.echo('Error: File creation failed', err=True) 

889 raise typer.Exit(2) from None 

890 

891 

892@app.command() 

893def audit( # noqa: C901, PLR0912 

894 *, 

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

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

897) -> None: 

898 """Check project integrity.""" 

899 try: 

900 project_root = path or _get_project_root() 

901 

902 # Wire up dependencies 

903 binder_repo = BinderRepoFs(project_root) 

904 clock = ClockSystem() 

905 editor_port = EditorLauncherSystem() 

906 node_repo = NodeRepoFs(project_root, editor_port, clock) 

907 logger = LoggerStdout() 

908 

909 # Execute use case 

910 interactor = AuditBinder( 

911 binder_repo=binder_repo, 

912 node_repo=node_repo, 

913 logger=logger, 

914 ) 

915 

916 report = interactor.execute() 

917 

918 # Always report placeholders if they exist (informational) 

919 if report.placeholders: 

920 for placeholder in report.placeholders: 

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

922 

923 # Report actual issues if they exist 

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

925 if has_real_issues: 

926 if report.placeholders: 

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

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

929 

930 if report.missing: 

931 for missing in report.missing: 

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

933 

934 if report.orphans: 

935 for orphan in report.orphans: 

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

937 

938 if report.mismatches: 

939 for mismatch in report.mismatches: 

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

941 else: 

942 # Show success messages for real issues when none exist 

943 if report.placeholders: 

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

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

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

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

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

949 

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

951 if has_real_issues: 

952 if fix: 

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

954 raise typer.Exit(2) 

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

956 raise typer.Exit(1) 

957 

958 except FileSystemError as e: 

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

960 raise typer.Exit(2) from e 

961 

962 

963def main() -> None: 

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

965 app() 

966 

967 

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

969 main()