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
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 10 additions & 10 deletions limacharlie/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 <entry>`` 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.

Expand Down