-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.py
More file actions
600 lines (495 loc) · 18.7 KB
/
cli.py
File metadata and controls
600 lines (495 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
"""CLI entry point for attune-author.
Provides commands for help system initialization, staleness
checking, template generation, and document generation.
Usage:
attune-author init # Bootstrap .help/
attune-author status # Show staleness report
attune-author generate <feat> # Generate templates
attune-author regenerate # Regenerate stale
attune-author docs <path> # Generate docs (AI)
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from attune_author.mcp.path_validation import validate_file_path
logger = logging.getLogger(__name__)
def _build_parser() -> argparse.ArgumentParser:
"""Build the top-level argparse parser for attune-author.
Keeping parser construction in its own function lets unit
tests exercise argument parsing without invoking the full
``main()`` dispatch and lets ``main()`` itself stay short.
Returns:
A fully-configured parser with every subcommand wired.
"""
parser = argparse.ArgumentParser(
prog="attune-author",
description="Documentation authoring and maintenance for the attune ecosystem.",
epilog=(
"Examples:\n"
" attune-author init Scan project and propose features\n"
" attune-author status Show which templates are stale\n"
" attune-author generate auth Generate templates for 'auth' feature\n"
" attune-author regenerate --dry-run List stale features without regenerating\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {_get_version()}",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Enable verbose logging.",
)
sub = parser.add_subparsers(dest="command", help="Available commands")
p_init = sub.add_parser(
"init",
help="Initialize .help/ in the current project",
description=(
"Scan the project and propose a features.yaml manifest listing "
"each subsystem's source files. Safe to re-run: exits cleanly if "
"a manifest already exists."
),
)
p_init.add_argument(
"--project-root",
default=".",
help="Project root directory (default: %(default)s).",
)
p_status = sub.add_parser(
"status",
help="Show staleness report",
description=(
"Report which features have help templates that are out of sync "
"with their source files, based on content hashes stored in "
"template frontmatter."
),
)
p_status.add_argument(
"--help-dir",
default=".help",
help="Path to .help/ directory (default: %(default)s).",
)
p_status.add_argument(
"--project-root",
default=".",
help="Project root directory (default: %(default)s).",
)
p_gen = sub.add_parser(
"generate",
help="Generate templates for a feature",
description=(
"Render help templates (concept, task, reference, quickstart, "
"etc.) for a single feature from its source files. Skips files "
"marked 'status: manual' in frontmatter unless --overwrite is given."
),
)
# Positional is optional so we can print a contextual error
# (with the list of available features) instead of argparse's
# terse "the following arguments are required" message.
p_gen.add_argument("feature", nargs="?", help="Feature name to generate.")
p_gen.add_argument(
"--help-dir",
default=".help",
help="Path to .help/ directory (default: %(default)s).",
)
p_gen.add_argument(
"--project-root",
default=".",
help="Project root directory (default: %(default)s).",
)
p_gen.add_argument(
"--overwrite",
action="store_true",
help="Overwrite templates marked 'status: manual' in their frontmatter.",
)
p_gen.add_argument(
"--no-rag",
action="store_true",
help=(
"Disable RAG grounding during polish. By default, when "
"attune-author[rag] is installed the polish pass consults "
"existing attune-help templates for style / naming "
"references. Set this flag (or ATTUNE_AUTHOR_RAG=0 in the "
"environment) to skip retrieval and use the bare prompt."
),
)
p_gen.add_argument(
"--all-kinds",
action="store_true",
help=(
"Generate every template kind: .help/ kinds (concept, task, "
"reference, quickstart, faq, error, warning, tip, note, "
"comparison, troubleshooting) plus project-doc kinds that "
"write to docs/ (how-to, tutorial, cli-reference, "
"architecture). Use this for full help and docs coverage."
),
)
p_regen = sub.add_parser(
"regenerate",
help="Regenerate all stale templates",
description=(
"Detect stale features (by source hash mismatch) and regenerate "
"their templates. Use --dry-run to preview without writing."
),
)
p_regen.add_argument(
"--help-dir",
default=".help",
help="Path to .help/ directory (default: %(default)s).",
)
p_regen.add_argument(
"--project-root",
default=".",
help="Project root directory (default: %(default)s).",
)
p_regen.add_argument(
"--dry-run",
action="store_true",
help="Report stale features without regenerating.",
)
p_cache = sub.add_parser(
"cache",
help="Manage the on-disk polish cache",
description=(
"Inspect and clear the on-disk LLM polish cache used by the "
"generator. Entries are pruned automatically by mtime (default "
"TTL 30 days, configurable via ATTUNE_AUTHOR_POLISH_CACHE_TTL_SECONDS); "
"this command exposes a manual nuke."
),
)
cache_sub = p_cache.add_subparsers(dest="cache_command", help="Cache subcommands")
cache_sub.add_parser(
"clear",
help="Delete every cached polish entry",
description=(
"Remove all entries from the polish cache directory. Useful "
"after a prompt change in attune-author itself, or to reclaim "
"disk space without waiting for the TTL sweep."
),
)
p_docs = sub.add_parser(
"docs",
help="Generate docs from source (requires [ai])",
description=(
"Generate documentation from a source file or module using the "
"three-stage LLM pipeline (outline, write, review). Requires the "
"[ai] extra and ANTHROPIC_API_KEY in the environment."
),
)
# Optional so the handler can print a contextual usage hint.
p_docs.add_argument("target", nargs="?", help="Source file or module to document.")
p_docs.add_argument("--output", "-o", help="Output file path (default: stdout).")
p_docs.add_argument(
"--doc-type",
default="api-reference",
help="Documentation type (default: %(default)s).",
)
p_docs.add_argument(
"--audience",
default="developers",
help="Target audience (default: %(default)s).",
)
return parser
def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
"""Run the subcommand selected on ``args``.
Split out from :func:`main` so the dispatch table stays
testable in isolation and so :func:`main` is thin enough
to read at a glance.
Args:
args: Parsed arguments.
parser: The top-level parser, used for the ``--help``
fallback when no subcommand is given.
Returns:
Process exit code.
"""
if not args.command:
_print_welcome()
return 0
handlers = {
"init": _cmd_init,
"status": _cmd_status,
"generate": _cmd_generate,
"regenerate": _cmd_regenerate,
"docs": _cmd_docs,
"cache": _cmd_cache,
}
handler = handlers.get(args.command)
if handler is None:
parser.print_help()
return 0
try:
return handler(args)
except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
def main(argv: list[str] | None = None) -> int:
"""CLI entry point.
Args:
argv: Command-line arguments (defaults to sys.argv[1:]).
Returns:
Exit code (0 for success).
"""
# Load .env early so downstream code (polish, doc-gen) can
# find ANTHROPIC_API_KEY without users exporting it manually.
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
parser = _build_parser()
args = parser.parse_args(argv)
if args.verbose:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO, format="%(message)s")
return _dispatch(args, parser)
def _cmd_init(args: argparse.Namespace) -> int:
"""Handle the init command."""
from attune_author.bootstrap import proposals_to_manifest, scan_project
from attune_author.manifest import save_manifest
root = validate_file_path(args.project_root)
help_dir = root / ".help"
if (help_dir / "features.yaml").exists():
print(f"Already initialized: {help_dir / 'features.yaml'}")
return 0
print(f"Scanning {root}...")
proposals = scan_project(root)
if not proposals:
print("No features discovered. Create .help/features.yaml manually.")
return 0
print(f"\nDiscovered {len(proposals)} features:\n")
for p in proposals:
conf = {"high": "+", "medium": "~", "low": "?"}
marker = conf.get(p.confidence, "?")
print(f" [{marker}] {p.name} — {p.description}")
print(f" files: {', '.join(p.files)}")
manifest = proposals_to_manifest(proposals)
path = save_manifest(manifest, help_dir)
print(f"\nSaved {len(proposals)} features to {path}")
print("Edit .help/features.yaml to refine, then run: attune-author generate <feature>")
return 0
def _print_missing_manifest_hint(help_dir: Path) -> None:
"""Print a friendly hint when features.yaml is missing."""
print(
f"No manifest at {help_dir / 'features.yaml'}. Run `attune-author init` first.",
file=sys.stderr,
)
def _cmd_status(args: argparse.Namespace) -> int:
"""Handle the status command."""
from attune_author.maintenance import format_status_report
from attune_author.manifest import load_manifest
from attune_author.staleness import check_staleness
root = validate_file_path(args.project_root)
help_dir = validate_file_path(args.help_dir)
try:
manifest = load_manifest(help_dir)
except FileNotFoundError:
_print_missing_manifest_hint(help_dir)
return 1
report = check_staleness(manifest, help_dir, root)
print(format_status_report(report, help_dir))
return 0
def _cmd_generate(args: argparse.Namespace) -> int:
"""Handle the generate command."""
from attune_author.generator import generate_feature_templates
from attune_author.manifest import load_manifest
root = validate_file_path(args.project_root)
help_dir = validate_file_path(args.help_dir)
if not args.feature:
_print_generate_usage(help_dir)
return 1
try:
manifest = load_manifest(help_dir)
except FileNotFoundError:
_print_missing_manifest_hint(help_dir)
return 1
feature = manifest.features.get(args.feature)
if not feature:
print(f"Feature '{args.feature}' not found in manifest.", file=sys.stderr)
if manifest.features:
print(
f"Available: {', '.join(sorted(manifest.features))}",
file=sys.stderr,
)
return 1
from attune_author.generator import _ALL_TEMPLATE_NAMES
result = generate_feature_templates(
feature=feature,
help_dir=help_dir,
project_root=root,
depths=list(_ALL_TEMPLATE_NAMES) if args.all_kinds else None,
overwrite=args.overwrite,
use_rag=not args.no_rag,
)
if result.templates:
print(f"Generated {len(result.templates)} templates for '{args.feature}':")
for t in result.templates:
print(f" {t.path}")
else:
print("No templates generated (all may be manual).")
return 0
def _cmd_regenerate(args: argparse.Namespace) -> int:
"""Handle the regenerate command."""
from attune_author.maintenance import run_maintenance
root = validate_file_path(args.project_root)
help_dir = validate_file_path(args.help_dir)
try:
result = run_maintenance(
help_dir=help_dir,
project_root=root,
dry_run=args.dry_run,
)
except FileNotFoundError:
_print_missing_manifest_hint(help_dir)
return 1
if args.dry_run:
print(f"Stale features: {result.stale_count}")
for name in result.staleness.stale_features:
print(f" - {name}")
else:
print(f"Regenerated: {result.regenerated_count}")
if result.failed:
print(f"Failed: {', '.join(result.failed)}")
return 0
def _cmd_cache(args: argparse.Namespace) -> int:
"""Handle the cache command and its subcommands."""
from attune_author.polish import _cache_dir, clear_cache
if args.cache_command == "clear":
deleted = clear_cache()
cache_path = _cache_dir()
print(f"Cleared {deleted} entries from {cache_path}")
return 0
print("Usage: attune-author cache clear", file=sys.stderr)
return 1
def _cmd_docs(args: argparse.Namespace) -> int:
"""Handle the docs command."""
if not args.target:
_print_docs_usage()
return 1
try:
from attune_author.doc_gen import DocGenConfig, generate_docs
except ImportError:
print(
"Doc generation requires the [ai] extra:\n pip install 'attune-author[ai]'",
file=sys.stderr,
)
return 1
target = str(validate_file_path(args.target))
output = str(validate_file_path(args.output)) if args.output else None
config = DocGenConfig(
doc_type=args.doc_type,
audience=args.audience,
)
result = generate_docs(
target=target,
config=config,
output_path=output,
)
if args.output:
print(f"Documentation written to {args.output}")
else:
print(result.content)
return 0
def _get_version() -> str:
"""Get package version."""
try:
from attune_author import __version__
return __version__
except ImportError:
return "dev"
_WELCOME_HEADER = "attune-author — documentation authoring for the attune ecosystem"
_MAX_FEATURES_IN_WELCOME = 8
def _print_welcome() -> None:
"""Print the zero-arg welcome screen.
Detects whether ``.help/features.yaml`` exists in the current
working directory and adjusts the suggested next command. A
missing or broken manifest falls through to the "not set up
yet" path so a stranger running the tool cold never sees a
traceback.
"""
print(_WELCOME_HEADER)
print()
features = _load_feature_names_for_welcome()
if features is None:
print("It looks like this project isn't set up yet.")
print()
print("Get started:")
print(" attune-author init Scan your project and propose features")
print()
print("Other commands: status, generate, regenerate, docs")
print("Run `attune-author --help` for the full reference.")
return
shown = features[:_MAX_FEATURES_IN_WELCOME]
suffix = ", …" if len(features) > _MAX_FEATURES_IN_WELCOME else ""
print(f"Found {len(features)} features in .help/features.yaml:")
print(f" {', '.join(shown)}{suffix}")
print()
print("Try:")
print(" attune-author status Check for stale docs")
print(" attune-author generate <feature> Generate templates for a feature")
print()
print("Run `attune-author --help` for the full reference.")
def _print_generate_usage(help_dir: Path) -> None:
"""Print a contextual usage hint for `generate` with no feature.
Tries to list the feature names from the manifest so the user
sees exactly what they can pass. Falls back to a generic hint
if the manifest is missing or unreadable.
"""
print("Usage: attune-author generate <feature>", file=sys.stderr)
print(" Generates concept/task/reference templates for a feature.", file=sys.stderr)
try:
from attune_author.manifest import load_manifest
manifest = load_manifest(help_dir)
except Exception: # noqa: BLE001
print(
f"\nNo manifest found at {help_dir / 'features.yaml'}. "
"Run `attune-author init` first.",
file=sys.stderr,
)
return
if not manifest.features:
print(
"\nThe manifest has no features yet — edit "
f"{help_dir / 'features.yaml'} or re-run `attune-author init`.",
file=sys.stderr,
)
return
names = sorted(manifest.features.keys())
print(f"\nAvailable features: {', '.join(names)}", file=sys.stderr)
def _print_docs_usage() -> None:
"""Print a contextual usage hint for `docs` with no target."""
print("Usage: attune-author docs <target> [--output FILE]", file=sys.stderr)
print(
" Generates documentation for a Python file or module using AI.",
file=sys.stderr,
)
print(" Example: attune-author docs src/myapp/auth.py", file=sys.stderr)
print(" Requires the [ai] extra: pip install 'attune-author[ai]'", file=sys.stderr)
def _load_feature_names_for_welcome() -> list[str] | None:
"""Return a sorted list of feature names, or None if unusable.
Returns None when ``.help/features.yaml`` is missing, unreadable,
malformed, or contains zero features — any of which means the
welcome screen should treat the project as "not set up yet".
Swallows every exception on purpose: this is UI, not a loader.
"""
help_dir = Path(".help")
if not (help_dir / "features.yaml").exists():
return None
try:
from attune_author.manifest import load_manifest
manifest = load_manifest(help_dir)
except Exception: # noqa: BLE001
# INTENTIONAL: any failure here means we should show the
# "not set up yet" screen — a corrupt manifest must not
# crash a bare `attune-author` invocation.
return None
names = sorted(manifest.features.keys())
return names or None
if __name__ == "__main__":
sys.exit(main())