Coverage for sources/agentsmgr/population.py: 75%

224 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 06:57 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Command for populating agent content from data sources. 

22 

23 Also provides the generate command for maintainer-facing generation 

24 from components/ to distribution/. 

25''' 

26 

27 

28from . import __ 

29from . import cmdbase as _cmdbase 

30from . import core as _core 

31from . import exceptions as _exceptions 

32from . import generator as _generator 

33from . import memorylinks as _memorylinks 

34from . import operations as _operations 

35from . import renderers as _renderers 

36from . import resolver as _resolver 

37from . import results as _results 

38from . import userdata as _userdata 

39 

40 

41_scribe = __.provide_scribe( __name__ ) 

42 

43 

44def _produce_default_configuration( 

45 location: __.Path, 

46) -> __.cabc.Mapping[ str, __.typx.Any ]: 

47 ''' Produces default configuration for generate command. 

48 

49 Uses all known coders from the renderer registry so that 

50 fallback content is generated for all coders, not just those 

51 with direct component content. 

52 ''' 

53 from . import renderers as _renderers 

54 coders = sorted( _renderers.RENDERERS.keys( ) ) 

55 return { 'coders': coders, 'languages': [ 'python' ] } 

56 

57 

58SourceArgument: __.typx.TypeAlias = __.typx.Annotated[ 

59 __.tyro.conf.Positional[ str ], 

60 __.tyro.conf.arg( help = "Data source (local path or git URL)" ), 

61] 

62TargetArgument: __.typx.TypeAlias = __.typx.Annotated[ 

63 __.tyro.conf.Positional[ __.Path ], 

64 __.tyro.conf.arg( help = "Target directory for content generation" ), 

65] 

66 

67 

68def _filter_coders_by_mode( 

69 coders: __.cabc.Sequence[ str ], 

70 target_mode: _renderers.ExplicitTargetMode, 

71) -> tuple[ str, ... ]: 

72 ''' Filters coders by their default targeting mode. 

73 

74 Returns coders whose mode_default matches the target mode. 

75 This ensures populate project only handles per-project coders 

76 and populate user only handles per-user coders, respecting each 

77 renderer's designed usage pattern. 

78 ''' 

79 return tuple( 

80 name 

81 for name, _renderer in _resolver.resolve_coders( 

82 coders, mode = target_mode ) 

83 ) 

84 

85 

86def _format_exclude_path( path: __.Path ) -> str: 

87 ''' Formats a project-relative path for git exclude syntax. ''' 

88 return path.as_posix( ) 

89 

90 

91def _create_all_symlinks( 

92 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

93 target: __.Path, 

94 mode: str, 

95 simulate: bool, 

96) -> tuple[ str, ... ]: 

97 ''' Creates all symlinks and returns their names for git exclude. 

98 

99 Creates memory symlinks for all coders and coder directory 

100 symlinks for per-project mode. Returns list of all symlink 

101 names (both newly created and pre-existing) for git exclude 

102 update. 

103 ''' 

104 all_symlink_names: list[ str ] = [ ] 

105 if mode == 'nowhere': return tuple( all_symlink_names ) 105 ↛ exitline 105 didn't return from function '_create_all_symlinks' because the return on line 105 wasn't executed

106 links_attempted, links_created, symlink_names_memory = ( 

107 _memorylinks.create_memory_symlinks_for_coders( 

108 coders = configuration[ 'coders' ], 

109 target = target, 

110 simulate = simulate, 

111 ) ) 

112 all_symlink_names.extend( symlink_names_memory ) 

113 if links_created > 0: 

114 _scribe.info( 

115 f"Created {links_created}/{links_attempted} memory symlinks" ) 

116 needs_coder_symlinks = ( 

117 mode == 'per-project' 

118 or ( mode == 'default' and any( 

119 coder in _renderers.RENDERERS 

120 and _renderers.RENDERERS[ coder ].mode_default == 'per-project' 

121 for coder in configuration[ 'coders' ] ) ) ) 

122 if needs_coder_symlinks: 122 ↛ 136line 122 didn't jump to line 136 because the condition on line 122 was always true

123 ( coder_symlinks_attempted, 

124 coder_symlinks_created, 

125 coder_symlink_names ) = ( 

126 _create_coder_directory_symlinks( 

127 coders = configuration[ 'coders' ], 

128 target = target, 

129 simulate = simulate, 

130 ) ) 

131 all_symlink_names.extend( coder_symlink_names ) 

132 if coder_symlinks_created > 0: 

133 _scribe.info( 

134 f"Created {coder_symlinks_created}/" 

135 f"{coder_symlinks_attempted} coder directory symlinks" ) 

136 openspec_link_path = target / 'openspec' 

137 openspec_source_path = ( 

138 target / 'documentation' / 'architecture' / 'openspec' ) 

139 if not simulate: 139 ↛ 141line 139 didn't jump to line 141 because the condition on line 139 was always true

140 openspec_source_path.mkdir( parents = True, exist_ok = True ) 

141 _, symlink_name_openspec = _memorylinks.create_memory_symlink( 

142 openspec_source_path, openspec_link_path, simulate ) 

143 all_symlink_names.append( symlink_name_openspec ) 

144 _scribe.info( "Created 1/1 openspec symlink" ) 

145 return tuple( all_symlink_names ) 

146 

147 

148def _copy_instructions_from_distribution( 

149 distribution: __.Path, 

150 target: __.Path, 

151 instructions_target: str, 

152 simulate: bool, 

153) -> tuple[ int, int, tuple[ str, ... ] ]: 

154 ''' Copies instruction files from distribution/ to target. 

