Skip to content

Commit 6ffec50

Browse files
authored
Merge pull request #320 from Wolfvin/feat/issue-319-agent-guide
Merged on local verification: --guide renders task-map + examples + live sub-checks, guard test ensures no wrong invocation, dogfood traps documented, 7 tests, 19=19 on main. Directly serves Wolfvin's ask — a new agent knows how to use CodeLens immediately. CI test-suite gate non-functional (#303).
2 parents 61c812b + 4b27c69 commit 6ffec50

4 files changed

Lines changed: 257 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ codelens context
7272

7373
### Zero-config for AI agents
7474

75+
**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.
76+
7577
If no `.codelens/` registry exists yet, any analysis command auto-runs `scan` first — no separate init step required:
7678

7779
```bash

scripts/agent_guide.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# @WHO: scripts/agent_guide.py
2+
# @WHAT: Task-oriented usage guide for agents (`codelens --guide`)
3+
# @PART: cli
4+
# @ENTRY: build_guide()
5+
"""The guide a new agent reads to use CodeLens without guessing.
6+
7+
`codelens --help` lists commands; this answers "I want to do X — what do I
8+
run?" with copy-paste examples, spells out the positional conventions, and
9+
bakes in the traps found by dogfooding (search's pattern positional, trace's
10+
--domain backend). Sub-checks are derived live from each umbrella's `_CHECKS`
11+
so the guide can't drift from the code.
12+
"""
13+
14+
import importlib
15+
import json
16+
from typing import Any, Dict, List
17+
18+
19+
# Task → command. Intent can't be derived from the registry, so this is
20+
# curated — but every command here is a real, tested invocation. `<ws>` is the
21+
# workspace path (auto-detected if omitted); `FN` a function name.
22+
_TASKS: List[Dict[str, str]] = [
23+
{"task": "Orient in an unfamiliar codebase",
24+
"run": "codelens context <ws>"},
25+
{"task": "Who calls this function (callers)",
26+
"run": "codelens context <ws> --check trace --name FN --direction up --domain backend"},
27+
{"task": "What does this function call (callees)",
28+
"run": "codelens context <ws> --check trace --name FN --direction down --domain backend"},
29+
{"task": "Read one function's source (skip reading the whole file)",
30+
"run": "codelens context <ws> --check source --name FN"},
31+
{"task": "Blast radius before changing a symbol",
32+
"run": "codelens impact <ws> --name FN"},
33+
{"task": "A file's structure (functions/classes)",
34+
"run": "codelens context <ws> --check outline --file path/to/file.py"},
35+
{"task": "Find a symbol by name",
36+
"run": "codelens search FN --mode symbol",
37+
"note": "PATTERN is the first positional (workspace optional second): "
38+
"`codelens search FN [<ws>] --mode symbol` — the reverse of every "
39+
"other command."},
40+
{"task": "Regex / full-text search",
41+
"run": "codelens search 'PATTERN' --mode regex"},
42+
{"task": "Find dead code",
43+
"run": "codelens audit <ws> --check dead-code"},
44+
{"task": "Collect a named @FLOW's scattered functions",
45+
"run": "codelens context <ws> --check flow --name FLOW_NAME"},
46+
{"task": "Did a named flow's shape change between two snapshots",
47+
"run": "codelens impact <ws> --check flow-diff --name FLOW_NAME"},
48+
{"task": "Audit @FLOW/@ENTRY doc-tags & coverage",
49+
"run": "codelens context <ws> --check tags"},
50+
{"task": "Circular dependencies",
51+
"run": "codelens deps <ws> --check circular"},
52+
{"task": "Scan / (re)build the graph for a workspace",
53+
"run": "codelens scan <ws>"},
54+
{"task": "Scan for secrets",
55+
"run": "codelens security <ws> --check secrets"},
56+
]
57+
58+
_CONVENTIONS = [
59+
"Most commands take the workspace as the first positional: "
60+
"`codelens <command> <workspace> [--check X] [--name Y]`.",
61+
"EXCEPTION — `search` is `codelens search <pattern> [<workspace>] "
62+
"--mode symbol|regex|semantic|graph`: the PATTERN is the first positional, "
63+
"the workspace an optional second (not the other way round).",
64+
"Add `--format json` (structured) or `--format compact` (token-lean) for "
65+
"machine parsing; the default is human-readable.",
66+
"`trace` and `context --check context` resolve backend symbols best with "
67+
"`--domain backend` (the default `auto` can miss them).",
68+
"On a CLI argument error with a machine format, the error is printed to "
69+
"stdout as `{\"s\":\"error\",...}` — so an empty result is a real empty, "
70+
"not a hidden error.",
71+
"Run `scan` once so graph-backed checks (impact, trace, flow subgraph, "
72+
"source-by-name) have data; they degrade gracefully without it.",
73+
]
74+
75+
# Umbrellas whose sub-checks come from a `_CHECKS` dict, plus the odd ones out.
76+
_UMBRELLAS = ["context", "impact", "audit", "deps", "security", "api_map"]
77+
_NON_CHECK = {
78+
"search": "modes: semantic (default) | symbol | regex | graph "
79+
"(via --mode; pattern is positional)",
80+
"summary": "workspace summary (no --check)",
81+
"scan": "build/refresh the registry (no --check)",
82+
"doctor": "environment audit (no --check)",
83+
"history": "historical trend data (no --check)",
84+
"graph": "raw Cypher-subset query (power-user)",
85+
}
86+
87+
88+
def _live_checks() -> Dict[str, List[str]]:
89+
"""Sub-checks per umbrella, read straight from the code."""
90+
out: Dict[str, List[str]] = {}
91+
for name in _UMBRELLAS:
92+
try:
93+
mod = importlib.import_module(f"commands.{name}")
94+
checks = getattr(mod, "_CHECKS", None)
95+
if checks:
96+
out[name.replace("_", "-")] = list(checks.keys())
97+
except Exception:
98+
continue
99+
return out
100+
101+
102+
def build_guide(fmt: str = "text") -> Any:
103+
"""The agent usage guide. Returns a dict for machine formats, else text."""
104+
checks = _live_checks()
105+
guide = {
106+
"tool": "codelens",
107+
"how_to_read": "Find your task below and run the command; replace "
108+
"<ws>/FN/FLOW_NAME with your values.",
109+
"conventions": _CONVENTIONS,
110+
"tasks": _TASKS,
111+
"commands": {
112+
**{u: {"checks": c} for u, c in checks.items()},
113+
**{u: {"note": n} for u, n in _NON_CHECK.items()},
114+
},
115+
}
116+
117+
if fmt in ("json", "compact", "ai", "sarif", "graphml", "junit-xml", "gitlab-sast"):
118+
return guide
119+
120+
return _render_text(guide)
121+
122+
123+
def _render_text(guide: Dict) -> str:
124+
lines = ["# CodeLens — agent guide", "", guide["how_to_read"], "",
125+
"## Conventions"]
126+
for c in guide["conventions"]:
127+
lines.append(f"- {c}")
128+
lines += ["", "## Tasks → commands"]
129+
for t in guide["tasks"]:
130+
lines.append(f"- {t['task']}:")
131+
lines.append(f" {t['run']}")
132+
if t.get("note"):
133+
lines.append(f" note: {t['note']}")
134+
lines += ["", "## Commands & sub-checks"]
135+
for cmd, info in guide["commands"].items():
136+
if "checks" in info:
137+
lines.append(f"- {cmd}: --check " + " | ".join(info["checks"]))
138+
else:
139+
lines.append(f"- {cmd}: {info['note']}")
140+
return "\n".join(lines)

