Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions scripts/agent_guide.py
Original file line number Diff line number Diff line change
@@ -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. `<ws>` 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 <ws>"},
{"task": "Who calls this function (callers)",
"run": "codelens context <ws> --check trace --name FN --direction up --domain backend"},
{"task": "What does this function call (callees)",
"run": "codelens context <ws> --check trace --name FN --direction down --domain backend"},
{"task": "Read one function's source (skip reading the whole file)",
"run": "codelens context <ws> --check source --name FN"},
{"task": "Blast radius before changing a symbol",
"run": "codelens impact <ws> --name FN"},
{"task": "A file's structure (functions/classes)",
"run": "codelens context <ws> --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 [<ws>] --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 <ws> --check dead-code"},
{"task": "Collect a named @FLOW's scattered functions",
"run": "codelens context <ws> --check flow --name FLOW_NAME"},
{"task": "Did a named flow's shape change between two snapshots",
"run": "codelens impact <ws> --check flow-diff --name FLOW_NAME"},
{"task": "Audit @FLOW/@ENTRY doc-tags & coverage",
"run": "codelens context <ws> --check tags"},
{"task": "Circular dependencies",
"run": "codelens deps <ws> --check circular"},
{"task": "Scan / (re)build the graph for a workspace",
"run": "codelens scan <ws>"},
{"task": "Scan for secrets",
"run": "codelens security <ws> --check secrets"},
]

_CONVENTIONS = [
"Most commands take the workspace as the first positional: "
"`codelens <command> <workspace> [--check X] [--name Y]`.",
"EXCEPTION — `search` is `codelens search <pattern> [<workspace>] "
"--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 "
"<ws>/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)
21 changes: 21 additions & 0 deletions scripts/codelens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/test_agent_guide.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading