1"""ll-verify-docs and ll-check-links: Documentation verification commands."""

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

9 from little_loops.cli.output import configure_output, print_json, use_color_enabled

10 from little_loops.cli_args import add_json_arg

11 from little_loops.logger import Logger

12 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

13

14

15 def main_verify_docs() -> int:

16 """Entry point for ll-verify-docs command.

17

18 Verify that documented counts (commands, agents, skills) match actual file counts.

19

20 Returns:

21 Exit code (0 = all match, 1 = mismatches found)

22 """

23 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-docs", sys.argv[1:]):

24 from little_loops.doc_counts import (

25 fix_counts,

26 format_result_json,

27 format_result_markdown,

28 format_result_text,

29 verify_documentation,

30 )

31

32 parser = argparse.ArgumentParser(

33 prog="ll-verify-docs",

34 description="Verify documented counts match actual file counts",

35 formatter_class=argparse.RawDescriptionHelpFormatter,

36 epilog="""

37 Examples:

38 %(prog)s # Check and show results

39 %(prog)s --json # Output as JSON

40 %(prog)s --format markdown # Markdown report

41 %(prog)s --fix # Auto-fix mismatches

42

43 Exit codes:

44 0 - All counts match

45 1 - Mismatches found

46 2 - Error occurred

47""",

48 )

49

50 parser.add_argument(

51 "-j",

52 "--json",

53 action="store_true",

54 help="Output as JSON",

55 )

56

57 parser.add_argument(

58 "-f",

59 "--format",

60 choices=["text", "json", "markdown"],

61 default="text",

62 help="Output format (default: text)",

63 )

64

65 parser.add_argument(

66 "--fix",

67 action="store_true",

68 help="Auto-fix count mismatches in documentation files",

69 )

70

71 parser.add_argument(

72 "-C",

73 "--directory",

74 type=Path,

75 default=None,

76 help="Base directory (default: current directory)",

77 )

78

79 args = parser.parse_args()

80

81 configure_output()

82 logger = Logger(use_color=use_color_enabled())

83

84 # Determine base directory

85 base_dir = args.directory or Path.cwd()

86

87 # Run verification

88 result = verify_documentation(base_dir)

89

90 # Format output

91 if args.json or args.format == "json":

92 output = format_result_json(result)

93 elif args.format == "markdown":

94 output = format_result_markdown(result)

95 else:

96 output = format_result_text(result)

97

98 print(output)

99

100 # Auto-fix if requested

101 if args.fix and not result.all_match:

102 fix_result = fix_counts(base_dir, result)

103 logger.success(

104 f"Fixed {fix_result.fixed_count} count(s) in {len(fix_result.files_modified)} file(s)"

105 )

106

107 # Return exit code based on results

108 return 0 if result.all_match else 1

109

110

111 def main_verify_skill_budget() -> int:

112 """Entry point for ll-verify-skill-budget command.

113

114 Scan skill description tokens and fail if total exceeds the configured budget.

115

116 Returns:

117 Exit code (0 = under budget, 1 = over budget)

118 """

119 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-skill-budget", sys.argv[1:]):

120 from little_loops.doc_counts import (

121 _DEFAULT_BUDGET_TOKENS,

122 check_skill_budget,

123 )

124

125 parser = argparse.ArgumentParser(

126 prog="ll-verify-skill-budget",

127 description="Verify skill description token footprint stays within listing budget",

128 formatter_class=argparse.RawDescriptionHelpFormatter,

129 epilog="""

130 Examples:

131 %(prog)s # Check against default 2000-token budget

132 %(prog)s --threshold 1500 # Custom token threshold

133

134 Exit codes:

135 0 - Under budget

136 1 - Over budget

137""",

138 )

139

140 parser.add_argument(

141 "--threshold",

142 type=int,

143 default=None,

144 metavar="N",

145 help=f"Token budget threshold (default: {_DEFAULT_BUDGET_TOKENS}; overrides ll-config.json)",

146 )

147

148 parser.add_argument(

149 "-C",

150 "--directory",

151 type=Path,

152 default=None,

153 help="Base directory (default: current directory)",

154 )

155

156 add_json_arg(parser)

157

158 args = parser.parse_args()

159

160 configure_output()

161 logger = Logger(use_color=use_color_enabled())

162

163 base_dir = args.directory or Path.cwd()

164

165 # Resolve threshold: CLI arg > config file > default

166 threshold = args.threshold

167 if threshold is None:

168 try:

169 from little_loops.config import BRConfig

170

171 threshold = (

172 BRConfig(base_dir)

173 ._raw_config.get("skill_budget", {})

174 .get("threshold_tokens", _DEFAULT_BUDGET_TOKENS)

175 )

176 except Exception:

177 threshold = _DEFAULT_BUDGET_TOKENS

178

179 result = check_skill_budget(base_dir=base_dir, threshold_tokens=threshold)

180

181 if args.json:

182 print_json(

183 {

184 "under_budget": result.under_budget,

185 "total_tokens": result.total_tokens,

186 "threshold_tokens": result.threshold_tokens,

187 "skills": [

188 {

189 "name": path.parent.name,

190 "char_count": len(desc),

191 "token_estimate": tokens,

192 }

193 for path, desc, tokens in result.skill_breakdown

194 ],

195 "violations": [

196 {

197 "name": path.parent.name,

198 "char_count": len(desc),

199 "token_estimate": tokens,

200 }

201 for path, desc, tokens in result.violations

202 ],

203 }

204 )

205 return 0 if result.under_budget else 1

206

207 # Per-skill breakdown header

208 print("Skill Description Token Budget Check")

209 print("=" * 40)

210 print(f"Threshold: {result.threshold_tokens} tokens")

211 print()

212

213 if result.skill_breakdown:

214 print(f"{'Tokens':>6} Skill")

215 print(f"{'------':>6} {'-----'}")

216 for skill_md, _, tokens in result.skill_breakdown:

217 marker = " !" if tokens >= 200 else " "

218 print(f"{tokens:>6}{marker} {skill_md.parent.name}")

219 print()

220

221 if result.under_budget:

222 logger.success(

223 f"Total: {result.total_tokens} / {result.threshold_tokens} tokens — under budget"

224 )

225 return 0

226 else:

227 logger.error(

228 f"Total: {result.total_tokens} / {result.threshold_tokens} tokens — OVER BUDGET"

229 )

230 if result.violations:

231 print("\nTop contributors (≥200 tokens each):")

232 for skill_md, _, tokens in result.violations:

233 print(f" {tokens:>6} {skill_md.parent.name}")

234 return 1

235

236

237 def main_verify_skills() -> int:

238 """Entry point for ll-verify-skills command.

239

240 Scan SKILL.md files and fail if any exceed the 500-line limit.

241

242 Returns:

243 Exit code (0 = all within limit, 1 = violations found)

244 """

245 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-skills", sys.argv[1:]):

246 from little_loops.doc_counts import check_skill_sizes

247

248 parser = argparse.ArgumentParser(

249 prog="ll-verify-skills",

250 description="Verify no SKILL.md file exceeds the 500-line limit",

251 formatter_class=argparse.RawDescriptionHelpFormatter,

252 epilog="""

253 Examples:

254 %(prog)s # Check against default 500-line limit

255 %(prog)s --limit 400 # Custom line limit

256 %(prog)s --json # Output as JSON

257

258 Exit codes:

259 0 - All SKILL.md files within limit

260 1 - One or more violations found

261""",

262 )

263

264 parser.add_argument(

265 "--limit",

266 type=int,

267 default=500,

268 metavar="N",

269 help="Maximum allowed lines per SKILL.md (default: 500)",

270 )

271

272 parser.add_argument(

273 "-C",

274 "--directory",

275 type=Path,

276 default=None,

277 help="Base directory (default: current directory)",

278 )

279

280 add_json_arg(parser)

281