155 

156 Reads from distribution/per-project/general/instructions/ and 

157 copies to the configured instructions target path. 

158 Returns tuple of (files_attempted, files_written, exclude_entries). 

159 ''' 

160 import contextlib as _contextlib 

161 source_dir = distribution / 'per-project' / 'general' / 'instructions' 

162 if not source_dir.exists( ): 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 return ( 0, 0, ( ) ) 

164 target_dir = target / instructions_target 

165 files_attempted = 0 

166 files_written = 0 

167 exclude_entries: list[ str ] = [ ] 

168 for source_file in source_dir.glob( '*' ): 

169 if not source_file.is_file( ): continue 169 ↛ 168line 169 didn't jump to line 168 because the continue on line 169 wasn't executed

170 files_attempted += 1 

171 dest_path = target_dir / source_file.name 

172 if _operations.save_content( 172 ↛ 178line 172 didn't jump to line 178 because the condition on line 172 was always true

173 source_file.read_text( encoding = 'utf-8' ), 

174 dest_path, 

175 simulate, 

176 ): 

177 files_written += 1 

178 with _contextlib.suppress( ValueError ): 

179 exclude_entries.append( 

180 _format_exclude_path( dest_path.relative_to( target ) ) ) 

181 return ( files_attempted, files_written, tuple( exclude_entries ) ) 

182 

183 

184def _populate_per_user_content( 

185 location: __.Path, 

186 coders: __.cabc.Sequence[ str ], 

187 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

188 simulate: bool, 

189) -> tuple[ int, int ]: 

190 ''' Populates commands, agents, and skills for per-user coders. 

191 

192 Copies distribution items to each coder's per-user directory. 

193 Returns tuple of (items_attempted, items_written). 

194 ''' 

195 attempted, written, _ = _copy_distribution_items( 

196 location, coders, __.Path.cwd( ), configuration, 

197 'per-user', simulate ) 

198 return ( attempted, written ) 

199 

200 

201def _create_coder_directory_symlinks( 

202 coders: __.cabc.Sequence[ str ], 

203 target: __.Path, 

204 simulate: bool = False, 

205) -> tuple[ int, int, tuple[ str, ... ] ]: 

206 ''' Creates symlinks from .{coder} to .auxiliary/configuration/coders/. 

207 

208 For per-project mode, creates symlinks that make coder directories 

209 accessible at their expected locations (.claude, .opencode, etc.) 

210 while keeping actual files organized under 

211 .auxiliary/configuration/coders/. 

212 

213 Each renderer is responsible for specifying its symlink requirements 

214 via provide_project_symlinks(). Population logic simply iterates 

215 coders and asks renderers for their symlinks. 

216 

217 Only creates symlinks for coders whose default mode is per-project. 

218 Coders with per-user default mode are skipped since they do not 

219 use per-project directories. 

220 

221 Returns tuple of (attempted, created, symlink_names) where 

222 symlink_names contains names of all symlinks (both newly created 

223 and pre-existing). 

