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

95 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-24 01:49 +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 

24from . import __ 

25from . import cmdbase as _cmdbase 

26from . import core as _core 

27from . import exceptions as _exceptions 

28from . import generator as _generator 

29from . import instructions as _instructions 

30from . import memorylinks as _memorylinks 

31from . import operations as _operations 

32from . import renderers as _renderers 

33from . import results as _results 

34from . import userdata as _userdata 

35 

36 

37_scribe = __.provide_scribe( __name__ ) 

38 

39 

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

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

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

43] 

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

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

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

47] 

48 

49 

50def _create_all_symlinks( 

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

52 target: __.Path, 

53 mode: str, 

54 simulate: bool, 

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

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

57 

58 Creates memory symlinks for all coders and coder directory 

59 symlinks for per-project mode. Returns tuple of all symlink 

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

61 update. 

62 ''' 

63 all_symlink_names: list[ str ] = [ ] 

64 if mode == 'nowhere': return tuple( all_symlink_names ) 

65 links_attempted, links_created, symlink_names = ( 

66 _memorylinks.create_memory_symlinks_for_coders( 

67 coders = configuration[ 'coders' ], 

68 target = target, 

69 renderers = _renderers.RENDERERS, 

70 simulate = simulate, 

71 ) ) 

72 all_symlink_names.extend( symlink_names ) 

73 if links_created > 0: 

74 _scribe.info( 

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

76 needs_coder_symlinks = ( 

77 mode == 'per-project' 

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

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

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

81 if needs_coder_symlinks: 

82 ( coder_symlinks_attempted, 

83 coder_symlinks_created, 

84 coder_symlink_names ) = ( 

85 _create_coder_directory_symlinks( 

86 coders = configuration[ 'coders' ], 

87 target = target, 

88 renderers = _renderers.RENDERERS, 

89 simulate = simulate, 

90 ) ) 

91 all_symlink_names.extend( coder_symlink_names ) 

92 if coder_symlinks_created > 0: 

93 _scribe.info( 

94 f"Created {coder_symlinks_created}/" 

95 f"{coder_symlinks_attempted} coder directory symlinks" ) 

96 return tuple( all_symlink_names ) 

97 

98 

99def _populate_instructions_if_configured( 

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

101 target: __.Path, 

102 tag_prefix: __.Absential[ str ], 

103 simulate: bool, 

104) -> tuple[ bool, str ]: 

105 ''' Populates instructions if configured and returns status. 

106 

107 Returns tuple of (sources_present, instructions_target_path). 

108 sources_present indicates whether instruction sources were 

109 configured and processed. 

110 ''' 

111 if not configuration.get( 'provide_instructions', False ): 

112 return ( False, '' ) 

113 instructions_sources = configuration.get( 'instructions_sources', [ ] ) 

114 instructions_target = configuration.get( 

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

116 if not instructions_sources: 

117 return ( False, instructions_target ) 

118 instructions_attempted, instructions_updated = ( 

119 _instructions.populate_instructions( 

120 instructions_sources, 

121 target / instructions_target, 

122 tag_prefix, 

123 simulate, 

124 ) ) 

125 _scribe.info( 

126 f"Updated {instructions_updated}/" 

127 f"{instructions_attempted} instruction files" ) 

128 return ( True, instructions_target ) 

129 

130 

131def _create_coder_directory_symlinks( 

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

133 target: __.Path, 

134 renderers: __.cabc.Mapping[ str, __.typx.Any ], 

135 simulate: bool = False, 

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

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

138 

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

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

141 while keeping actual files organized under 

142 .auxiliary/configuration/coders/. 

143 

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

145 symlink_names contains names of all symlinks (both newly created 

146 and pre-existing). 

147 ''' 

148 attempted = 0 

149 created = 0 

150 symlink_names: list[ str ] = [ ] 

151 for coder_name in coders: 

152 try: renderers[ coder_name ] 

153 except KeyError as exception: 

154 raise _exceptions.CoderAbsence( coder_name ) from exception 

155 

156 # Source: actual location under .auxiliary/configuration/coders/ 

157 source = ( 

158 target / '.auxiliary' / 'configuration' / 'coders' / coder_name ) 

159 # Link: expected location for coder (.claude, .opencode, etc.) 

160 link_path = target / f'.{coder_name}' 

161 

162 attempted += 1 

163 was_created, symlink_name = _memorylinks.create_memory_symlink( 

164 source, link_path, simulate ) 

165 if was_created: created += 1 

166 symlink_names.append( symlink_name ) 

167 

168 # Create .mcp.json symlink for Claude coder specifically 

169 if coder_name == 'claude': 

170 mcp_source = ( 

171 target / '.auxiliary' / 'configuration' / 'mcp-servers.json' ) 

172 mcp_link = target / '.mcp.json' 

173 attempted += 1 

174 was_created, symlink_name = _memorylinks.create_memory_symlink( 

175 mcp_source, mcp_link, simulate ) 

176 if was_created: created += 1 

177 symlink_names.append( symlink_name ) 

178 

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

180 

181 

182class PopulateCommand( __.appcore_cli.Command ): 

183 ''' Generates dynamic agent content from data sources. ''' 

184 

185 source: SourceArgument = '.' 

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

187 profile: __.typx.Annotated[ 

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

189 __.tyro.conf.arg( 

190 help = ( 

191 "Alternative Copier answers file (defaults to " 

192 "auto-detected)" ), 

193 prefix_name = False ), 

194 ] = None 

195 simulate: __.typx.Annotated[ 

196 bool, 

197 __.tyro.conf.arg( 

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

199 prefix_name = False ), 

200 ] = False 

201 mode: __.typx.Annotated[ 

202 _renderers.TargetMode, 

203 __.tyro.conf.arg( 

204 help = ( 

205 "Targeting mode: default (use coder defaults), per-user, " 

206 "per-project, or nowhere (skip generation)" ), 

207 prefix_name = False ), 

208 ] = 'default' 

209 update_globals: __.typx.Annotated[ 

210 bool, 

211 __.tyro.conf.arg( 

212 help = "Update per-user global files (orthogonal to mode)", 

213 prefix_name = False ), 

214 ] = False 

215 tag_prefix: __.typx.Annotated[ 

216 __.typx.Optional[ str ], 

217 __.tyro.conf.arg( 

218 help = ( 

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

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

221 "is stripped before version parsing" ), 

222 prefix_name = False ), 

223 ] = None 

224 

225 @_cmdbase.intercept_errors( ) 

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

227 ''' Generates content from data sources and displays result. ''' 

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

229 raise _exceptions.ContextInvalidity 

230 _scribe.info( 

231 f"Populating agent content from {self.source} to {self.target}" ) 

232 configuration = await _cmdbase.retrieve_configuration( 

233 self.target, self.profile ) 

234 coder_count = len( configuration[ 'coders' ] ) 

235 _scribe.debug( f"Detected configuration with {coder_count} coders" ) 

236 _scribe.debug( f"Using {self.mode} targeting mode" ) 

237 prefix = ( 

238 __.absent if self.tag_prefix is None 

239 else self.tag_prefix ) 

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

241 generator = _generator.ContentGenerator( 

242 location = location, 

243 configuration = configuration, 

244 application_configuration = auxdata.configuration, 

245 mode = self.mode, 

246 ) 

247 items_attempted, items_generated = _operations.populate_directory( 

248 generator, self.target, self.simulate ) 

249 _scribe.info( f"Generated {items_generated}/{items_attempted} items" ) 

250 instructions_populated, instructions_target = ( 

251 _populate_instructions_if_configured( 

252 configuration, self.target, prefix, self.simulate ) ) 

253 all_symlink_names = _create_all_symlinks( 

254 configuration, self.target, self.mode, self.simulate ) 

255 git_exclude_entries: list[ str ] = [ ] 

256 if instructions_populated: 

257 git_exclude_entries.append( instructions_target ) 

258 git_exclude_entries.extend( all_symlink_names ) 

259 if self.update_globals: 

260 globals_attempted, globals_updated = ( 

261 _userdata.populate_globals( 

262 location, 

263 configuration[ 'coders' ], 

264 auxdata.configuration, 

265 self.simulate, 

266 ) ) 

267 _scribe.info( 

268 f"Updated {globals_updated}/{globals_attempted} " 

269 "global files" ) 

270 if git_exclude_entries: 

271 excludes_added = _operations.update_git_exclude( 

272 self.target, git_exclude_entries, self.simulate ) 

273 if excludes_added > 0: 

274 _scribe.info( 

275 f"Added {excludes_added} entries to .git/info/exclude" ) 

276 result = _results.ContentGenerationResult( 

277 source_location = location, 

278 target_location = self.target, 

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

280 simulated = self.simulate, 

281 items_generated = items_generated, 

282 ) 

283 await _core.render_and_print_result( 

284 result, auxdata.display, auxdata.exits )