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

471 statements  

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

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 

788def _validate_materialize_all_options( 

789 *, 

790 dry_run: bool, 

791 force: bool, 

792 verbose: bool, 

793 quiet: bool, 

794 continue_on_error: bool, 

795 batch_size: int, 

796 timeout: int, 

797) -> None: 

798 """Validate options for materialize_all command.""" 

799 # Validate mutually exclusive options 

800 if dry_run and force: 

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

802 raise typer.Exit(1) 

803 

804 if verbose and quiet: 

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

806 raise typer.Exit(1) 

807 

808 if dry_run and continue_on_error: 

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

810 raise typer.Exit(1) 

811 

812 # Validate batch size 

813 if batch_size <= 0: 

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

815 raise typer.Exit(1) 

816 

817 # Validate timeout 

818 if timeout <= 0: 

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

820 raise typer.Exit(1) 

821 

822 

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

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

825 binder_repo = BinderRepoFs(project_root) 

826 binder = binder_repo.load() 

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

828 

829 if not placeholders: 

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

831 return 

832 

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

834 for placeholder in placeholders: 

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

836 

837 

838def _execute_materialize_all( 

839 project_root: Path, 

840 *, 

841 continue_on_error: bool = False, 

842) -> None: 

843 """Execute materialization of all placeholders.""" 

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

845 

846 _materialize_all_placeholders( 

847 project_root, 

848 binder_repo, 

849 node_repo, 

850 id_generator, 

851 clock, 

852 logger, 

853 json_output=False, 

854 continue_on_error=continue_on_error, 

855 ) 

856 

857 

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

859def materialize_all( 

860 *, 

861 dry_run: Annotated[ 

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

863 ] = False, 

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

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

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

867 continue_on_error: Annotated[ 

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

869 ] = False, 

870 batch_size: Annotated[ 

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

872 ] = 10, 

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

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

875) -> None: 

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

877 _validate_materialize_all_options( 

878 dry_run=dry_run, 

879 force=force, 

880 verbose=verbose, 

881 quiet=quiet, 

882 continue_on_error=continue_on_error, 

883 batch_size=batch_size, 

884 timeout=timeout, 

885 ) 

886 

887 try: 

888 project_root = path or _get_project_root() 

889 

890 # Validate that the path exists and is a directory 

891 if not project_root.exists(): 

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

893 raise typer.Exit(1) 

894 

895 if not project_root.is_dir(): 

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

897 raise typer.Exit(1) 

898 

899 if dry_run: 

900 _execute_dry_run_materialize(project_root) 

901 else: 

902 _execute_materialize_all(project_root, continue_on_error=continue_on_error) 

903 

904 except PlaceholderNotFoundError: 

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

906 raise typer.Exit(1) from None 

907 except RuntimeError as e: 

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

909 raise typer.Exit(1) from None 

910 except TimeoutError as e: 

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

912 raise typer.Exit(1) from None 

913 except BinderFormatError as e: 

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

915 raise typer.Exit(1) from None 

916 except BinderNotFoundError: 

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

918 raise typer.Exit(1) from None 

919 except FileSystemError: 

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

921 raise typer.Exit(2) from None 

922 

923 

924@app.command() 

925def audit( # noqa: C901, PLR0912 

926 *, 

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

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

929) -> None: 

930 """Check project integrity.""" 

931 try: 

932 project_root = path or _get_project_root() 

933 

934 # Wire up dependencies 

935 binder_repo = BinderRepoFs(project_root) 

936 clock = ClockSystem() 

937 editor_port = EditorLauncherSystem() 

938 node_repo = NodeRepoFs(project_root, editor_port, clock) 

939 logger = LoggerStdout() 

940 

941 # Execute use case 

942 interactor = AuditBinder( 

943 binder_repo=binder_repo, 

944 node_repo=node_repo, 

945 logger=logger, 

946 ) 

947 

948 report = interactor.execute() 

949 

950 # Always report placeholders if they exist (informational) 

951 if report.placeholders: 

952 for placeholder in report.placeholders: 

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

954 

955 # Report actual issues if they exist 

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

957 if has_real_issues: 

958 if report.placeholders: 

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

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

961 

962 if report.missing: 

963 for missing in report.missing: 

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

965 

966 if report.orphans: 

967 for orphan in report.orphans: 

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

969 

970 if report.mismatches: 

971 for mismatch in report.mismatches: 

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

973 else: 

974 # Show success messages for real issues when none exist 

975 if report.placeholders: 

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

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

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

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

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

981 

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

983 if has_real_issues: 

984 if fix: 

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

986 raise typer.Exit(2) 

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

988 raise typer.Exit(1) 

989 

990 except FileSystemError as e: 

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

992 raise typer.Exit(2) from e 

993 

994 

995def main() -> None: 

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

997 app() 

998 

999 

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

1001 main() 

1002 

1003 

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

1005def compile_cmd( 

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

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

1008) -> None: 

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

1010 from prosemark.cli.compile import compile_command 

1011 

1012 compile_command(node_id, path)