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

462 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-23 21:54 +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.daily_repo_fs import DailyRepoFs 

21from prosemark.adapters.editor_launcher_system import EditorLauncherSystem 

22from prosemark.adapters.id_generator_uuid7 import IdGeneratorUuid7 

23from prosemark.adapters.logger_stdout import LoggerStdout 

24from prosemark.adapters.node_repo_fs import NodeRepoFs 

25 

26# Use case imports 

27from prosemark.app.materialize_all_placeholders import MaterializeAllPlaceholders 

28from prosemark.app.materialize_node import MaterializeNode 

29from prosemark.app.use_cases import ( 

30 AddNode, 

31 AuditBinder, 

32 EditPart, 

33 InitProject, 

34 MoveNode, 

35 RemoveNode, 

36 ShowStructure, 

37 WriteFreeform, 

38) 

39from prosemark.app.use_cases import MaterializeNode as MaterializeNodeUseCase 

40from prosemark.domain.batch_materialize_result import BatchMaterializeResult 

41 

42# Domain model imports 

43from prosemark.domain.binder import Item 

44from prosemark.domain.models import BinderItem, NodeId 

45 

46# Exception imports 

47from prosemark.exceptions import ( 

48 AlreadyMaterializedError, 

49 BinderFormatError, 

50 BinderIntegrityError, 

51 BinderNotFoundError, 

52 EditorLaunchError, 

53 FileSystemError, 

54 NodeNotFoundError, 

55 PlaceholderNotFoundError, 

56) 

57 

58# Port imports 

59from prosemark.ports.config_port import ConfigPort, ProsemarkConfig 

60 

61 

62# Protocol definitions 

63class MaterializationResult(Protocol): 

64 """Protocol for materialization process result objects. 

65 

66 Defines the expected interface for results of materialization operations, 

67 capturing details about the process such as placeholders materialized, 

68 failures encountered, and execution metadata. 

69 """ 

70 

71 total_placeholders: int 

72 successful_materializations: list[Any] 

73 failed_materializations: list[Any] 

74 has_failures: bool 

75 type: str | None 

76 is_complete_success: bool 

77 execution_time: float 

78 message: str | None 

79 summary_message: Callable[[], str] 

80 

81 

82app = typer.Typer( 

83 name='pmk', 

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

85 add_completion=False, 

86) 

87 

88# Alias for backward compatibility with tests 

89cli = app 

90 

91 

92class FileSystemConfigPort(ConfigPort): 

93 """Temporary config port implementation.""" 

94 

95 def create_default_config(self, config_path: Path) -> None: 

96 """Create default configuration file.""" 

97 # For MVP, we don't need a config file 

98 

99 @staticmethod 

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

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

102 return config_path.exists() 

103 

104 @staticmethod 

105 def get_default_config_values() -> ProsemarkConfig: 

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

107 return {} 

108 

109 @staticmethod 

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

111 """Load configuration from file.""" 

112 return {} 

113 

114 

115def _get_project_root() -> Path: 

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

117 return Path.cwd() 

118 

119 

120@app.command() 

121def init( 

122 title: Annotated[str, typer.Option('--title', '-t', help='Project title')], 

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

124) -> None: 

125 """Initialize a new prosemark project.""" 

126 try: 

127 project_path = path or Path.cwd() 

128 

129 # Wire up dependencies 

130 binder_repo = BinderRepoFs(project_path) 

131 config_port = FileSystemConfigPort() 

132 console_port = ConsolePretty() 

133 logger = LoggerStdout() 

134 clock = ClockSystem() 

135 

136 # Execute use case 

137 interactor = InitProject( 

138 binder_repo=binder_repo, 

139 config_port=config_port, 

140 console_port=console_port, 

141 logger=logger, 

142 clock=clock, 

143 ) 

144 interactor.execute(project_path) 

145 

146 # Success output matching test expectations 

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

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

149 

150 except BinderIntegrityError: 

151 typer.echo('Error: Directory already contains a prosemark project', err=True) 

152 raise typer.Exit(1) from None 

153 except FileSystemError as e: 

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

155 raise typer.Exit(2) from e 

