Re: git: 93eaa5412bfc - main - security/howdy: Face recognition based authentication provider.
Date: Mon, 03 Apr 2023 15:07:10 UTC
Hi, This seems to be incorrect versioning looking at https://github.com/boltgolt/howdy/tags ? Please follow Porters Handbook in such cases https://docs.freebsd.org/en/books/porters-handbook/book/#makefile-master_sites-github Best regards, Daniel On 2023-04-03 16:15, Gleb Popov wrote: > The branch main has been updated by arrowd: > > URL: > https://cgit.FreeBSD.org/ports/commit/?id=93eaa5412bfca8daf12b7c81110c81b83211f54e > > commit 93eaa5412bfca8daf12b7c81110c81b83211f54e > Author: Alexey Yushkin <636808@mail.ru> > AuthorDate: 2023-04-03 14:06:36 +0000 > Commit: Gleb Popov <arrowd@FreeBSD.org> > CommitDate: 2023-04-03 14:15:32 +0000 > > security/howdy: Face recognition based authentication provider. > > Co-authored-by: Alexey Donskov <voxnod@gmail.com> > Co-authored-by: Gleb Popov <arrowd@FreeBSD.org> > > Sponsored by: Serenity Cybersecurity, LLC > --- > security/Makefile | 1 + > security/howdy/Makefile | 52 +++++ > security/howdy/distinfo | 3 + > security/howdy/files/patch-paths | 429 > +++++++++++++++++++++++++++++++++++++++ > security/howdy/pkg-descr | 3 + > security/howdy/pkg-plist | 27 +++ > 6 files changed, 515 insertions(+) > > diff --git a/security/Makefile b/security/Makefile > index 034e957031c0..2f61c05f25c7 100644 > --- a/security/Makefile > +++ b/security/Makefile > @@ -218,6 +218,7 @@ > SUBDIR += honeytrap > SUBDIR += honggfuzz > SUBDIR += horcrux > + SUBDIR += howdy > SUBDIR += hpenc > SUBDIR += hs-cryptol > SUBDIR += hydra > diff --git a/security/howdy/Makefile b/security/howdy/Makefile > new file mode 100644 > index 000000000000..675fca7c5aca > --- /dev/null > +++ b/security/howdy/Makefile > @@ -0,0 +1,52 @@ > +PORTNAME= howdy > +PORTVERSION= 3.0.0.b > +CATEGORIES= security > + > +MAINTAINER= arrowd@FreeBSD.org > +COMMENT= Windows Hello(TM) style authentication provider > +WWW= https://github.com/boltgolt/howdy > + > +LICENSE= MIT > +LICENSE_FILE= ${WRKSRC}/LICENSE > + > +RUN_DEPENDS= opencv>0:graphics/opencv \ > + ${PYTHON_PKGNAMEPREFIX}python-pam>0:security/py-python-pam@${PY_FLAVOR} > \ > + ${PYTHON_PKGNAMEPREFIX}dlib>0:science/py-dlib@${PY_FLAVOR} \ > + ${PYTHON_PKGNAMEPREFIX}numpy>0:math/py-numpy@${PY_FLAVOR} > + > +USES= python:run shebangfix > + > +USE_GITHUB= yes > +GH_ACCOUNT= boltgolt > +GH_PROJECT= howdy > +GH_TAGNAME= 30728a6d3634479c24ffd4e094c34a30bbb43058 > + > +SHEBANG_GLOB= *.py > + > +NO_BUILD= yes > +NO_ARCH= yes > +STRIP= > + > +post-patch: > + ${ECHO_CMD} 'config_dir = "${ETCDIR}"' >> > ${WRKSRC}/howdy/src/paths.py > + ${ECHO_CMD} 'dlib_data_dir = "${LOCALBASE}/share/dlib-data/"' >> > ${WRKSRC}/howdy/src/paths.py > + ${ECHO_CMD} 'user_models_dir = "/var/db/howdy/models/"' >> > ${WRKSRC}/howdy/src/paths.py > + > +do-install: > + ${MKDIR} ${STAGEDIR}${PREFIX}/libexec/howdy > + cd ${WRKSRC}/howdy/src/ && \ > + ${COPYTREE_SHARE} . ${STAGEDIR}${PREFIX}/libexec/howdy > + ${MKDIR} ${STAGEDIR}${ETCDIR} > + ${MV} ${STAGEDIR}${PREFIX}/libexec/howdy/config.ini > ${STAGEDIR}${ETCDIR}/config.ini.sample > + ${LN} -s ../libexec/howdy/cli.py ${STAGEDIR}${PREFIX}/bin/howdy > + ${INSTALL_PROGRAM} ${WRKSRC}/howdy/src/cli.py > ${STAGEDIR}${PREFIX}/libexec/howdy > + > + ${MKDIR} ${STAGEDIR}${PREFIX}/share/bash-completion/completions > + ${INSTALL_DATA} ${WRKSRC}/howdy/src/autocomplete/howdy > ${STAGEDIR}${PREFIX}/share/bash-completion/completions/ > + > + ${RM} -r ${STAGEDIR}${PREFIX}/libexec/howdy/autocomplete > + ${RM} -r ${STAGEDIR}${PREFIX}/libexec/howdy/dlib-data > + ${RM} -r ${STAGEDIR}${PREFIX}/libexec/howdy/pam > + ${RM} -r ${STAGEDIR}${PREFIX}/libexec/howdy/pam-config > + > +.include <bsd.port.mk> > diff --git a/security/howdy/distinfo b/security/howdy/distinfo > new file mode 100644 > index 000000000000..8b91fb02664b > --- /dev/null > +++ b/security/howdy/distinfo > @@ -0,0 +1,3 @@ > +TIMESTAMP = 1680193772 > +SHA256 > (boltgolt-howdy-3.0.0.b-30728a6d3634479c24ffd4e094c34a30bbb43058_GH0.tar.gz) > = 76078fbfb48a186678476cf9844987c97793a71135f1e612d761350c6b27fa42 > +SIZE > (boltgolt-howdy-3.0.0.b-30728a6d3634479c24ffd4e094c34a30bbb43058_GH0.tar.gz) > = 130651 > diff --git a/security/howdy/files/patch-paths > b/security/howdy/files/patch-paths > new file mode 100644 > index 000000000000..57e375f2dea5 > --- /dev/null > +++ b/security/howdy/files/patch-paths > @@ -0,0 +1,429 @@ > +From 30034c66d72e8e15e2ad5db68d1a5940df2568b6 Mon Sep 17 00:00:00 2001 > +From: Gleb Popov <6yearold@gmail.com> > +Date: Thu, 30 Mar 2023 21:55:11 +0300 > +Subject: [PATCH] Put all path variables into a separate module. > + > +This makes it easier for downstream packagers to customize where howdy > installs > +its files. > +--- > + howdy/src/cli/add.py | 22 ++++++++++------------ > + howdy/src/cli/clear.py | 9 ++++----- > + howdy/src/cli/config.py | 3 ++- > + howdy/src/cli/disable.py | 3 ++- > + howdy/src/cli/list.py | 7 +++---- > + howdy/src/cli/remove.py | 9 ++++----- > + howdy/src/cli/set.py | 3 ++- > + howdy/src/cli/snap.py | 5 ++--- > + howdy/src/cli/test.py | 11 ++++++----- > + howdy/src/compare.py | 18 ++++++++---------- > + howdy/src/paths.py | 12 ++++++++++++ > + howdy/src/snapshot.py | 15 ++++++--------- > + 12 files changed, 61 insertions(+), 56 deletions(-) > + create mode 100644 howdy/src/paths.py > + > +diff --git a/howdy/src/cli/add.py b/howdy/src/cli/add.py > +index 7a6d9eca..5a63bdfe 100644 > +--- a/howdy/src/cli/add.py > ++++ ./howdy/src/cli/add.py > +@@ -8,6 +8,7 @@ > + import configparser > + import builtins > + import numpy as np > ++import paths > + > + from recorders.video_capture import VideoCapture > + from i18n import _ > +@@ -26,39 +27,36 @@ > + # OpenCV needs to be imported after dlib > + import cv2 > + > +-# Define the absolute path to the config directory > +-config_path = "/etc/howdy" > +- > + # Test if at lest 1 of the data files is there and abort if it's not > +-if not os.path.isfile(config_path + > "/dlib-data/shape_predictor_5_face_landmarks.dat"): > ++if not os.path.isfile(paths.dlib_data_dir + > "shape_predictor_5_face_landmarks.dat"): > + print(_("Data files have not been downloaded, please run the > following commands:")) > +- print("\n\tcd " + config_path + "/dlib-data") > ++ print("\n\tcd " + paths.dlib_data_dir) > + print("\tsudo ./install.sh\n") > + sys.exit(1) > + > + # Read config from disk > + config = configparser.ConfigParser() > +-config.read(config_path + "/config.ini") > ++config.read(paths.config_dir + "/config.ini") > + > + use_cnn = config.getboolean("core", "use_cnn", fallback=False) > + if use_cnn: > +- face_detector = dlib.cnn_face_detection_model_v1(config_path + > "/dlib-data/mmod_human_face_detector.dat") > ++ face_detector = > dlib.cnn_face_detection_model_v1(paths.dlib_data_dir + > "mmod_human_face_detector.dat") > + else: > + face_detector = dlib.get_frontal_face_detector() > + > +-pose_predictor = dlib.shape_predictor(config_path + > "/dlib-data/shape_predictor_5_face_landmarks.dat") > +-face_encoder = dlib.face_recognition_model_v1(config_path + > "/dlib-data/dlib_face_recognition_resnet_model_v1.dat") > ++pose_predictor = dlib.shape_predictor(paths.dlib_data_dir + > "shape_predictor_5_face_landmarks.dat") > ++face_encoder = dlib.face_recognition_model_v1(paths.dlib_data_dir + > "dlib_face_recognition_resnet_model_v1.dat") > + > + user = builtins.howdy_user > + # The permanent file to store the encoded model in > +-enc_file = config_path + "/models/" + user + ".dat" > ++enc_file = paths.user_models_dir + user + ".dat" > + # Known encodings > + encodings = [] > + > + # Make the ./models folder if it doesn't already exist > +-if not os.path.exists(config_path + "/models"): > ++if not os.path.exists(paths.user_models_dir): > + print(_("No face model folder found, creating one")) > +- os.makedirs(config_path + "/models") > ++ os.makedirs(paths.user_models_dir) > + > + # To try read a premade encodings file if it exists > + try: > +diff --git a/howdy/src/cli/clear.py b/howdy/src/cli/clear.py > +index 6fa5f3ef..aa43e152 100644 > +--- a/howdy/src/cli/clear.py > ++++ ./howdy/src/cli/clear.py > +@@ -4,21 +4,20 @@ > + import os > + import sys > + import builtins > ++import paths > + > + from i18n import _ > + > +-# Get the full path to this file > +-path = "/etc/howdy/models" > + # Get the passed user > + user = builtins.howdy_user > + > + # Check if the models folder is there > +-if not os.path.exists(path): > ++if not os.path.exists(paths.user_models_dir): > + print(_("No models created yet, can't clear them if they don't > exist")) > + sys.exit(1) > + > + # Check if the user has a models file to delete > +-if not os.path.isfile(path + "/" + user + ".dat"): > ++if not os.path.isfile(paths.user_models_dir + user + ".dat"): > + print(_("{} has no models or they have been cleared > already").format(user)) > + sys.exit(1) > + > +@@ -34,5 +33,5 @@ > + sys.exit(1) > + > + # Delete otherwise > +-os.remove(path + "/" + user + ".dat") > ++os.remove(paths.user_models_dir + user + ".dat") > + print(_("\nModels cleared")) > +diff --git a/howdy/src/cli/config.py b/howdy/src/cli/config.py > +index 71064839..04c51798 100644 > +--- a/howdy/src/cli/config.py > ++++ ./howdy/src/cli/config.py > +@@ -3,6 +3,7 @@ > + # Import required modules > + import os > + import subprocess > ++import paths > + > + from i18n import _ > + > +@@ -19,4 +20,4 @@ > + editor = "/etc/alternatives/editor" > + > + # Open the editor as a subprocess and fork it > +-subprocess.call([editor, "/etc/howdy/config.ini"]) > ++subprocess.call([editor, paths.config_dir + "config.ini"]) > +diff --git a/howdy/src/cli/disable.py b/howdy/src/cli/disable.py > +index be78c97f..1f655412 100644 > +--- a/howdy/src/cli/disable.py > ++++ ./howdy/src/cli/disable.py > +@@ -6,11 +6,12 @@ > + import builtins > + import fileinput > + import configparser > ++import paths > + > + from i18n import _ > + > + # Get the absolute filepath > +-config_path = os.path.dirname("/etc/howdy") + "/config.ini" > ++config_path = os.path.dirname(paths.config_dir) + "/config.ini" > + > + # Read config from disk > + config = configparser.ConfigParser() > +diff --git a/howdy/src/cli/list.py b/howdy/src/cli/list.py > +index 3532e9f8..7539837d 100644 > +--- a/howdy/src/cli/list.py > ++++ ./howdy/src/cli/list.py > +@@ -6,21 +6,20 @@ > + import json > + import time > + import builtins > ++import paths > + > + from i18n import _ > + > +-# Get the absolute path and the username > +-path = "/etc/howdy" > + user = builtins.howdy_user > + > + # Check if the models file has been created yet > +-if not os.path.exists(path + "/models"): > ++if not os.path.exists(paths.user_models_dir): > + print(_("Face models have not been initialized yet, please run:")) > + print("\n\tsudo howdy -U " + user + " add\n") > + sys.exit(1) > + > + # Path to the models file > +-enc_file = path + "/models/" + user + ".dat" > ++enc_file = paths.user_models_dir + user + ".dat" > + > + # Try to load the models file and abort if the user does not have it > yet > + try: > +diff --git a/howdy/src/cli/remove.py b/howdy/src/cli/remove.py > +index 6321e0b5..37894422 100644 > +--- a/howdy/src/cli/remove.py > ++++ ./howdy/src/cli/remove.py > +@@ -5,11 +5,10 @@ > + import os > + import json > + import builtins > ++import paths > + > + from i18n import _ > + > +-# Get the absolute path and the username > +-path = "/etc/howdy" > + user = builtins.howdy_user > + > + # Check if enough arguments have been passed > +@@ -22,13 +21,13 @@ > + sys.exit(1) > + > + # Check if the models file has been created yet > +-if not os.path.exists(path + "/models"): > ++if not os.path.exists(paths.user_models_dir): > + print(_("Face models have not been initialized yet, please run:")) > + print("\n\thowdy add\n") > + sys.exit(1) > + > + # Path to the models file > +-enc_file = path + "/models/" + user + ".dat" > ++enc_file = paths.user_models_dir + user + ".dat" > + > + # Try to load the models file and abort if the user does not have it > yet > + try: > +@@ -72,7 +71,7 @@ > + > + # Remove the entire file if this encoding is the only one > + if len(encodings) == 1: > +- os.remove(path + "/models/" + user + ".dat") > ++ os.remove(paths.user_models_dir + user + ".dat") > + print(_("Removed last model, howdy disabled for user")) > + else: > + # A place holder to contain the encodings that will remain > +diff --git a/howdy/src/cli/set.py b/howdy/src/cli/set.py > +index 14d15c20..efbbee5b 100644 > +--- a/howdy/src/cli/set.py > ++++ ./howdy/src/cli/set.py > +@@ -5,11 +5,12 @@ > + import os > + import builtins > + import fileinput > ++import paths > + > + from i18n import _ > + > + # Get the absolute filepath > +-config_path = os.path.dirname("/etc/howdy") + "/config.ini" > ++config_path = os.path.dirname(paths.config_dir) + "/config.ini" > + > + # Check if enough arguments have been passed > + if len(builtins.howdy_args.arguments) < 2: > +diff --git a/howdy/src/cli/snap.py b/howdy/src/cli/snap.py > +index cbcae501..2c625d3b 100644 > +--- a/howdy/src/cli/snap.py > ++++ ./howdy/src/cli/snap.py > +@@ -5,15 +5,14 @@ > + import configparser > + import datetime > + import snapshot > ++import paths > + from recorders.video_capture import VideoCapture > + > + from i18n import _ > + > +-path = "/etc/howdy" > +- > + # Read the config > + config = configparser.ConfigParser() > +-config.read(path + "/config.ini") > ++config.read(paths.config_dir + "config.ini") > + > + # Start video capture > + video_capture = VideoCapture(config) > +diff --git a/howdy/src/cli/test.py b/howdy/src/cli/test.py > +index 3a6e4d19..563be19b 100644 > +--- a/howdy/src/cli/test.py > ++++ ./howdy/src/cli/test.py > +@@ -10,6 +10,7 @@ > + import dlib > + import cv2 > + import numpy as np > ++import paths > + > + from i18n import _ > + from recorders.video_capture import VideoCapture > +@@ -19,7 +20,7 @@ > + > + # Read config from disk > + config = configparser.ConfigParser() > +-config.read(path + "/config.ini") > ++config.read(paths.config_dir + "config.ini") > + > + if config.get("video", "recording_plugin", fallback="opencv") != > "opencv": > + print(_("Howdy has been configured to use a recorder which doesn't > support the test command yet, aborting")) > +@@ -59,20 +60,20 @@ def print_text(line_number, text): > + > + if use_cnn: > + face_detector = dlib.cnn_face_detection_model_v1( > +- path + "/dlib-data/mmod_human_face_detector.dat" > ++ paths.dlib_data_dir + "mmod_human_face_detector.dat" > + ) > + else: > + face_detector = dlib.get_frontal_face_detector() > + > +-pose_predictor = dlib.shape_predictor(path + > "/dlib-data/shape_predictor_5_face_landmarks.dat") > +-face_encoder = dlib.face_recognition_model_v1(path + > "/dlib-data/dlib_face_recognition_resnet_model_v1.dat") > ++pose_predictor = dlib.shape_predictor(paths.dlib_data_dir + > "shape_predictor_5_face_landmarks.dat") > ++face_encoder = dlib.face_recognition_model_v1(paths.dlib_data_dir + > "dlib_face_recognition_resnet_model_v1.dat") > + > + encodings = [] > + models = None > + > + try: > + user = builtins.howdy_user > +- models = json.load(open(path + "/models/" + user + ".dat")) > ++ models = json.load(open(paths.user_models_dir + user + ".dat")) > + > + for model in models: > + encodings += model["data"] > +diff --git a/howdy/src/compare.py b/howdy/src/compare.py > +index 99f5285b..f81fe386 100644 > +--- a/howdy/src/compare.py > ++++ ./howdy/src/compare.py > +@@ -23,6 +23,7 @@ > + import snapshot > + import numpy as np > + import _thread as thread > ++import paths > + > + # Allow imports from the local howdy folder > + sys.path.append('/lib/security/howdy') > +@@ -48,22 +49,22 @@ def init_detector(lock): > + global face_detector, pose_predictor, face_encoder > + > + # Test if at lest 1 of the data files is there and abort if it's not > +- if not os.path.isfile(PATH + > "/dlib-data/shape_predictor_5_face_landmarks.dat"): > ++ if not os.path.isfile(paths.dlib_data_dir + > "shape_predictor_5_face_landmarks.dat"): > + print(_("Data files have not been downloaded, please run the > following commands:")) > +- print("\n\tcd " + PATH + "/dlib-data") > ++ print("\n\tcd " + paths.dlib_data_dir) > + print("\tsudo ./install.sh\n") > + lock.release() > + exit(1) > + > + # Use the CNN detector if enabled > + if use_cnn: > +- face_detector = dlib.cnn_face_detection_model_v1(PATH + > "/dlib-data/mmod_human_face_detector.dat") > ++ face_detector = > dlib.cnn_face_detection_model_v1(paths.dlib_data_dir + > "mmod_human_face_detector.dat") > + else: > + face_detector = dlib.get_frontal_face_detector() > + > + # Start the others regardless > +- pose_predictor = dlib.shape_predictor(PATH + > "/dlib-data/shape_predictor_5_face_landmarks.dat") > +- face_encoder = dlib.face_recognition_model_v1(PATH + > "/dlib-data/dlib_face_recognition_resnet_model_v1.dat") > ++ pose_predictor = dlib.shape_predictor(paths.dlib_data_dir + > "shape_predictor_5_face_landmarks.dat") > ++ face_encoder = dlib.face_recognition_model_v1(paths.dlib_data_dir + > "dlib_face_recognition_resnet_model_v1.dat") > + > + # Note the time it took to initialize detectors > + timings["ll"] = time.time() - timings["ll"] > +@@ -103,9 +104,6 @@ def send_to_ui(type, message): > + if len(sys.argv) < 2: > + exit(12) > + > +-# Get the absolute path to the config directory > +-PATH = "/etc/howdy" > +- > + # The username of the user being authenticated > + user = sys.argv[1] > + # The model file contents > +@@ -129,7 +127,7 @@ def send_to_ui(type, message): > + > + # Try to load the face model from the models folder > + try: > +- models = json.load(open(PATH + "/models/" + user + ".dat")) > ++ models = json.load(open(paths.user_models_dir + user + ".dat")) > + > + for model in models: > + encodings += model["data"] > +@@ -142,7 +140,7 @@ def send_to_ui(type, message): > + > + # Read config from disk > + config = configparser.ConfigParser() > +-config.read(PATH + "/config.ini") > ++config.read(paths.config_dir + "config.ini") > + > + # Get all config values needed > + use_cnn = config.getboolean("core", "use_cnn", fallback=False) > +diff --git a/howdy/src/paths.py b/howdy/src/paths.py > +new file mode 100644 > +index 00000000..22825405 > +--- /dev/null > ++++ ./howdy/src/paths.py > +@@ -0,0 +1,12 @@ > ++ > ++# Define the absolute path to the config directory > ++config_dir = "/etc/howdy/" > ++ > ++# Define the absolute path to the DLib models data directory > ++dlib_data_dir = config_dir + "/dlib-data/" > ++ > ++# Define the absolute path to the Howdy user models directory > ++user_models_dir = config_dir + "/models/" > ++ > ++# Define path to any howdy logs > ++log_path = "/var/log/howdy" > +diff --git a/howdy/src/snapshot.py b/howdy/src/snapshot.py > +index 324b5789..9f2f563c 100644 > +--- a/howdy/src/snapshot.py > ++++ ./howdy/src/snapshot.py > +@@ -49,19 +49,16 @@ def generate(frames, text_lines): > + > + line_number += 1 > + > +- # Define path to any howdy logs > +- log_path = "/var/log/howdy" > +- > + # Made sure a snapshot folder exist > +- if not os.path.exists(log_path): > +- os.makedirs(log_path) > +- if not os.path.exists(log_path + "/snapshots"): > +- os.makedirs(log_path + "/snapshots") > ++ if not os.path.exists(paths.log_path): > ++ os.makedirs(paths.log_path) > ++ if not os.path.exists(paths.log_path + "/snapshots"): > ++ os.makedirs(paths.log_path + "/snapshots") > + > + # Generate a filename based on the current time > + filename = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%S.jpg") > + # Write the image to that file > +- cv2.imwrite(log_path + "/snapshots/" + filename, snap) > ++ cv2.imwrite(paths.log_path + "/snapshots/" + filename, snap) > + > + # Return the saved file location > +- return log_path + "/snapshots/" + filename > ++ return paths.log_path + "/snapshots/" + filename > diff --git a/security/howdy/pkg-descr b/security/howdy/pkg-descr > new file mode 100644 > index 000000000000..1fd314d7c55e > --- /dev/null > +++ b/security/howdy/pkg-descr > @@ -0,0 +1,3 @@ > +Howdy is an authentication tool that allows you to unlock your desktop > +session using your webcam. It uses facial recognition to authenticate > and > +unlock the session. > diff --git a/security/howdy/pkg-plist b/security/howdy/pkg-plist > new file mode 100644 > index 000000000000..5353cffca512 > --- /dev/null > +++ b/security/howdy/pkg-plist > @@ -0,0 +1,27 @@ > +bin/howdy > +@sample %%ETCDIR%%/config.ini.sample > +libexec/howdy/cli.py > +libexec/howdy/cli/__init__.py > +libexec/howdy/cli/add.py > +libexec/howdy/cli/clear.py > +libexec/howdy/cli/config.py > +libexec/howdy/cli/disable.py > +libexec/howdy/cli/list.py > +libexec/howdy/cli/remove.py > +libexec/howdy/cli/set.py > +libexec/howdy/cli/snap.py > +libexec/howdy/cli/test.py > +libexec/howdy/compare.py > +libexec/howdy/i18n.py > +libexec/howdy/logo.png > +libexec/howdy/paths.py > +libexec/howdy/recorders/__init__.py > +libexec/howdy/recorders/ffmpeg_reader.py > +libexec/howdy/recorders/pyv4l2_reader.py > +libexec/howdy/recorders/v4l2.py > +libexec/howdy/recorders/video_capture.py > +libexec/howdy/rubberstamps/__init__.py > +libexec/howdy/rubberstamps/hotkey.py > +libexec/howdy/rubberstamps/nod.py > +libexec/howdy/snapshot.py > +share/bash-completion/completions/howdy