diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5220dcdc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# python-limacharlie + +Python SDK and CLI for LimaCharlie. `NEW_CLI.md` is the CLI design document. +`.claude/docs/sdk-docstring-conventions.md` is the docstring checklist for +`limacharlie/sdk/`. + +Run the tests the way CI does: `pytest tests/unit/ tests/microbenchmarks/`. + +## Adding, renaming, or moving a CLI command + +Three hand-maintained maps describe the command surface. None of them updates +itself. Update all of them in the same change: + +1. **`limacharlie/cli.py` — `_COMMAND_MODULE_MAP`.** Maps a top-level command + name to its module so the CLI can lazy-load it. Enforced by + `tests/unit/test_cli_command_map_lint.py`. +2. **`limacharlie/discovery.py` — `PROFILES`.** Groups commands by use-case; + this is what `limacharlie help discover` prints. A verb in no profile is + invisible to anyone — or any agent — discovering the CLI, and an entry + naming a command that no longer exists prints advice that cannot be + followed. Enforced by `tests/unit/test_discovery.py`. +3. **`doc/cli/`.** The user-facing command reference. + +Reuse an existing profile name where one fits. `PROFILES` is meant to mirror +the profiles the LimaCharlie MCP server exposes (`NEW_CLI.md` §1.1), so the +names are a cross-repo contract, not a local choice. + +When the lint reports a `PROFILES` entry that no longer resolves, find the +command's current spelling before touching the entry — most rotted entries were +renamed or moved, not removed. Drop an entry only when the command is genuinely +gone or has become a flag on another command. diff --git a/limacharlie/discovery.py b/limacharlie/discovery.py index c4f90450..799c038b 100644 --- a/limacharlie/discovery.py +++ b/limacharlie/discovery.py @@ -13,7 +13,7 @@ "sensor_management": { "description": "Sensor lifecycle, deployment, and monitoring commands", "commands": [ - "sensor list", "sensor get", "sensor delete", "sensor online", + "sensor list", "sensor get", "sensor delete", "sensor wait-online", "sensor upgrade", "sensor set-version", "sensor export", "sensor dump", "sensor sweep", "tag list", "tag add", "tag remove", "tag find", @@ -26,19 +26,19 @@ "detection_engineering": { "description": "D&R rule creation, testing, deployment, and false positives", "commands": [ - "rule list", "rule get", "rule create", "rule update", "rule delete", - "rule test", "rule replay", "rule validate", "rule export", "rule import", - "fp list", "fp get", "fp create", "fp delete", + "dr list", "dr get", "dr set", "dr delete", + "dr test", "dr replay", "dr validate", "dr export", "dr import", + "dr convert-rules", + "fp list", "fp get", "fp set", "fp delete", "replay run", "ai generate-rule", "ai generate-detection", "ai generate-response", - "dr convert-rules", ], }, "historical_data": { "description": "Searching, querying, and analyzing historical telemetry", "commands": [ - "search run", "search validate", "search estimate", "search interactive", - "search saved list", "search saved get", "search saved create", "search saved delete", + "search run", "search validate", "search estimate", + "search saved-list", "search saved-get", "search saved-create", "search saved-delete", "event list", "event get", "event children", "event overview", "event timeline", "event types", "event schema", "event retention", "detection list", "detection get", @@ -76,14 +76,14 @@ "download sensor", "download adapter", "download list", "sensor upgrade", "sensor set-version", "sensor export", "tag mass-add", "tag mass-remove", - "sync pull", "sync push", "sync diff", + "sync pull", "sync push", ], }, "platform_admin": { "description": "Users, groups, API keys, billing, outputs, and organization management", "commands": [ - "org info", "org list", "org create", "org delete", "org config get", - "org config set", "org urls", "org stats", "org errors", + "org info", "org list", "org create", "org delete", "org config-get", + "org config-set", "org urls", "org stats", "org errors", "user list", "user invite", "user remove", "user permissions list", "group list", "group create", "group delete", "api-key list", "api-key create", "api-key delete", diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index 7d4e9fd8..15586614 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -44,6 +44,39 @@ def test_list_profiles(self): assert "sensor_management" in names +def _resolve(path: str) -> str | None: + """Resolve a profile entry against the live command tree. + + Returns None when the path names a real, runnable command, or a + human-readable reason when it does not. + """ + import importlib + + from limacharlie.cli import _COMMAND_MODULE_MAP + + parts = path.split() + top = parts[0] + if top not in _COMMAND_MODULE_MAP: + return f"no top-level command named {top!r}" + + modname, attr = _COMMAND_MODULE_MAP[top] + cmd = getattr(importlib.import_module(f"limacharlie.commands.{modname}"), attr) + + for i, part in enumerate(parts[1:]): + parent = " ".join(parts[: i + 1]) + if not isinstance(cmd, click.Group): + return f"{parent!r} takes no subcommands, so {part!r} cannot exist" + sub = cmd.commands.get(part) + if sub is None: + options = ", ".join(sorted(cmd.commands)) + return f"{parent!r} has no subcommand {part!r} (it has: {options})" + cmd = sub + + if isinstance(cmd, click.Group): + return f"{path!r} is a group, not a runnable command" + return None + + def _leaf_paths(cmd: click.BaseCommand, prefix: list[str]) -> list[str]: """Every runnable command path under ``cmd``, space-joined.""" if isinstance(cmd, click.Group): @@ -54,6 +87,38 @@ def _leaf_paths(cmd: click.BaseCommand, prefix: list[str]) -> list[str]: return [" ".join(prefix)] +class TestProfileEntriesResolve: + """Every profile entry must name a command that actually exists. + + ``limacharlie help discover`` prints these strings as literal + ``limacharlie `` invocations, so a rotted entry tells an + operator — or an agent — to run something that cannot work. This + catches a command being renamed or moved out from under a profile. + + Note this is deliberately one-directional: it does not require that + every command appear in some profile. Which commands are worth + surfacing is a curation call, and many groups are not profiled yet. + """ + + def test_no_unresolvable_entries(self): + broken = [] + for profile_name, profile in sorted(PROFILES.items()): + for entry in profile["commands"]: + reason = _resolve(entry) + if reason is not None: + broken.append(f' [{profile_name}] "{entry}" -> {reason}') + + if broken: + pytest.fail( + "Discovery profiles advertise commands that do not exist. " + "'limacharlie help discover' prints these verbatim, so each " + "one is advice that cannot be followed:\n\n" + + "\n".join(broken) + + "\n\n Fix the spelling, or drop the entry, in " + "limacharlie/discovery.py." + ) + + class TestMailsecCoverage: """Pin ``mailsec`` command coverage in PROFILES.