156 except Exception as e: 

157 typer.echo(f'Unexpected error: {e}', err=True) 

158 raise typer.Exit(3) from e 

159 

160 

161@app.command() 

162def add( 

163 title: Annotated[str, typer.Argument(help='Display title for the new node')], 

164 parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID')] = None, 

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

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

167) -> None: 

168 """Add a new node to the binder hierarchy.""" 

169 try: 

170 project_root = path or _get_project_root() 

171 

172 # Wire up dependencies 

173 binder_repo = BinderRepoFs(project_root) 

174 clock = ClockSystem() 

175 editor_port = EditorLauncherSystem() 

176 node_repo = NodeRepoFs(project_root, editor_port, clock) 

177 id_generator = IdGeneratorUuid7() 

178 logger = LoggerStdout() 

179 

180 # Execute use case 

181 interactor = AddNode( 

182 binder_repo=binder_repo, 

183 node_repo=node_repo, 

184 id_generator=id_generator, 

185 logger=logger, 

186 clock=clock, 

187 ) 

188 

189 parent_id = NodeId(parent) if parent else None 

190 node_id = interactor.execute( 

191 title=title, 

192 synopsis=None, 

193 parent_id=parent_id, 

194 position=position, 

195 ) 

196 

197 # Success output 

198 typer.echo(f'Added "{title}" ({node_id})') 

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

200 typer.echo('Updated binder structure') 

201 

202 except NodeNotFoundError: 

203 typer.echo('Error: Parent node not found', err=True) 

204 raise typer.Exit(1) from None 

205 except ValueError: 

206 typer.echo('Error: Invalid position index', err=True) 

207 raise typer.Exit(2) from None 

208 except FileSystemError as e: 

209 typer.echo(f'Error: File creation failed - {e}', err=True) 

210 raise typer.Exit(3) from e 

211 

212 

213@app.command() 

214def edit( 

215 node_id: Annotated[str, typer.Argument(help='Node identifier')], 

216 part: Annotated[str, typer.Option('--part', help='Content part to edit')] = 'draft', 

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

218) -> None: 

219 """Open node content in your preferred editor.""" 

220 try: 

221 project_root = path or _get_project_root() 

222 

223 # Wire up dependencies 

224 binder_repo = BinderRepoFs(project_root) 

225 clock = ClockSystem() 

226 editor_port = EditorLauncherSystem() 

227 node_repo = NodeRepoFs(project_root, editor_port, clock) 

228 logger = LoggerStdout() 

229 

230 # Execute use case 

231 interactor = EditPart( 

232 binder_repo=binder_repo, 

233 node_repo=node_repo, 

234 logger=logger, 

235 ) 

236 

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

238 

239 # Success output 

240 if part == 'draft': 

241 typer.echo(f'Opened {node_id}.md in editor') 

242 elif part == 'notes': 

243 typer.echo(f'Opened {node_id}.notes.md in editor') 

244 else: 

245 typer.echo(f'Opened {part} for {node_id} in editor') 

246 

247 except NodeNotFoundError: 

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

249 raise typer.Exit(1) from None 

250 except EditorLaunchError: 

251 typer.echo('Error: Editor not available', err=True) 

252 raise typer.Exit(2) from None 

253 except FileSystemError: 

254 typer.echo('Error: File permission denied', err=True) 

255 raise typer.Exit(3) from None 

256 except ValueError as e: 

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

258 raise typer.Exit(1) from e 

259 

260 

261@app.command() 

262def structure( 

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

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

265) -> None: 

266 """Display project hierarchy.""" 

267 try: 

268 project_root = path or _get_project_root() 

269 

270 # Wire up dependencies 

271 binder_repo = BinderRepoFs(project_root) 

272 logger = LoggerStdout() 

273 

274 # Execute use case 

275 interactor = ShowStructure( 

276 binder_repo=binder_repo, 

277 logger=logger, 

278 ) 

279 

280 structure_str = interactor.execute() 

281 

282 if output_format == 'tree': 

283 typer.echo('Project Structure:') 

284 typer.echo(structure_str) 