224 ''' 

225 attempted = 0 

226 created = 0 

227 symlink_names: list[ str ] = [ ] 

228 for coder_name, renderer in _resolver.resolve_coders( 

229 coders, mode = 'per-project' 

230 ): 

231 for source, link_path in renderer.provide_project_symlinks( target ): 

232 attempted += 1 

233 was_created, symlink_name = ( 

234 _memorylinks.create_memory_symlink( 

235 source, link_path, simulate ) ) 

236 if was_created: created += 1 

237 symlink_names.append( symlink_name ) 

238 return ( attempted, created, tuple( symlink_names ) ) 

239 

240 

241def _copy_distribution_items( # noqa: PLR0913 

242 distribution: __.Path, 

243 coders: __.cabc.Sequence[ str ], 

244 target: __.Path, 

245 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

246 mode: _renderers.ExplicitTargetMode, 

247 simulate: bool, 

248) -> tuple[ int, int, tuple[ str, ... ] ]: 

249 ''' Copies distribution items to downstream target paths. 

250 

251 For each coder, copies the entire 

252 distribution/<mode>/coders/<coder>/ tree to the target. 

253 Skills are copied separately from 

254 distribution/per-project/general/skills/. 

255 

256 The distribution tree mirrors downstream layout, so this is 

257 a single copy operation per coder. 

258 

259 Returns tuple of (items_attempted, items_written, exclude_entries). 

260 ''' 

261 items_attempted = 0 

262 items_written = 0 

263 exclude_entries: list[ str ] = [ ] 

264 for coder_name, manager in _resolver.resolve_coders( 

265 coders, mode = mode 

266 ): 

267 base_directory = manager.resolve_base_directory( 

268 mode = mode, 

269 target = target, 

270 configuration = configuration, 

271 environment = __.os.environ, 

272 ) 

273 coder_source = distribution / mode / 'coders' / coder_name 

274 # Copy entire coder tree (commands, agents, resources). 

275 if coder_source.exists( ): 275 ↛ 282line 275 didn't jump to line 282 because the condition on line 275 was always true

276 attempted, written, entries = _copy_tree( 

277 coder_source, base_directory, target, simulate ) 

278 items_attempted += attempted 

279 items_written += written 

280 exclude_entries.extend( entries ) 

281 # Copy skills from general directory. 

282 if mode == 'per-project': 282 ↛ 264line 282 didn't jump to line 264 because the condition on line 282 was always true

283 attempted, written, entries = _copy_skills( 

284 distribution, base_directory, manager, target, simulate ) 

285 items_attempted += attempted 

286 items_written += written 

287 exclude_entries.extend( entries ) 

288 return ( items_attempted, items_written, tuple( exclude_entries ) ) 

289 

290 

291def _copy_tree( 

292 source: __.Path, 

293 target: __.Path, 

294 project_root: __.Path, 

295 simulate: bool, 

296) -> tuple[ int, int, tuple[ str, ... ] ]: 

297 ''' Copies directory tree from source to target. 

298 

299 Recursively copies all files and subdirectories. 

300 Returns tuple of (files_attempted, files_written, exclude_entries). 

301 ''' 

302 import contextlib as _contextlib 

303 files_attempted = 0 

304 files_written = 0 

305 exclude_entries: list[ str ] = [ ] 

306 for source_file in source.rglob( '*' ): 

307 if not source_file.is_file( ): continue 

308 files_attempted += 1 

309 relative = source_file.relative_to( source ) 

310 dest_path = target / relative 

311 if _operations.save_content( 311 ↛ 317line 311 didn't jump to line 317 because the condition on line 311 was always true

312 source_file.read_text( encoding = 'utf-8' ), 

313 dest_path, 

314 simulate, 

315 ): 

316 files_written += 1 

317 with _contextlib.suppress( ValueError ): 

318 exclude_entries.append( 

319 _format_exclude_path( 

320 dest_path.relative_to( project_root ) ) ) 

321 return ( files_attempted, files_written, tuple( exclude_entries ) ) 

322 

323 

324def _copy_skills( 

325 distribution: __.Path, 

326 base_directory: __.Path, 

327 manager: _renderers.RendererBase, 

328 project_root: __.Path, 

329 simulate: bool, 

330) -> tuple[ int, int, tuple[ str, ... ] ]: 

331 ''' Copies skill files directly from distribution/ to target paths. 

332 

333 Skills are static artifacts that require no rendering. 

334 Copies from distribution/per-project/general/skills/<name>.md to 

335 <base>/skills/<name>/SKILL.md. Returns tuple of (attempted, 

336 written, exclude_entries). 