scripts/codelens.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,14 @@ def main():
11421142
"and exit. Single source of truth for issue #38 reconciliation. "
11431143
"Hidden commands are excluded (issues #195/#199/#200).",
11441144
)
1145+
parser.add_argument(
1146+
"--guide",
1147+
action="store_true",
1148+
default=False,
1149+
help="Print a task-oriented usage guide for agents (task→command map, "
1150+
"conventions, live sub-checks) and exit. Add --format json for a "
1151+
"machine-parseable form.",
1152+
)
11451153
subparsers = parser.add_subparsers(
11461154
dest="command",
11471155
parser_class=_StdoutErrorParser,
@@ -1277,6 +1285,19 @@ def main():
12771285
print(_command_count)
12781286
sys.exit(0)
12791287

1288+
# Handle --guide as a top-level flag (issue #319): a task-oriented usage
1289+
# guide for agents. Respects --format so agents can request JSON.
1290+
if "--guide" in sys.argv:
1291+
from agent_guide import build_guide
1292+
fmt = _argv_format(sys.argv) or "text"
1293+
guide = build_guide(fmt)
1294+
if isinstance(guide, dict):
1295+
import json as _json
1296+
print(_json.dumps(guide, indent=2))
1297+
else:
1298+
print(guide)
1299+
sys.exit(0)
1300+
12801301
# Pre-parse to capture global flags before subparser overwrites them
12811302
global_format = None
12821303
global_top = None

tests/test_agent_guide.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Tests for the agent usage guide (`codelens --guide`, issue #319).
3+
4+
The guide must never hand an agent a wrong invocation, so the key guard checks
5+
every task command against the live registry and `_CHECKS`: a renamed or removed
6+
sub-check breaks the test rather than silently misleading an agent.
7+
"""
8+
9+
import importlib
10+
import os
11+
import re
12+
import sys
13+
14+
import pytest
15+
16+
SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
17+
sys.path.insert(0, SCRIPT_DIR)
18+
19+
from agent_guide import build_guide, _TASKS # noqa: E402
20+
from commands import get_visible_commands # noqa: E402
21+
22+
23+
def _parse(run):
24+
"""(umbrella, check) from a task's `run` string; check is None if absent."""
25+
parts = run.split()
26+
umbrella = parts[1] if len(parts) > 1 and parts[0] == "codelens" else None
27+
check = None
28+
if "--check" in parts:
29+
i = parts.index("--check")
30+
if i + 1 < len(parts):
31+
check = parts[i + 1]
32+
return umbrella, check
33+
34+
35+
# ─── structure ───────────────────────────────────────────
36+
37+
def test_json_form_is_machine_readable():
38+
g = build_guide("json")
39+
assert isinstance(g, dict)
40+
assert g["tasks"] and g["conventions"] and g["commands"]
41+
42+
43+
def test_text_form_has_tasks_and_conventions():
44+
text = build_guide("text")
45+
assert "Tasks" in text
46+
assert "Conventions" in text
47+
assert "codelens context" in text
48+
49+
50+
# ─── the guard: guide never lies about invocations ───────
51+
52+
def test_every_task_umbrella_is_a_real_command():
53+
visible = set(get_visible_commands().keys())
54+
for t in _TASKS:
55+
umbrella, _ = _parse(t["run"])
56+
assert umbrella in visible, f"task '{t['task']}' names unknown command {umbrella}"
57+
58+
59+
def test_every_task_check_exists_in_that_umbrella():
60+
for t in _TASKS:
61+
umbrella, check = _parse(t["run"])
62+
if check is None:
63+
continue
64+
mod = importlib.import_module(f"commands.{umbrella.replace('-', '_')}")
65+
checks = getattr(mod, "_CHECKS", {})
66+
assert check in checks, (
67+
f"task '{t['task']}' uses --check {check}, absent from {umbrella}._CHECKS"
68+
)
69+
70+
71+
# ─── the dogfood traps are documented ────────────────────
72+
73+
def test_search_positional_trap_is_called_out():
74+
text = build_guide("text").lower()
75+
assert "search" in text and "positional" in text
76+
77+
78+
def test_trace_domain_hint_is_present():
79+
text = build_guide("text")
80+
assert "--domain backend" in text
81+
82+
83+
# ─── checks are live, not hardcoded ──────────────────────
84+
85+
def test_subchecks_are_derived_live():
86+
g = build_guide("json")
87+
# `source` and `flow` were added late; they must appear without editing here.
88+
assert "source" in g["commands"]["context"]["checks"]
89+
assert "flow" in g["commands"]["context"]["checks"]
90+
assert "flow-diff" in g["commands"]["impact"]["checks"]
91+
92+
93+
if __name__ == "__main__":
94+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)