285 elif output_format == 'json': 

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

287 # This is a simplified version for MVP 

288 import json 

289 

290 binder = binder_repo.load() 

291 

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

293 result: dict[str, Any] = { 

294 'display_title': item.display_title, 

295 } 

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

297 if node_id: 

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

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

300 if item_children: 

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

302 return result 

303 

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

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

306 else: 

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

308 raise typer.Exit(1) 

309 

310 except FileSystemError as e: 

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

312 raise typer.Exit(1) from e 

313 

314 

315@app.command() 

316def write( 

317 title: Annotated[str | None, typer.Argument(help='Optional title for freeform content')] = None, 

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

319) -> None: 

320 """Create a timestamped freeform writing file.""" 

321 try: 

322 project_root = path or _get_project_root() 

323 

324 # Wire up dependencies 

325 clock = ClockSystem() 

326 id_generator = IdGeneratorUuid7() 

327 daily_repo = DailyRepoFs(project_root, id_generator=id_generator, clock=clock) 

328 editor_port = EditorLauncherSystem() 

329 logger = LoggerStdout() 

330 

331 # Execute use case 

332 interactor = WriteFreeform( 

333 daily_repo=daily_repo, 

334 editor_port=editor_port, 

335 logger=logger, 

336 clock=clock, 

337 ) 

338 

339 filename = interactor.execute(title) 

340 

341 # Success output 

342 typer.echo(f'Created freeform file: {filename}') 

343 typer.echo('Opened in editor') 

344 

345 except FileSystemError: 

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

347 raise typer.Exit(1) from None 

348 except EditorLaunchError: 

349 typer.echo('Error: Editor launch failed', err=True) 

350 raise typer.Exit(2) from None 

351 

352 

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

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

355 if title and all_placeholders: 

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

357 raise typer.Exit(1) from None 

358 

359 if not title and not all_placeholders: 

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

361 raise typer.Exit(1) from None 

362 

363 