337 ''' 

338 import contextlib as _contextlib 

339 items_attempted = 0 

340 items_written = 0 

341 exclude_entries: list[ str ] = [ ] 

342 skills_dir = ( 

343 distribution / 'per-project' / 'general' / 'skills' ) 

344 if not skills_dir.exists( ): 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 return ( items_attempted, items_written, tuple( exclude_entries ) ) 

346 skills_output = manager.calculate_directory_location( 'skills' ) 

347 for skill_file in skills_dir.glob( '*.md' ): 

348 items_attempted += 1 

349 item_name = skill_file.stem 

350 dest_path = ( 

351 base_directory / skills_output / item_name / 'SKILL.md' ) 

352 if _operations.save_content( 352 ↛ 358line 352 didn't jump to line 358 because the condition on line 352 was always true

353 skill_file.read_text( encoding = 'utf-8' ), 

354 dest_path, 

355 simulate, 

356 ): 

357 items_written += 1 

358 with _contextlib.suppress( ValueError ): 

359 exclude_entries.append( 

360 _format_exclude_path( 

361 dest_path.relative_to( project_root ) ) ) 

362 return ( items_attempted, items_written, tuple( exclude_entries ) ) 

363 

364 

365def _manage_project_auxiliaries( 

366 configuration: __.cabc.Mapping[ str, __.typx.Any ], 

367 distribution: __.Path, 

368 target: __.Path, 

369 distribution_entries: __.cabc.Sequence[ str ], 

370 simulate: bool 

371) -> None: 

372 ''' Manages auxiliary project files (instructions, symlinks, excludes). ''' 

373 instruction_entries: tuple[ str, ... ] = ( ) 

374 if configuration.get( 'provide_instructions', False ): 

375 instructions_target = configuration.get( 

376 'instructions_target', '.auxiliary/instructions' ) 

377 instructions_attempted, instructions_written, instruction_entries = ( 

378 _copy_instructions_from_distribution( 

379 distribution, target, instructions_target, simulate ) ) 

380 if instructions_written > 0: 380 ↛ 384line 380 didn't jump to line 384 because the condition on line 380 was always true

381 _scribe.info( 

382 f"Copied {instructions_written}/{instructions_attempted} " 

383 "instruction files" ) 

384 all_symlink_names: list[ str ] = list( _create_all_symlinks( 

385 configuration, target, 'per-project', simulate ) ) 

386 git_exclude_entries: list[ str ] = list( distribution_entries ) 

387 git_exclude_entries.extend( instruction_entries ) 

388 git_exclude_entries.extend( all_symlink_names ) 

389 if git_exclude_entries: 389 ↛ exitline 389 didn't return from function '_manage_project_auxiliaries' because the condition on line 389 was always true

390 entries_count = _operations.update_git_exclude( 

391 target, git_exclude_entries, simulate ) 

392 if entries_count > 0: 

393 _scribe.info( 

394 f"Managing {entries_count} entries in .git/info/exclude" ) 

395 

396 

397class PopulateProjectCommand( __.appcore_cli.Command ): 

398 ''' Generates project-scoped agent content from data sources. 

399 

400 Populates agent commands, definitions, and static resources 

401 from the specified data source. Copies pre-rendered commands 

402 and agents from distribution/, generates skills, and copies 

403 static resources. 

