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

311 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 05:15 +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_text( 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, 

197 coders, 

198 __.Path.cwd( ), 

199 configuration = configuration, 

200 mode = 'per-user', 

201 simulate = simulate ) 

202 return ( attempted, written ) 

203 

204 

205def _create_coder_directory_symlinks( 

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

207 target: __.Path, 

208 simulate: bool = False, 

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

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

211 

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

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

214 while keeping actual files organized under 

215 .auxiliary/configuration/coders/. 

216 

217 Each renderer is responsible for specifying its symlink requirements 

218 via provide_project_symlinks(). Population logic simply iterates 

219 coders and asks renderers for their symlinks. 

220 

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

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

223 use per-project directories. 

224 

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

226 symlink_names contains names of all symlinks (both newly created 

227 and pre-existing). 

228 ''' 

229 attempted = 0 

230 created = 0 

231 symlink_names: list[ str ] = [ ] 

232 for coder_name, renderer in _resolver.resolve_coders( 

233 coders, mode = 'per-project' 

234 ): 

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

236 attempted += 1 

237 was_created, symlink_name = ( 

238 _memorylinks.create_memory_symlink( 

239 source, link_path, simulate ) ) 

240 if was_created: created += 1 

241 symlink_names.append( symlink_name ) 

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

243 

244 

245def _copy_distribution_items( # noqa: PLR0913 

246 distribution: __.Path, 

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

248 target: __.Path, 

249 *, 

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

251 mode: _renderers.ExplicitTargetMode, 

252 simulate: bool, 

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

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

255 

256 For each coder, copies the entire 

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

258 In per-project mode, skills materialize once under 

259 ``.auxiliary/agents/skills/`` with discovery symlinks. 

260 

261 The distribution tree mirrors downstream layout, so this is 

262 a single copy operation per coder. 

263 

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

265 ''' 

266 items_attempted = 0 

267 items_written = 0 

268 exclude_entries: list[ str ] = [ ] 

269 for coder_name, manager in _resolver.resolve_coders( 

270 coders, mode = mode 

271 ): 

272 base_directory = manager.resolve_base_directory( 

273 mode = mode, 

274 target = target, 

275 configuration = configuration, 

276 environment = __.os.environ, 

277 ) 

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

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

280 if coder_source.exists( ): 

281 attempted, written, entries = _copy_tree( 

282 coder_source, base_directory, target, simulate ) 

283 items_attempted += attempted 

284 items_written += written 

285 exclude_entries.extend( entries ) 

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

287 attempted, written, entries = _copy_skills( 

288 distribution, target, coders, configuration, simulate ) 

289 items_attempted += attempted 

290 items_written += written 

291 exclude_entries.extend( entries ) 

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

293 

294 

295def _copy_tree( 

296 source: __.Path, 

297 target: __.Path, 

298 project_root: __.Path, 

299 simulate: bool, 

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

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

302 

303 Recursively copies all files and subdirectories (binary-safe) and 

304 preserves each source file mode (including executable bits). 

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

306 ''' 

307 import contextlib as _contextlib 

308 files_attempted = 0 

309 files_written = 0 

310 exclude_entries: list[ str ] = [ ] 

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

312 if not source_file.is_file( ): continue 

313 files_attempted += 1 

314 relative = source_file.relative_to( source ) 

315 dest_path = target / relative 

316 if _operations.save_content_bytes( 316 ↛ 323line 316 didn't jump to line 323 because the condition on line 316 was always true

317 source_file.read_bytes( ), 

318 dest_path, 

319 simulate, 

320 ): 

321 files_written += 1 

322 __.shutil.copymode( source_file, dest_path ) 

323 with _contextlib.suppress( ValueError ): 

324 exclude_entries.append( 

325 _format_exclude_path( 

326 dest_path.relative_to( project_root ) ) ) 

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

328 

329 

330def _canonical_skills_directory( project_root: __.Path ) -> __.Path: 

331 ''' Returns project-canonical skills root under .auxiliary/agents. ''' 

332 return project_root / '.auxiliary' / 'agents' / 'skills' 

333 

334 

335def _ensure_directory_symlink( 

336 source: __.Path, 

337 link_path: __.Path, 

338 *, 

339 simulate: bool = False, 

340 replace_existing: bool = False, 

341) -> bool: 

342 ''' Ensures link_path is a symlink to source. 

343 

344 When replace_existing is true, removes an existing non-symlink 

345 directory or file at link_path so managed coder skill trees can cut 

346 over to the canonical package root. When false (default), leaves an 

347 existing non-symlink path in place and reports a warning — used for 

348 root ``.agents``, which may belong to another tool. Returns whether 

349 the symlink was created or updated. 

350 ''' 

351 try: 

352 relative_source = __.os.path.relpath( 

353 source, start = link_path.parent ) 

354 except ValueError: 

355 relative_source = str( source.resolve( ) ) 

356 if link_path.is_symlink( ): 

357 try: current_target = __.os.readlink( link_path ) 

358 except OSError as exception: 

359 _scribe.warning( 

360 f"Cannot read symlink {link_path}: {exception}." ) 

361 return False 

362 if current_target == relative_source: 362 ↛ 364line 362 didn't jump to line 364 because the condition on line 362 was always true

363 return False 

364 _scribe.info( 

365 f"Updating symlink {link_path.name}: " 

366 f"{current_target} → {relative_source}" ) 

367 if not simulate: link_path.unlink( ) 

368 elif link_path.exists( ): 

369 if not replace_existing: 

370 _scribe.warning( 

371 f"Path already exists at {link_path}; " 

372 "not replacing with symlink. Remove or relocate it to " 

373 "enable agentsmgr skills discovery linking." ) 

374 return False 

375 _scribe.info( 

376 f"Replacing existing path with symlink: {link_path}" ) 

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

378 if link_path.is_dir( ): __.shutil.rmtree( link_path ) 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was always true

379 else: link_path.unlink( ) 

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

381 link_path.parent.mkdir( parents = True, exist_ok = True ) 

382 link_path.symlink_to( relative_source ) 

383 _scribe.info( f"Created directory symlink: {link_path.name}" ) 

384 else: 

385 _scribe.info( 

386 f"[SIMULATE] Would create symlink: " 

387 f"{link_path} → {relative_source}" ) 

388 return True 

389 

390 

391def _link_skills_discovery( 

392 project_root: __.Path, 

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

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

395 simulate: bool, 

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

397 ''' Links harness discovery paths to the canonical skills tree. 

398 

399 - Root ``.agents`` → ``.auxiliary/agents`` so ``.agents/skills`` 

400 resolves; never deletes a pre-existing non-symlink ``.agents`` path 

401 - Per-coder ``<base>/skills`` → canonical skills directory 

402 (destructive cutover of legacy dual-copy skill trees only) 

403 ''' 

404 import contextlib as _contextlib 

405 exclude_entries: list[ str ] = [ ] 

406 agents_source = project_root / '.auxiliary' / 'agents' 

407 agents_link = project_root / '.agents' 

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

409 agents_source.mkdir( parents = True, exist_ok = True ) 

410 if _ensure_directory_symlink( 

411 agents_source, agents_link, 

412 simulate = simulate, replace_existing = False, 

413 ) or agents_link.is_symlink( ): 

414 exclude_entries.append( agents_link.name ) 

415 canonical = _canonical_skills_directory( project_root ) 

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

417 canonical.mkdir( parents = True, exist_ok = True ) 

418 for _, manager in _resolver.resolve_coders( 

419 coders, mode = 'per-project' 

420 ): 

421 if 'skills' not in manager.item_types_available: continue 421 ↛ 418line 421 didn't jump to line 418 because the continue on line 421 wasn't executed

422 base_directory = manager.resolve_base_directory( 

423 mode = 'per-project', 

424 target = project_root, 

425 configuration = configuration, 

426 environment = __.os.environ, 

427 ) 

428 skills_name = manager.calculate_directory_location( 'skills' ) 

429 link_path = base_directory / skills_name 

430 if _ensure_directory_symlink( 430 ↛ 418line 430 didn't jump to line 418 because the condition on line 430 was always true

431 canonical, link_path, 

432 simulate = simulate, replace_existing = True, 

433 ) or link_path.is_symlink( ): 

434 with _contextlib.suppress( ValueError ): 

435 exclude_entries.append( 

436 _format_exclude_path( 

437 link_path.relative_to( project_root ) ) ) 

438 return tuple( exclude_entries ) 

439 

440 

441def _copy_skills( 

442 distribution: __.Path, 

443 project_root: __.Path, 

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

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

446 simulate: bool, 

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

448 ''' Materializes skill packages once and links harness discovery paths. 

449 

450 Skills are static artifacts that require no rendering. Layout follows 

451 the Agent Skills specification 

452 (https://agentskills.io/specification.md). Source layout under 

453 ``distribution/per-project/general/skills/`` may be either: 

454 

455 - Directory package (Agent Skills): ``<name>/SKILL.md`` plus optional 

456 ``scripts/``, ``references/``, ``assets/``, and other supporting 

457 files — the entire directory is copied to 

458 ``.auxiliary/agents/skills/<name>/``. 

459 - Legacy flat file: ``<name>.md`` → 

460 ``.auxiliary/agents/skills/<name>/SKILL.md``. 

461 

462 When both a directory package and a flat ``<name>.md`` exist for the 

463 same name, the directory package wins. Supporting files are not 

464 language-filtered; a skill is a portable package. After materializing 

465 the canonical tree, creates discovery symlinks (``.agents`` and 

466 per-coder ``skills/``). Returns tuple of 

467 (files_attempted, files_written, exclude_entries). 

468 ''' 

469 import contextlib as _contextlib 

470 items_attempted = 0 

471 items_written = 0 

472 exclude_entries: list[ str ] = [ ] 

473 skills_dir = ( 

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

475 canonical = _canonical_skills_directory( project_root ) 

476 if skills_dir.exists( ): 476 ↛ 506line 476 didn't jump to line 506 because the condition on line 476 was always true

477 directory_skills: dict[ str, __.Path ] = { } 

478 flat_skills: dict[ str, __.Path ] = { } 

479 for entry in skills_dir.iterdir( ): 

480 if entry.is_dir( ) and ( entry / 'SKILL.md' ).is_file( ): 

481 directory_skills[ entry.name ] = entry 

482 elif entry.is_file( ) and entry.suffix == '.md': 482 ↛ 479line 482 didn't jump to line 479 because the condition on line 482 was always true

483 flat_skills[ entry.stem ] = entry 

484 for item_name, source_dir in sorted( directory_skills.items( ) ): 

485 dest_root = canonical / item_name 

486 attempted, written, entries = _copy_tree( 

487 source_dir, dest_root, project_root, simulate ) 

488 items_attempted += attempted 

489 items_written += written 

490 exclude_entries.extend( entries ) 

491 for item_name, skill_file in sorted( flat_skills.items( ) ): 

492 if item_name in directory_skills: continue 

493 items_attempted += 1 

494 dest_path = canonical / item_name / 'SKILL.md' 

495 if _operations.save_content_bytes( 495 ↛ 502line 495 didn't jump to line 502 because the condition on line 495 was always true

496 skill_file.read_bytes( ), 

497 dest_path, 

498 simulate, 

499 ): 

500 items_written += 1 

501 __.shutil.copymode( skill_file, dest_path ) 

502 with _contextlib.suppress( ValueError ): 

503 exclude_entries.append( 

504 _format_exclude_path( 

505 dest_path.relative_to( project_root ) ) ) 

506 exclude_entries.extend( 

507 _link_skills_discovery( 

508 project_root, coders, configuration, simulate ) ) 

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

510 

511 

512def _manage_project_auxiliaries( 

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

514 distribution: __.Path, 

515 target: __.Path, 

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

517 simulate: bool 

518) -> None: 

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

520 instructions_target = configuration.get( 

521 'instructions_target', '.auxiliary/agents/standards' ) 

522 instructions_attempted, instructions_written, instruction_entries = ( 

523 _copy_instructions_from_distribution( 

524 distribution, target, instructions_target, simulate ) ) 

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

526 _scribe.info( 

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

528 "instruction files" ) 

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

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

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

532 git_exclude_entries.extend( instruction_entries ) 

533 git_exclude_entries.extend( all_symlink_names ) 

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

535 entries_count = _operations.update_git_exclude( 

536 target, git_exclude_entries, simulate ) 

537 if entries_count > 0: 

538 _scribe.info( 

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

540 

541 

542class PopulateProjectCommand( __.appcore_cli.Command ): 

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

544 

545 Populates agent commands, definitions, and static resources 

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

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

548 static resources. 

549 ''' 

550 

551 source: SourceArgument = '.' 

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

553 profile: __.typx.Annotated[ 

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

555 __.tyro.conf.arg( 

556 help = ( 

557 "Alternative Copier answers file (defaults to " 

558 "auto-detected)" ), 

559 prefix_name = False ), 

560 ] = None 

561 simulate: __.typx.Annotated[ 

562 bool, 

563 __.tyro.conf.arg( 

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

565 prefix_name = False ), 

566 ] = False 

567 tag_prefix: __.typx.Annotated[ 

568 __.typx.Optional[ str ], 

569 __.tyro.conf.arg( 

570 help = ( 

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

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

573 "is stripped before version parsing" ), 

574 prefix_name = False ), 

575 ] = None 

576 

577 @_cmdbase.intercept_errors( ) 

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

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

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

581 raise _exceptions.ContextInvalidity 

582 _scribe.info( 

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

584 configuration = await _cmdbase.retrieve_configuration( 

585 self.target, self.profile ) 

586 per_project_coders = _filter_coders_by_mode( 

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

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

589 _scribe.warning( 

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

591 return 

592 filtered_configuration = dict( configuration ) 

593 filtered_configuration[ 'coders' ] = per_project_coders 

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

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

596 _cmdbase.validate_data_source_structure( 

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

598 items_attempted, items_copied, exclude_entries = ( 

599 _copy_distribution_items( 

600 location, 

601 filtered_configuration[ 'coders' ], 

602 self.target, 

603 configuration = filtered_configuration, 

604 mode = 'per-project', 

605 simulate = self.simulate ) ) 

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

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

608 _scribe.info( 

609 f"Would copy {items_attempted} items" ) 

610 else: 

611 _scribe.info( 

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

613 _manage_project_auxiliaries( 

614 filtered_configuration, location, self.target, 

615 exclude_entries, self.simulate ) 

616 result = _results.ContentGenerationResult( 

617 source_location = location, 

618 target_location = self.target, 

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

620 simulated = self.simulate, 

621 items_generated = ( 

622 items_attempted if self.simulate else items_copied ), 

623 ) 

624 await _core.render_and_print_result( 

625 result, auxdata.display, auxdata.exits ) 

626 

627 

628class PopulateUserCommand( __.appcore_cli.Command ): 

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

630 

631 source: SourceArgument = '.' 

632 profile: __.typx.Annotated[ 

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

634 __.tyro.conf.arg( 

635 help = ( 

636 "Alternative Copier answers file (defaults to " 

637 "auto-detected)" ), 

638 prefix_name = False ), 

639 ] = None 

640 simulate: __.typx.Annotated[ 

641 bool, 

642 __.tyro.conf.arg( 

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

644 prefix_name = False ), 

645 ] = False 

646 tag_prefix: __.typx.Annotated[ 

647 __.typx.Optional[ str ], 

648 __.tyro.conf.arg( 

649 help = ( 

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

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

652 "is stripped before version parsing" ), 

653 prefix_name = False ), 

654 ] = None 

655 

656 @_cmdbase.intercept_errors( ) 

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

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

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

660 raise _exceptions.ContextInvalidity 

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

662 configuration = await _cmdbase.retrieve_configuration( 

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

664 per_user_coders = _filter_coders_by_mode( 

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

666 if not per_user_coders: 

667 _scribe.warning( 

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

669 return 

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

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

672 _cmdbase.validate_data_source_structure( 

673 location, 

674 ( 'per-user', ) ) 

675 content_attempted, content_generated = _populate_per_user_content( 

676 location, 

677 per_user_coders, 

678 configuration, 

679 self.simulate, 

680 ) 

681 if content_attempted > 0: 

682 _scribe.info( 

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

684 globals_attempted, globals_updated = _userdata.populate_globals( 

685 location, 

686 per_user_coders, 

687 configuration, 

688 self.simulate, 

689 ) 

690 _scribe.info( 

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

692 wrappers_attempted, wrappers_installed = ( 

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

694 if wrappers_attempted > 0: 

695 _scribe.info( 

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

697 "wrapper scripts" ) 

698 total_items = content_generated + globals_updated + wrappers_installed 

699 result = _results.ContentGenerationResult( 

700 source_location = location, 

701 target_location = __.Path.home( ), 

702 coders = per_user_coders, 

703 simulated = self.simulate, 

704 items_generated = total_items, 

705 ) 

706 await _core.render_and_print_result( 

707 result, auxdata.display, auxdata.exits ) 

708 

709 

710class PopulateCommand( __.appcore_cli.Command ): 

711 ''' Populates agent content and configuration. ''' 

712 

713 command: __.typx.Union[ 

714 __.typx.Annotated[ 

715 PopulateProjectCommand, 

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

717 ], 

718 __.typx.Annotated[ 

719 PopulateUserCommand, 

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

721 ], 

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

723 

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

725 await self.command( auxdata ) 

726 

727 

728class GenerateCommand( __.appcore_cli.Command ): 

729 ''' Generates pre-rendered artifacts from components/. 

730 

731 Two invocation shapes: 

732 

733 **Default**: ``agentsmgr generate`` reads 3-tier pipeline 

734 source from ``components/`` and writes rendered commands and 

735 agents to ``distribution/`` (or to ``--output PATH`` if given). 

736 Skills are direct distribution artifacts and are not generated. 

737 ``--check`` validates distribution/ is current without writing 

738 files. ``--simulate`` shows what would be written. 

739 

740 **Answers-file**: ``agentsmgr generate --answers-file PATH 

741 --output PATH`` uses the given Copier answers file as the 

742 configuration and renders into the explicit ``--output`` 

743 target. The caller owns output allocation and cleanup; 

744 production CLI does not manage temporary directories. 

745 

746 ``--check`` and ``--simulate`` are only valid in default 

747 mode. ``--answers-file`` requires ``--output``. 

748 ''' 

749 

750 source: __.typx.Annotated[ 

751 str, 

752 __.tyro.conf.arg( 

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

754 prefix_name = False ), 

755 ] = 'components' 

756 output: __.typx.Annotated[ 

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

758 __.tyro.conf.arg( 

759 help = ( 

760 "Distribution output path. Default mode: defaults to " 

761 "'distribution/'. Required when --answers-file is used." ), 

762 prefix_name = False ), 

763 ] = None 

764 check: __.typx.Annotated[ 

765 bool, 

766 __.tyro.conf.arg( 

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

768 prefix_name = False ), 

769 ] = False 

770 simulate: __.typx.Annotated[ 

771 bool, 

772 __.tyro.conf.arg( 

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

774 prefix_name = False ), 

775 ] = False 

776 answers_file: __.typx.Annotated[ 

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

778 __.tyro.conf.arg( 

779 help = ( 

780 "Answers-file mode: path to a Copier answers file to " 

781 "use as the configuration source. Requires --output." ), 

782 prefix_name = False ), 

783 ] = None 

784 

785 @_cmdbase.intercept_errors( ) 

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

787 ''' Generates distribution artifacts from components. ''' 

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

789 raise _exceptions.ContextInvalidity 

790 if self.answers_file is not None and self.check: 

791 raise _exceptions.ConfigurationInvalidity( 

792 reason = '--check is not valid with --answers-file' ) 

793 if self.answers_file is not None and self.simulate: 

794 raise _exceptions.ConfigurationInvalidity( 

795 reason = '--simulate is not valid with --answers-file' ) 

796 if self.answers_file is not None and self.output is None: 

797 raise _exceptions.ConfigurationInvalidity( 

798 reason = '--answers-file requires --output' ) 

799 location = _cmdbase.retrieve_data_location( self.source ) 

800 _cmdbase.validate_data_source_structure( 

801 location, 

802 ( 'configurations', 'contents', 'templates' ) ) 

803 if self.answers_file is not None: 

804 await self._execute_answers_file_mode( 

805 auxdata, location ) 

806 return 

807 await self._execute_default_mode( auxdata, location ) 

808 

809 async def _execute_default_mode( 

810 self, 

811 auxdata: _core.Globals, 

812 location: __.Path, 

813 ) -> None: 

814 ''' Default mode: renders distribution tree with universal config. ''' 

815 target = ( 

816 self.output if self.output is not None 

817 else __.Path( 'distribution' ) ) 

818 _scribe.info( 

819 f"Generating distribution from {self.source} to {target}" ) 

820 configuration = _produce_default_configuration( location ) 

821 generator = _generator.ContentGenerator( 

822 location = location, 

823 configuration = configuration, 

824 application_configuration = auxdata.configuration, 

825 mode = 'per-project', 

826 ) 

827 if self.check: 827 ↛ 828line 827 didn't jump to line 828 because the condition on line 827 was never true

828 items_checked, diff_lines = ( 

829 _operations.check_distribution_staleness( 

830 generator, target ) ) 

831 if diff_lines: 

832 _scribe.error( 

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

834 for line in diff_lines: 

835 print( line ) 

836 raise SystemExit( 1 ) 

837 _scribe.info( 

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

839 return 

840 items_attempted, items_generated = ( 

841 _operations.generate_distribution( 

842 generator, target, self.simulate ) ) 

843 _scribe.info( 

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

845 result = _results.ContentGenerationResult( 

846 source_location = location, 

847 target_location = target, 

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

849 simulated = self.simulate, 

850 items_generated = items_generated, 

851 ) 

852 await _core.render_and_print_result( 

853 result, auxdata.display, auxdata.exits ) 

854 

855 async def _execute_answers_file_mode( 

856 self, 

857 auxdata: _core.Globals, 

858 location: __.Path, 

859 ) -> None: 

860 ''' Answers-file mode: renders against an explicit answers file 

861 into the explicit --output target. Caller owns the target 

862 directory and its cleanup. 

863 ''' 

864 answers_file = __.typx.cast( __.Path, self.answers_file ) 

865 target = __.typx.cast( __.Path, self.output ) 

866 _scribe.info( 

867 f"Generating distribution from {self.source} against " 

868 f"{answers_file} to {target}" ) 

869 configuration = await _cmdbase.retrieve_configuration( 

870 target = __.Path.cwd( ), profile = answers_file ) 

871 generator = _generator.ContentGenerator( 

872 location = location, configuration = configuration ) 

873 items_attempted, items_generated = ( 

874 _operations.populate_directory( 

875 generator, target, simulate = False ) ) 

876 _scribe.info( 

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

878 result = _results.ContentGenerationResult( 

879 source_location = location, 

880 target_location = target, 

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

882 simulated = False, 

883 items_generated = items_generated, 

884 ) 

885 await _core.render_and_print_result( 

886 result, auxdata.display, auxdata.exits )