364def _create_shared_dependencies( 

365 project_root: Path, 

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

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

368 binder_repo = BinderRepoFs(project_root) 

369 clock = ClockSystem() 

370 editor_port = EditorLauncherSystem() 

371 node_repo = NodeRepoFs(project_root, editor_port, clock) 

372 id_generator = IdGeneratorUuid7() 

373 logger = LoggerStdout() 

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

375 

376 

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

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

379 json_result: dict[str, Any] = { 

380 'type': output_type, 

381 'total_placeholders': result.total_placeholders, 

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

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

384 'execution_time': result.execution_time, 

385 } 

386 

387 # Add overall message 

388 if result.total_placeholders == 0: 

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

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

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

392 else: 

393 success_count = len(result.successful_materializations) 

394 failure_count = len(result.failed_materializations) 

395 json_result['message'] = ( 

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

397 ) 

398 

399 # Add results based on type 

400 if output_type == 'batch_partial': 

401 json_result['successes'] = [ 

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

403 for success in result.successful_materializations 

404 ] 

405 json_result['failures'] = [ 

406 { 

407 'placeholder_title': failure.display_title, 

408 'error_type': failure.error_type, 

409 'error_message': failure.error_message, 

410 } 

411 for failure in result.failed_materializations 

412 ] 

413 elif result.successful_materializations or result.failed_materializations: 

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

415 details_list.extend( 

416 { 

417 'placeholder_title': success.display_title, 

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

419 'status': 'success', 

420 } 

421 for success in result.successful_materializations 

422 ) 

423 

424 details_list.extend( 

425 { 

426 'placeholder_title': failure.display_title, 

427 'error_type': failure.error_type, 

428 'error_message': failure.error_message, 

429 'status': 'failed', 

430 } 

431 for failure in result.failed_materializations 

432 ) 

433 

434 json_result['details'] = details_list 

435 

436 return json_result 

437 

438 

439def _check_result_failure_status( 

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

441) -> None: 

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

443 has_failures = len(result.failed_materializations) > 0 

444 

445 if has_failures: 

446 if not continue_on_error: 

447 raise typer.Exit(1) from None 

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

449 raise typer.Exit(1) from None 

450 

451 

452def _report_materialization_progress( 

453 result: MaterializationResult | BatchMaterializeResult, 

454 *, 

455 json_output: bool = False, 

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

457) -> None: 

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

459 progress_messages = progress_messages or [] 

460 if not json_output and not progress_messages: 

461 if result.total_placeholders == 0: 

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

463 else: 

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

465 for success in result.successful_materializations: 

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

467 for failure in result.failed_materializations: 

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

469 typer.echo(failure.error_message) 

470 

471 

472def _materialize_all_placeholders( 

473 project_root: Path, 

474 binder_repo: BinderRepoFs, 

475 node_repo: NodeRepoFs, 

476 id_generator: IdGeneratorUuid7, 

477 clock: ClockSystem, 

478 logger: LoggerStdout, 

479 *, 

480 json_output: bool = False, 

481 continue_on_error: bool = False, 

482) -> None: 

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

484 console = ConsolePretty() 

485 

486 # Create individual materialize use case for delegation 

487 materialize_node_use_case = MaterializeNode( 

488 binder_repo=binder_repo, 

489 node_repo=node_repo, 

490 id_generator=id_generator, 

491 clock=clock, 

492 console=console, 

493 logger=logger, 

494 ) 

495 

496 # Create batch use case 

497 batch_interactor = MaterializeAllPlaceholders( 

498 materialize_node_use_case=materialize_node_use_case, 

499 binder_repo=binder_repo, 

500 node_repo=node_repo, 

501 id_generator=id_generator, 

502 clock=clock, 

503 logger=logger, 

504 ) 

505 

506 # Execute with progress callback and track messages 

507 progress_messages: list[str] = [] 

508 

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

510 progress_messages.append(message) 

511 typer.echo(message) 

512 

513 result = batch_interactor.execute( 

514 project_path=project_root, 

515 progress_callback=progress_callback, 

516 ) 

517 

518 # Report progress messages 

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

520 

521 # Report final results 

522 if json_output: 

523 import json 

524 

525 # Determine type based on results 

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

527 json_result = _generate_json_result(result, output_type) 

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

529 

530 # Check for specific interruption types 

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

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

533 raise typer.Exit(1) from None 

534 

535 # Handle failures 

536 _check_result_failure_status(result, continue_on_error=continue_on_error) 

537 else: 

538 if result.total_placeholders == 0: 

539 return 

540 

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

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

543 raise typer.Exit(1) from None 

544 

545 success_count = len(result.successful_materializations) 

546 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error) 

547 

548 

549def _describe_materialization_result( 

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

551) -> None: 

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

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

554 

555 if is_complete_success: 

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

557 else: 

558 # Retrieve or generate summary message 

559 summary_msg = _get_summary_message(result, success_count) 

560 typer.echo(summary_msg) 

561 

562 # Check for interrupted operations 

563 _check_result_failure_status(result, continue_on_error=continue_on_error) 

564 

565 

566def _get_safe_attribute( 

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

568) -> str: 

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

570 try: 

571 value = getattr(result, attr_name, default) 

572 if callable(value): 

573 value_result = value() 

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

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

576 except (TypeError, ValueError, AttributeError): 

577 return default 

578 

579 

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

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

582 # First try standard message retrieval methods 

583 summary_msg = _get_safe_attribute(result, 'message') 

584 if not summary_msg: 

585 summary_msg = _get_safe_attribute(result, 'summary_message') 

586 

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

588 if not summary_msg: 

589 failure_count = len(result.failed_materializations) 

590 if failure_count == 1: 

591 summary_msg = ( 

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

593 ) 

594 else: 

595 summary_msg = ( 

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

597 ) 

598 

599 return summary_msg 

600 

601 

602def _materialize_single_placeholder( 

603 title: str, 

604 binder_repo: BinderRepoFs, 

605 node_repo: NodeRepoFs, 

606 id_generator: IdGeneratorUuid7, 

607 logger: LoggerStdout, 

608) -> None: 

609 """Execute single materialization.""" 

610 interactor = MaterializeNodeUseCase( 

611 binder_repo=binder_repo, 

612 node_repo=node_repo, 

613 id_generator=id_generator, 

614 logger=logger, 

615 ) 