404 ''' 

405 

406 source: SourceArgument = '.' 

407 target: TargetArgument = __.dcls.field( default_factory = __.Path.cwd ) 

408 profile: __.typx.Annotated[ 

409 __.typx.Optional[ __.Path ], 

410 __.tyro.conf.arg( 

411 help = ( 

412 "Alternative Copier answers file (defaults to " 

413 "auto-detected)" ), 

414 prefix_name = False ), 

415 ] = None 

416 simulate: __.typx.Annotated[ 

417 bool, 

418 __.tyro.conf.arg( 

419 help = "Dry run mode - show generated content", 

420 prefix_name = False ), 

421 ] = False 

422 tag_prefix: __.typx.Annotated[ 

423 __.typx.Optional[ str ], 

424 __.tyro.conf.arg( 

425 help = ( 

426 "Prefix for version tags (e.g., 'v', 'stable-', 'prod-'); " 

427 "only tags with this prefix are considered and the prefix " 

428 "is stripped before version parsing" ), 

429 prefix_name = False ), 

430 ] = None 

431 

432 @_cmdbase.intercept_errors( ) 

433 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

434 ''' Generates project content from data sources. ''' 

435 if not isinstance( auxdata, _core.Globals ): # pragma: no cover 

436 raise _exceptions.ContextInvalidity 

437 _scribe.info( 

438 f"Populating project content from {self.source} to {self.target}" ) 

439 configuration = await _cmdbase.retrieve_configuration( 

440 self.target, self.profile ) 

441 per_project_coders = _filter_coders_by_mode( 

442 configuration[ 'coders' ], 'per-project' ) 

443 if not per_project_coders: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 _scribe.warning( 

445 "No per-project default coders found in configuration" ) 

446 return 

447 filtered_configuration = dict( configuration ) 

448 filtered_configuration[ 'coders' ] = per_project_coders 

449 prefix = __.absent if self.tag_prefix is None else self.tag_prefix 

450 location = _cmdbase.retrieve_data_location( self.source, prefix ) 

451 _cmdbase.validate_data_source_structure( 

452 location, ( 'per-project', ) ) 

453 items_attempted, items_copied, exclude_entries = ( 

454 _copy_distribution_items( 

455 location, 

456 filtered_configuration[ 'coders' ], 

457 self.target, 

458 filtered_configuration, 

459 'per-project', 

460 self.simulate ) ) 

461 if items_attempted > 0: 461 ↛ 468line 461 didn't jump to line 468 because the condition on line 461 was always true

462 if self.simulate: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 _scribe.info( 

464 f"Would copy {items_attempted} items" ) 

465 else: 

466 _scribe.info( 

467 f"Copied {items_copied}/{items_attempted} items" ) 

468 _manage_project_auxiliaries( 

469 filtered_configuration, location, self.target, 

470 exclude_entries, self.simulate ) 

471 result = _results.ContentGenerationResult( 

472 source_location = location, 

473 target_location = self.target, 

474 coders = tuple( configuration[ 'coders' ] ), 

475 simulated = self.simulate, 

476 items_generated = ( 

477 items_attempted if self.simulate else items_copied ), 

478 ) 

479 await _core.render_and_print_result( 

480 result, auxdata.display, auxdata.exits ) 

481 

482 

483class PopulateUserCommand( __.appcore_cli.Command ): 

484 ''' Populates per-user global settings and executables. ''' 

485 

486 source: SourceArgument = '.' 

487 profile: __.typx.Annotated[ 

488 __.typx.Optional[ __.Path ], 

489 __.tyro.conf.arg( 

490 help = ( 

491 "Alternative Copier answers file (defaults to " 

492 "auto-detected)" ), 

493 prefix_name = False ), 

494 ] = None 

495 simulate: __.typx.Annotated[ 

496 bool, 

497 __.tyro.conf.arg( 

498 help = "Dry run mode - show what would be installed", 

499 prefix_name = False ), 

500 ] = False 

501 tag_prefix: __.typx.Annotated[ 

502 __.typx.Optional[ str ], 

503 __.tyro.conf.arg( 

504 help = ( 

505 "Prefix for version tags (e.g., 'v', 'stable-', 'prod-'); " 

506 "only tags with this prefix are considered and the prefix " 

507 "is stripped before version parsing" ), 

508 prefix_name = False ), 

509 ] = None 

510 

511 @_cmdbase.intercept_errors( ) 

512 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

513 ''' Populates user-scoped settings and executables. ''' 

514 if not isinstance( auxdata, _core.Globals ): # pragma: no cover 

515 raise _exceptions.ContextInvalidity 

516 _scribe.info( f"Populating user configuration from {self.source}" ) 

517 configuration = await _cmdbase.retrieve_configuration( 

518 __.Path.cwd( ), self.profile ) 

519 per_user_coders = _filter_coders_by_mode( 

520 configuration[ 'coders' ], 'per-user' ) 

521 if not per_user_coders: 

522 _scribe.warning( 

523 "No per-user default coders found in configuration" ) 

524 return 

525 prefix = __.absent if self.tag_prefix is None else self.tag_prefix 

526 location = _cmdbase.retrieve_data_location( self.source, prefix ) 

527 _cmdbase.validate_data_source_structure( 

528 location, 

529 ( 'per-user', ) ) 

530 content_attempted, content_generated = _populate_per_user_content( 

531 location, 

532 per_user_coders, 

533 configuration, 

534 self.simulate, 

535 ) 

536 if content_attempted > 0: 

537 _scribe.info( 

538 f"Generated {content_generated}/{content_attempted} items" ) 

539 globals_attempted, globals_updated = _userdata.populate_globals( 

540 location, 

541 per_user_coders, 

542 configuration, 

543 self.simulate, 

544 ) 

545 _scribe.info( 

546 f"Updated {globals_updated}/{globals_attempted} global files" ) 

547 wrappers_attempted, wrappers_installed = ( 

548 _userdata.populate_user_wrappers( location, self.simulate ) ) 

549 if wrappers_attempted > 0: 

550 _scribe.info( 

551 f"Installed {wrappers_installed}/{wrappers_attempted} " 

552 "wrapper scripts" ) 

553 total_items = content_generated + globals_updated + wrappers_installed 

554 result = _results.ContentGenerationResult( 

555 source_location = location, 

556 target_location = __.Path.home( ), 

557 coders = per_user_coders, 

558 simulated = self.simulate, 

559 items_generated = total_items, 

560 ) 

561 await _core.render_and_print_result( 

562 result, auxdata.display, auxdata.exits ) 

563 

564 

565class PopulateCommand( __.appcore_cli.Command ): 

566 ''' Populates agent content and configuration. ''' 

567 

568 command: __.typx.Union[ 

569 __.typx.Annotated[ 

570 PopulateProjectCommand, 

571 __.tyro.conf.subcommand( 'project', prefix_name = False ), 

572 ], 

573 __.typx.Annotated[ 

574 PopulateUserCommand, 

575 __.tyro.conf.subcommand( 'user', prefix_name = False ), 

576 ], 

577 ] = __.dcls.field( default_factory = PopulateProjectCommand ) 

578 

579 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

580 await self.command( auxdata ) 

581 

582 

583class GenerateCommand( __.appcore_cli.Command ): 

584 ''' Generates pre-rendered artifacts from components/ to distribution/. 

