Fix Reckless search command not finding partial matches
What changed, and why it matters
This commit fixes a user-experience bug in the 'reckless' plugin manager tool. Previously, the 'search' command only found plugins if you typed the exact name. Now it also returns plugins whose names contain the search term as a substring. There is no security issue here—it's a straightforward feature/bug-fix for a command-line search tool.
No security action required. Treat as a normal bug-fix/feature improvement.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch modifies tools/reckless to add substring matching to the search() function. A new helper _get_all_plugins_from_source() enumerates plugin directories from each configured source, and search() now first collects and displays all partial matches before falling back to the existing exact-match logic used for installation. Tests in tests/test_reckless.py are added to verify partial and negative substring matching. No cryptographic, network, or privilege-related code is changed.
Changed components
tools/recklesstests/test_reckless.pyInspect captured patch +81 / −9
diff --git a/tests/test_reckless.py b/tests/test_reckless.py
index 94632a24..d294b79a 100644
--- a/tests/test_reckless.py
+++ b/tests/test_reckless.py
@@ -224,6 +224,32 @@ def test_search(node_factory):
assert r.search_stdout('found testplugpass in source: https://github.com/lightningd/plugins')
+def test_search_partial_match(node_factory):
+ """test that partial/substring search returns multiple matches"""
+ n = get_reckless_node(node_factory)
+
+ # Search for partial name "testplug" - should find all test plugins
+ r = reckless([f"--network={NETWORK}", "search", "testplug"], dir=n.lightning_dir)
+ # Should show the "Plugins matching" header
+ assert r.search_stdout("Plugins matching 'testplug':")
+ # Should list multiple plugins (all start with "testplug")
+ assert r.search_stdout('testplugpass')
+ assert r.search_stdout('testplugfail')
+ assert r.search_stdout('testplugpyproj')
+ assert r.search_stdout('testpluguv')
+
+ # Search for "pass" - should find testplugpass
+ r = reckless([f"--network={NETWORK}", "search", "pass"], dir=n.lightning_dir)
+ assert r.search_stdout("Plugins matching 'pass':")
+ assert r.search_stdout('testplugpass')
+ # Should not find plugins without "pass" in name
+ assert not r.search_stdout('testplugfail')
+
+ # Search for something that doesn't exist
+ r = reckless([f"--network={NETWORK}", "search", "nonexistent"], dir=n.lightning_dir)
+ assert r.search_stdout("Search exhausted all sources")
+
+
def test_install(node_factory):
"""test search, git clone, and installation to folder."""
n = get_reckless_node(node_factory)
diff --git a/tools/reckless b/tools/reckless
index 62a917b6..c3f89bbd 100755
--- a/tools/reckless
+++ b/tools/reckless
@@ -1548,6 +1548,32 @@ def uninstall(plugin_name: str) -> str:
return "uninstalled"
+def _get_all_plugins_from_source(src: str) -> list:
+ """Get all plugin directories from a source repository.
+ Returns a list of (plugin_name, source_url) tuples."""
+ plugins = []
+ srctype = Source.get_type(src)
+ if srctype == Source.UNKNOWN:
+ return plugins
+
+ try:
+ root = SourceDir(src, srctype=srctype)
+ root.populate()
+ except Exception as e:
+ log.debug(f"Failed to populate source {src}: {e}")
+ return plugins
+
+ plugins.append((root.name, src))
+
+ for item in root.contents:
+ if isinstance(item, SourceDir):
+ # Skip archive directories
+ if 'archive' in item.name.lower():
+ continue
+ plugins.append((item.name, src))
+ return plugins
+
+
def search(plugin_name: str) -> Union[InstInfo, None]:
"""searches plugin index for plugin"""
ordered_sources = RECKLESS_SOURCES.copy()
@@ -1563,6 +1589,22 @@ def search(plugin_name: str) -> Union[InstInfo, None]:
if Source.get_type(src) in [Source.DIRECTORY, Source.LOCAL_REPO]:
ordered_sources.remove(src)
ordered_sources.insert(0, src)
+
+ # First, collect all partial matches to display to user
+ partial_matches = []
+ for source in ordered_sources:
+ for plugin_name_found, src_url in _get_all_plugins_from_source(source):
+ if plugin_name.lower() in plugin_name_found.lower():
+ partial_matches.append((plugin_name_found, src_url))
+
+ # Display all partial matches
+ if partial_matches:
+ log.info(f"Plugins matching '{plugin_name}':")
+ for name, src_url in partial_matches:
+ log.info(f" {name} ({src_url})")
+
+ # Now try exact match for installation purposes
+ exact_match = None
for source in ordered_sources:
srctype = Source.get_type(source)
if srctype == Source.UNKNOWN:
@@ -1573,17 +1615,21 @@ def search(plugin_name: str) -> Union[InstInfo, None]:
found = _source_search(plugin_name, source)
if found:
log.debug(f"{found}, {found.srctype}")
- if not found:
- continue
- log.info(f"found {found.name} in source: {found.source_loc}")
- log.debug(f"entry: {found.entry}")
- if found.subdir:
- log.debug(f'sub-directory: {found.subdir}')
+ exact_match = found
+ break
+
+ if exact_match:
+ log.info(f"found {exact_match.name} in source: {exact_match.source_loc}")
+ log.debug(f"entry: {exact_match.entry}")
+ if exact_match.subdir:
+ log.debug(f'sub-directory: {exact_match.subdir}')
global LAST_FOUND
# Stashing the search result saves install() a call to _source_search.
- LAST_FOUND = found
- return str(found.source_loc)
- log.info("Search exhausted all sources")
+ LAST_FOUND = exact_match
+ return str(exact_match.source_loc)
+
+ if not partial_matches:
+ log.info("Search exhausted all sources")
return None
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.