616 

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

618 

619 # Success output 

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

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

622 typer.echo('Updated binder structure') 

623 

624 

625@app.command() 

626def materialize( 

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

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

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

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

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

632) -> None: 

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

634 try: 

635 _validate_materialize_args(title, all_placeholders=all_placeholders) 

636 project_root = path or _get_project_root() 

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

638 

639 if all_placeholders: 

640 _materialize_all_placeholders( 

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

642 ) 

643 else: 

644 # Ensure title is not None for type safety 

645 if title is None: 

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

647 raise typer.Exit(1) from None 

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

649 

650 except PlaceholderNotFoundError: 

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

652 raise typer.Exit(1) from None 

653 except AlreadyMaterializedError: 

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

655 raise typer.Exit(1) from None 

656 except BinderFormatError as e: 

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

658 raise typer.Exit(1) from None 

659 except BinderNotFoundError: 

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

661 raise typer.Exit(1) from None 

662 except FileSystemError: 

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

664 raise typer.Exit(2) from None 

665 

666 

667@app.command() 

668def move( 

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

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

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

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

673) -> None: 

674 """Reorganize binder hierarchy.""" 

675 try: 

676 project_root = path or _get_project_root() 

677 

678 # Wire up dependencies 

679 binder_repo = BinderRepoFs(project_root) 

680 logger = LoggerStdout() 

681 

682 # Execute use case 

683 interactor = MoveNode( 

684 binder_repo=binder_repo, 

685 logger=logger, 

686 ) 

687 

688 parent_id = NodeId(parent) if parent else None 

689 interactor.execute( 

690 node_id=NodeId(node_id), 

691 parent_id=parent_id, 

692 position=position, 

693 ) 

694 

695 # Success output 

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

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

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

699 typer.echo('Updated binder structure') 

700 

701 except NodeNotFoundError as e: 

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

703 raise typer.Exit(1) from e 

704 except ValueError: 

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

706 raise typer.Exit(2) from None 

707 except BinderIntegrityError: 

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

709 raise typer.Exit(3) from None 

710 

711 

712@app.command() 

713def remove( 

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

715 *, 

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

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

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

719) -> None: 

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

721 try: 

722 project_root = path or _get_project_root() 

723 

724 # Confirmation prompt if not forced 

725 if not force and delete_files: 

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

727 if not confirm: 

728 typer.echo('Operation cancelled') 

729 raise typer.Exit(2) 

730 

731 # Wire up dependencies 

732 binder_repo = BinderRepoFs(project_root) 

733 clock = ClockSystem() 

734 editor_port = EditorLauncherSystem() 

735 node_repo = NodeRepoFs(project_root, editor_port, clock) 

736 logger = LoggerStdout() 

737 

738 # Execute use case 

739 interactor = RemoveNode( 

740 binder_repo=binder_repo, 

741 node_repo=node_repo, 

742 logger=logger, 

743 ) 

744 

745 # Get node title for output 

746 binder = binder_repo.load() 

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

748 title = target_item.display_title if target_item else node_id 

749 

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

751 

752 # Success output 

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

754 if delete_files: 

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

756 else: 

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

758 

759 except NodeNotFoundError: 

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

761 raise typer.Exit(1) from None 

762 except FileSystemError: 

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

764 raise typer.Exit(3) from None 

765 

766 

767def _validate_materialize_all_options( 

768 *, 

769 dry_run: bool, 

770 force: bool, 

771 verbose: bool, 

772 quiet: bool, 

773 continue_on_error: bool, 

774 batch_size: int, 

775 timeout: int, 

776) -> None: 

777 """Validate options for materialize_all command.""" 

778 # Validate mutually exclusive options 

779 if dry_run and force: 

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

781 raise typer.Exit(1) 

782 

783 if verbose and quiet: 

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

785 raise typer.Exit(1) 

786 

787 if dry_run and continue_on_error: 

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

789 raise typer.Exit(1) 

790 

791 # Validate batch size 

792 if batch_size <= 0: 

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