585 

586 Reads 3-tier pipeline source material from components/ and writes 

587 rendered commands and agents to distribution/. Skills are direct 

588 distribution artifacts and are not generated. 

589 

590 Use --check to validate that distribution/ is current without 

591 writing files. 

592 ''' 

593 

594 source: __.typx.Annotated[ 

595 str, 

596 __.tyro.conf.arg( 

597 help = "Components source path (defaults to 'components')" ), 

598 ] = 'components' 

599 output: __.typx.Annotated[ 

600 __.Path, 

601 __.tyro.conf.arg( 

602 help = "Distribution output path" ), 

603 ] = __.Path( 'distribution' ) 

604 check: __.typx.Annotated[ 

605 bool, 

606 __.tyro.conf.arg( 

607 help = "Check mode - fail if distribution is stale", 

608 prefix_name = False ), 

609 ] = False 

610 simulate: __.typx.Annotated[ 

611 bool, 

612 __.tyro.conf.arg( 

613 help = "Dry run mode - show what would be generated", 

614 prefix_name = False ), 

615 ] = False 

616 

617 @_cmdbase.intercept_errors( ) 

618 async def execute( self, auxdata: __.appcore.state.Globals ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] 

619 ''' Generates distribution artifacts from components. ''' 

620 if not isinstance( auxdata, _core.Globals ): # pragma: no cover 

621 raise _exceptions.ContextInvalidity 

622 _scribe.info( 

623 f"Generating distribution from {self.source} to {self.output}" ) 

624 location = _cmdbase.retrieve_data_location( self.source ) 

625 _cmdbase.validate_data_source_structure( 

626 location, 

627 ( 'configurations', 'contents', 'templates' ) ) 

628 configuration = _produce_default_configuration( location ) 

629 generator = _generator.ContentGenerator( 

630 location = location, 

631 configuration = configuration, 

632 application_configuration = auxdata.configuration, 

633 mode = 'per-project', 

634 ) 

635 if self.check: 

636 items_checked, diff_lines = ( 

637 _operations.check_distribution_staleness( 

638 generator, self.output ) ) 

639 if diff_lines: 

640 _scribe.error( 

641 f"Distribution is stale ({items_checked} items checked):" ) 

642 for line in diff_lines: 

643 print( line ) 

644 raise SystemExit( 1 ) 

645 _scribe.info( 

646 f"Distribution is current ({items_checked} items checked)" ) 

647 return 

648 items_attempted, items_generated = ( 

649 _operations.generate_distribution( 

650 generator, self.output, self.simulate ) ) 

651 _scribe.info( 

652 f"Generated {items_generated}/{items_attempted} artifacts" ) 

653 result = _results.ContentGenerationResult( 

654 source_location = location, 

655 target_location = self.output, 

656 coders = tuple( configuration.get( 'coders', ( ) ) ), 

657 simulated = self.simulate, 

658 items_generated = items_generated, 

659 ) 

660 await _core.render_and_print_result( 

661 result, auxdata.display, auxdata.exits )