282 args = parser.parse_args()

283

284 configure_output()

285 logger = Logger(use_color=use_color_enabled())

286

287 base_dir = args.directory or Path.cwd()

288

289 violations = check_skill_sizes(base_dir=base_dir, limit=args.limit)

290

291 if args.json:

292 print_json(

293 {

294 "ok": len(violations) == 0,

295 "limit": args.limit,

296 "violations": [

297 {"name": path.parent.name, "lines": lines} for path, lines in violations

298 ],

299 }

300 )

301 return 0 if not violations else 1

302

303 if not violations:

304 logger.success(f"All SKILL.md files within {args.limit}-line limit")

305 return 0

306

307 logger.error(f"{len(violations)} SKILL.md file(s) exceed the {args.limit}-line limit:")

308 for path, lines in violations:

309 print(f" {lines:>6} lines {path.parent.name}/SKILL.md")

310 return 1

311

312

313 def main_check_links() -> int:

314 """Entry point for ll-check-links command.

315

316 Check markdown documentation for broken links.

317

318 Returns:

319 Exit code (0 = all links valid, 1 = broken links found, 2 = error)

320 """

321 with cli_event_context(DEFAULT_DB_PATH, "ll-check-links", sys.argv[1:]):

322 from little_loops.link_checker import (

323 check_markdown_links,

324 format_result_json,

325 format_result_markdown,

326 format_result_text,

327 load_ignore_patterns,

328 )

329

330 parser = argparse.ArgumentParser(

331 prog="ll-check-links",

332 description="Check markdown documentation for broken links",

333 formatter_class=argparse.RawDescriptionHelpFormatter,

334 epilog="""

335 Examples:

336 %(prog)s # Check all markdown files

337 %(prog)s --json # Output as JSON

338 %(prog)s --format markdown # Markdown report

339 %(prog)s docs/ # Check specific directory

340 %(prog)s --ignore 'http://localhost.*' # Ignore pattern

341

342 Exit codes:

343 0 - All links valid

344 1 - Broken links found

345 2 - Error occurred

346""",

347 )

348

349 parser.add_argument(

350 "-j",

351 "--json",

352 action="store_true",

353 help="Output as JSON",

354 )

355

356 parser.add_argument(

357 "-f",

358 "--format",

359 choices=["text", "json", "markdown"],

360 default="text",

361 help="Output format (default: text)",

362 )

363

364 parser.add_argument(

365 "-C",

366 "--directory",

367 type=Path,

368 default=None,

369 help="Base directory (default: current directory)",

370 )

371

372 parser.add_argument(

373 "--ignore",

374 action="append",

375 default=[],

376 help="Ignore URL patterns (can be used multiple times)",

377 )

378

379 parser.add_argument(

380 "--timeout",

381 "-t",

382 type=int,

383 default=10,

384 help="Request timeout in seconds (default: 10)",

385 )

386

387 parser.add_argument(

388 "-w",

389 "--workers",

390 type=int,

391 default=10,

392 help="Maximum concurrent HTTP requests (default: 10)",

393 )

394

395 parser.add_argument(

396 "-v",

397 "--verbose",

398 action="store_true",

399 help="Show verbose output",

400 )

401

402 args = parser.parse_args()

403

404 configure_output()

405

406 # Determine base directory

407 base_dir = args.directory or Path.cwd()

408

409 # Load ignore patterns from config + CLI args

410 ignore_patterns = load_ignore_patterns(base_dir)

411 ignore_patterns.extend(args.ignore)

412

413 # Run link check

414 result = check_markdown_links(

415 base_dir, ignore_patterns, args.timeout, args.verbose, args.workers

416 )

417

418 # Format output

419 if args.json or args.format == "json":

420 output = format_result_json(result)

421 elif args.format == "markdown":

422 output = format_result_markdown(result)

423 else:

424 output = format_result_text(result)

425

426 print(output)

427

428 # Return exit code based on results

429 if result.has_errors:

430 return 1

431 return 0