794 raise typer.Exit(1) 

795 

796 # Validate timeout 

797 if timeout <= 0: 

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

799 raise typer.Exit(1) 

800 

801 

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

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

804 binder_repo = BinderRepoFs(project_root) 

805 binder = binder_repo.load() 

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

807 

808 if not placeholders: 

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

810 return 

811 

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

813 for placeholder in placeholders: 

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

815 

816 

817def _execute_materialize_all( 

818 project_root: Path, 

819 *, 

820 continue_on_error: bool = False, 

821) -> None: 

822 """Execute materialization of all placeholders.""" 

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

824 

825 _materialize_all_placeholders( 

826 project_root, 

827 binder_repo, 

828 node_repo, 

829 id_generator, 

830 clock, 

831 logger, 

832 json_output=False, 

833 continue_on_error=continue_on_error, 

834 ) 

835 

836 

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

838def materialize_all( 

839 *, 

840 dry_run: Annotated[ 

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

842 ] = False, 

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

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

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

846 continue_on_error: Annotated[ 

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

848 ] = False, 

849 batch_size: Annotated[ 

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

851 ] = 10, 

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

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

854) -> None: 

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

856 _validate_materialize_all_options( 

857 dry_run=dry_run, 

858 force=force, 

859 verbose=verbose, 

860 quiet=quiet, 

861 continue_on_error=continue_on_error, 

862 batch_size=batch_size, 

863 timeout=timeout, 

864 ) 

865 

866 try: 

867 project_root = path or _get_project_root() 

868 

869 # Validate that the path exists and is a directory 

870 if not project_root.exists(): 

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

872 raise typer.Exit(1) 

873 

874 if not project_root.is_dir(): 

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

876 raise typer.Exit(1) 

877 

878 if dry_run: 

879 _execute_dry_run_materialize(project_root) 

880 else: 

881 _execute_materialize_all(project_root, continue_on_error=continue_on_error) 

882 

883 except PlaceholderNotFoundError: 

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

885 raise typer.Exit(1) from None 

886 except RuntimeError as e: 

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

888 raise typer.Exit(1) from None 

889 except TimeoutError as e: 

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

891 raise typer.Exit(1) from None 

892 except BinderFormatError as e: 

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

894 raise typer.Exit(1) from None 

895 except BinderNotFoundError: 

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

897 raise typer.Exit(1) from None 

898 except FileSystemError: 

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

900 raise typer.Exit(2) from None 

901 

902 

903@app.command() 

904def audit( # noqa: C901, PLR0912 

905 *, 

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

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

908) -> None: 

909 """Check project integrity.""" 

910 try: 

911 project_root = path or _get_project_root() 

912 

913 # Wire up dependencies 

914 binder_repo = BinderRepoFs(project_root) 

915 clock = ClockSystem() 

916 editor_port = EditorLauncherSystem() 

917 node_repo = NodeRepoFs(project_root, editor_port, clock) 

918 logger = LoggerStdout() 

919 

920 # Execute use case 

921 interactor = AuditBinder( 

922 binder_repo=binder_repo, 

923 node_repo=node_repo, 

924 logger=logger, 

925 ) 

926 

927 report = interactor.execute() 

928 

929 # Always report placeholders if they exist (informational) 

930 if report.placeholders: 

931 for placeholder in report.placeholders: 

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

933 

934 # Report actual issues if they exist 

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

936 if has_real_issues: 

937 if report.placeholders: 

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

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

940 

941 if report.missing: 

942 for missing in report.missing: 

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

944 

945 if report.orphans: 

946 for orphan in report.orphans: 

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

948 

949 if report.mismatches: 

950 for mismatch in report.mismatches: 

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

952 else: 

953 # Show success messages for real issues when none exist 

954 if report.placeholders: 

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

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

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

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

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

960 

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

962 if has_real_issues: 

963 if fix: 

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

965 raise typer.Exit(2) 

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

967 raise typer.Exit(1) 

968 

969 except FileSystemError as e: 

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

971 raise typer.Exit(2) from e 

972 

973 

974def main() -> None: 

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

976 app() 

977 

978 

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

980 main()