diff --git a/README.md b/README.md index 74f8745..e86482b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ codelens context ### Zero-config for AI agents +**New here? Run `codelens --guide` first** (add `--format json` for a machine-parseable form). It prints a task→command map with copy-paste examples and the positional conventions, so an agent knows exactly what to run without guessing. + If no `.codelens/` registry exists yet, any analysis command auto-runs `scan` first — no separate init step required: ```bash diff --git a/scripts/agent_guide.py b/scripts/agent_guide.py new file mode 100644 index 0000000..7d54123 --- /dev/null +++ b/scripts/agent_guide.py @@ -0,0 +1,140 @@ +# @WHO: scripts/agent_guide.py +# @WHAT: Task-oriented usage guide for agents (`codelens --guide`) +# @PART: cli +# @ENTRY: build_guide() +"""The guide a new agent reads to use CodeLens without guessing. + +`codelens --help` lists commands; this answers "I want to do X — what do I +run?" with copy-paste examples, spells out the positional conventions, and +bakes in the traps found by dogfooding (search's pattern positional, trace's +--domain backend). Sub-checks are derived live from each umbrella's `_CHECKS` +so the guide can't drift from the code. +""" + +import importlib +import json +from typing import Any, Dict, List + + +# Task → command. Intent can't be derived from the registry, so this is +# curated — but every command here is a real, tested invocation. `` is the +# workspace path (auto-detected if omitted); `FN` a function name. +_TASKS: List[Dict[str, str]] = [ + {"task": "Orient in an unfamiliar codebase", + "run": "codelens context "}, + {"task": "Who calls this function (callers)", + "run": "codelens context --check trace --name FN --direction up --domain backend"}, + {"task": "What does this function call (callees)", + "run": "codelens context --check trace --name FN --direction down --domain backend"}, + {"task": "Read one function's source (skip reading the whole file)", + "run": "codelens context --check source --name FN"}, + {"task": "Blast radius before changing a symbol", + "run": "codelens impact --name FN"}, + {"task": "A file's structure (functions/classes)", + "run": "codelens context --check outline --file path/to/file.py"}, + {"task": "Find a symbol by name", + "run": "codelens search FN --mode symbol", + "note": "PATTERN is the first positional (workspace optional second): " + "`codelens search FN [] --mode symbol` — the reverse of every " + "other command."}, + {"task": "Regex / full-text search", + "run": "codelens search 'PATTERN' --mode regex"}, + {"task": "Find dead code", + "run": "codelens audit --check dead-code"}, + {"task": "Collect a named @FLOW's scattered functions", + "run": "codelens context --check flow --name FLOW_NAME"}, + {"task": "Did a named flow's shape change between two snapshots", + "run": "codelens impact --check flow-diff --name FLOW_NAME"}, + {"task": "Audit @FLOW/@ENTRY doc-tags & coverage", + "run": "codelens context --check tags"}, + {"task": "Circular dependencies", + "run": "codelens deps --check circular"}, + {"task": "Scan / (re)build the graph for a workspace", + "run": "codelens scan "}, + {"task": "Scan for secrets", + "run": "codelens security --check secrets"}, +] + +_CONVENTIONS = [ + "Most commands take the workspace as the first positional: " + "`codelens [--check X] [--name Y]`.", + "EXCEPTION — `search` is `codelens search [] " + "--mode symbol|regex|semantic|graph`: the PATTERN is the first positional, " + "the workspace an optional second (not the other way round).", + "Add `--format json` (structured) or `--format compact` (token-lean) for " + "machine parsing; the default is human-readable.", + "`trace` and `context --check context` resolve backend symbols best with " + "`--domain backend` (the default `auto` can miss them).", + "On a CLI argument error with a machine format, the error is printed to " + "stdout as `{\"s\":\"error\",...}` — so an empty result is a real empty, " + "not a hidden error.", + "Run `scan` once so graph-backed checks (impact, trace, flow subgraph, " + "source-by-name) have data; they degrade gracefully without it.", +] + +# Umbrellas whose sub-checks come from a `_CHECKS` dict, plus the odd ones out. +_UMBRELLAS = ["context", "impact", "audit", "deps", "security", "api_map"] +_NON_CHECK = { + "search": "modes: semantic (default) | symbol | regex | graph " + "(via --mode; pattern is positional)", + "summary": "workspace summary (no --check)", + "scan": "build/refresh the registry (no --check)", + "doctor": "environment audit (no --check)", + "history": "historical trend data (no --check)", + "graph": "raw Cypher-subset query (power-user)", +} + + +def _live_checks() -> Dict[str, List[str]]: + """Sub-checks per umbrella, read straight from the code.""" + out: Dict[str, List[str]] = {} + for name in _UMBRELLAS: + try: + mod = importlib.import_module(f"commands.{name}") + checks = getattr(mod, "_CHECKS", None) + if checks: + out[name.replace("_", "-")] = list(checks.keys()) + except Exception: + continue + return out + + +def build_guide(fmt: str = "text") -> Any: + """The agent usage guide. Returns a dict for machine formats, else text.""" + checks = _live_checks() + guide = { + "tool": "codelens", + "how_to_read": "Find your task below and run the command; replace " + "/FN/FLOW_NAME with your values.", + "conventions": _CONVENTIONS, + "tasks": _TASKS, + "commands": { + **{u: {"checks": c} for u, c in checks.items()}, + **{u: {"note": n} for u, n in _NON_CHECK.items()}, + }, + } + + if fmt in ("json", "compact", "ai", "sarif", "graphml", "junit-xml", "gitlab-sast"): + return guide + + return _render_text(guide) + + +def _render_text(guide: Dict) -> str: + lines = ["# CodeLens — agent guide", "", guide["how_to_read"], "", + "## Conventions"] + for c in guide["conventions"]: + lines.append(f"- {c}") + lines += ["", "## Tasks → commands"] + for t in guide["tasks"]: + lines.append(f"- {t['task']}:") + lines.append(f" {t['run']}") + if t.get("note"): + lines.append(f" note: {t['note']}") + lines += ["", "## Commands & sub-checks"] + for cmd, info in guide["commands"].items(): + if "checks" in info: + lines.append(f"- {cmd}: --check " + " | ".join(info["checks"])) + else: + lines.append(f"- {cmd}: {info['note']}") + return "\n".join(lines) diff --git a/scripts/codelens.py b/scripts/codelens.py index 58e2f82..abf359b 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -1142,6 +1142,14 @@ def main(): "and exit. Single source of truth for issue #38 reconciliation. " "Hidden commands are excluded (issues #195/#199/#200).", ) + parser.add_argument( + "--guide", + action="store_true", + default=False, + help="Print a task-oriented usage guide for agents (task→command map, " + "conventions, live sub-checks) and exit. Add --format json for a " + "machine-parseable form.", + ) subparsers = parser.add_subparsers( dest="command", parser_class=_StdoutErrorParser, @@ -1277,6 +1285,19 @@ def main(): print(_command_count) sys.exit(0) + # Handle --guide as a top-level flag (issue #319): a task-oriented usage + # guide for agents. Respects --format so agents can request JSON. + if "--guide" in sys.argv: + from agent_guide import build_guide + fmt = _argv_format(sys.argv) or "text" + guide = build_guide(fmt) + if isinstance(guide, dict): + import json as _json + print(_json.dumps(guide, indent=2)) + else: + print(guide) + sys.exit(0) + # Pre-parse to capture global flags before subparser overwrites them global_format = None global_top = None diff --git a/tests/test_agent_guide.py b/tests/test_agent_guide.py new file mode 100644 index 0000000..8bc5ee0 --- /dev/null +++ b/tests/test_agent_guide.py @@ -0,0 +1,94 @@ +""" +Tests for the agent usage guide (`codelens --guide`, issue #319). + +The guide must never hand an agent a wrong invocation, so the key guard checks +every task command against the live registry and `_CHECKS`: a renamed or removed +sub-check breaks the test rather than silently misleading an agent. +""" + +import importlib +import os +import re +import sys + +import pytest + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +sys.path.insert(0, SCRIPT_DIR) + +from agent_guide import build_guide, _TASKS # noqa: E402 +from commands import get_visible_commands # noqa: E402 + + +def _parse(run): + """(umbrella, check) from a task's `run` string; check is None if absent.""" + parts = run.split() + umbrella = parts[1] if len(parts) > 1 and parts[0] == "codelens" else None + check = None + if "--check" in parts: + i = parts.index("--check") + if i + 1 < len(parts): + check = parts[i + 1] + return umbrella, check + + +# ─── structure ─────────────────────────────────────────── + +def test_json_form_is_machine_readable(): + g = build_guide("json") + assert isinstance(g, dict) + assert g["tasks"] and g["conventions"] and g["commands"] + + +def test_text_form_has_tasks_and_conventions(): + text = build_guide("text") + assert "Tasks" in text + assert "Conventions" in text + assert "codelens context" in text + + +# ─── the guard: guide never lies about invocations ─────── + +def test_every_task_umbrella_is_a_real_command(): + visible = set(get_visible_commands().keys()) + for t in _TASKS: + umbrella, _ = _parse(t["run"]) + assert umbrella in visible, f"task '{t['task']}' names unknown command {umbrella}" + + +def test_every_task_check_exists_in_that_umbrella(): + for t in _TASKS: + umbrella, check = _parse(t["run"]) + if check is None: + continue + mod = importlib.import_module(f"commands.{umbrella.replace('-', '_')}") + checks = getattr(mod, "_CHECKS", {}) + assert check in checks, ( + f"task '{t['task']}' uses --check {check}, absent from {umbrella}._CHECKS" + ) + + +# ─── the dogfood traps are documented ──────────────────── + +def test_search_positional_trap_is_called_out(): + text = build_guide("text").lower() + assert "search" in text and "positional" in text + + +def test_trace_domain_hint_is_present(): + text = build_guide("text") + assert "--domain backend" in text + + +# ─── checks are live, not hardcoded ────────────────────── + +def test_subchecks_are_derived_live(): + g = build_guide("json") + # `source` and `flow` were added late; they must appear without editing here. + assert "source" in g["commands"]["context"]["checks"] + assert "flow" in g["commands"]["context"]["checks"] + assert "flow-diff" in g["commands"]["impact"]["checks"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])