howzo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
howzo/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """howzo - knows your machine.
2
+
3
+ Ask "how do I X" in English and get the tool that is actually installed on
4
+ this machine, plus a usage hint. Stdlib only, zero models, fully local.
5
+ """
6
+
7
+ __version__ = "0.1.0"
howzo/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m howzo` entry point."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main() or 0)
howzo/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """howzo command-line entry point."""
2
+ import sys
3
+
4
+ from . import commands
5
+ from .config import db_path
6
+ from .mcp import cmd_mcp
7
+
8
+ USAGE = """howzo - knows your machine. Ask "how do I X" in English; get the installed tool + command.
9
+
10
+ Commands:
11
+ howzo <query> just type your question (same as ask)
12
+ howzo scan [--deep] rebuild inventory (brew/npm/pipx/uv/scripts/system). --deep also captures --help
13
+ howzo ask "query"
14
+ howzo whatis <tool>
15
+ howzo deep <tool> capture --help for one tool
16
+ howzo add <name> "desc" register a tool the scanner can't see (internal CLIs, npx aliases)
17
+ howzo list [--source S]
18
+ howzo mcp run as a stdio MCP server (exposes howzo_ask / howzo_whatis / howzo_list)
19
+ howzo db show database path
20
+
21
+ Stdlib only, zero models, fully local. Works on macOS, Linux, and Windows."""
22
+
23
+
24
+ def main(argv=None):
25
+ args = list(sys.argv[1:] if argv is None else argv)
26
+ if not args:
27
+ print(USAGE)
28
+ return 1
29
+ cmd, rest = args[0], args[1:]
30
+ if cmd == "scan":
31
+ return commands.cmd_scan(rest)
32
+ if cmd == "ask":
33
+ return commands.cmd_ask(rest)
34
+ if cmd == "whatis":
35
+ return commands.cmd_whatis(rest)
36
+ if cmd == "deep":
37
+ return commands.cmd_deep(rest)
38
+ if cmd == "add":
39
+ return commands.cmd_add(rest)
40
+ if cmd == "list" and (not rest or rest[0].startswith("-")):
41
+ return commands.cmd_list(rest)
42
+ if cmd == "mcp":
43
+ return cmd_mcp(rest)
44
+ if cmd == "db":
45
+ print(db_path())
46
+ return 0
47
+ # implicit ask: howzo <query> == howzo ask <query>
48
+ return commands.cmd_ask(args)
howzo/commands.py ADDED
@@ -0,0 +1,180 @@
1
+ """CLI command handlers (scan / ask / whatis / deep / add / list)."""
2
+ import os
3
+ import sqlite3
4
+ import time
5
+
6
+ from . import config
7
+ from .db import db, upsert
8
+ from .helptext import capture_help
9
+ from .match import coverage, fts_query, find_by_name, has_word, query_tokens, rank_rows
10
+ from .render import render_tool
11
+ from .scan import scanners
12
+
13
+
14
+ def cmd_scan(args):
15
+ c = db()
16
+ try:
17
+ # preserve on-demand captured help + enrichments across rescans
18
+ keep = {r["name"]: (r["help_excerpt"], r["help_captured_at"], r["when_to_use"])
19
+ for r in c.execute("SELECT name, help_excerpt, help_captured_at, when_to_use FROM tools")}
20
+ custom_rows = {r["name"]: (r["source"], r["version"], r["path"], r["oneliner"],
21
+ r["when_to_use"], r["help_excerpt"], r["help_captured_at"])
22
+ for r in c.execute("SELECT * FROM tools WHERE source IN ('custom','npx')")}
23
+ c.execute("DELETE FROM tools")
24
+ except sqlite3.DatabaseError:
25
+ print(" warning: db corrupted, rebuilding")
26
+ d = config.db_dir()
27
+ for f in os.listdir(d):
28
+ if f.startswith("howzo.db"):
29
+ os.remove(os.path.join(d, f))
30
+ c = db()
31
+ keep, custom_rows = {}, {}
32
+ deep = "--deep" in args
33
+ t0 = time.time()
34
+ for fn in scanners():
35
+ fn(c)
36
+ # custom tools added via 'howzo add' must survive rescans
37
+ for name, (src, ver, path, one, w, h, h_at) in custom_rows.items():
38
+ c.execute("INSERT INTO tools(name, source, version, path, oneliner, when_to_use, help_excerpt, "
39
+ "help_captured_at, scanned_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(name) DO NOTHING",
40
+ (name, src, ver, path, one, w, h, h_at, time.strftime("%Y-%m-%d")))
41
+ for name, (h, h_at, w) in keep.items():
42
+ c.execute("UPDATE tools SET help_excerpt=?, help_captured_at=?, when_to_use=? WHERE name=?",
43
+ (h, h_at, w, name))
44
+ c.commit()
45
+ total = c.execute("SELECT COUNT(*) FROM tools").fetchone()[0]
46
+ print(f" inventory: {total} tools in {time.time()-t0:.0f}s")
47
+ if deep:
48
+ print(" capturing --help (can take a few minutes)...")
49
+ rows = c.execute("SELECT id, name, path FROM tools").fetchall()
50
+ done = 0
51
+ for tid, name, path in rows:
52
+ h = capture_help(name, path)
53
+ if h:
54
+ c.execute("UPDATE tools SET help_excerpt=?, help_captured_at=? WHERE id=?",
55
+ (h, time.strftime("%Y-%m-%d"), tid))
56
+ done += 1
57
+ c.commit()
58
+ print(f" help captured for {done}/{len(rows)} tools")
59
+ else:
60
+ print(" (tip: 'howzo scan --deep' also captures --help for richer answers;")
61
+ print(" 'howzo deep <tool>' captures help for one tool on demand)")
62
+ return 0
63
+
64
+
65
+ def cmd_ask(args):
66
+ q = " ".join(args).strip()
67
+ if not q:
68
+ print('usage: howzo ask "how do I ..."')
69
+ return 1
70
+ c = db()
71
+ row = find_by_name(c, q)
72
+ if row:
73
+ print(render_tool(row, q))
74
+ return 0
75
+ ftsq = fts_query(q)
76
+ toks = query_tokens(q)
77
+ rows = []
78
+ if ftsq:
79
+ try:
80
+ rows = c.execute(
81
+ "SELECT t.*, bm25(tools_fts) AS score FROM tools_fts f JOIN tools t ON t.id=f.rowid "
82
+ "WHERE tools_fts MATCH ? ORDER BY score LIMIT 12", (ftsq,)).fetchall()
83
+ except sqlite3.OperationalError:
84
+ rows = []
85
+ if not rows and toks:
86
+ # fallback: broad LIKE candidates per token, then word-boundary filter
87
+ cand = {}
88
+ for t in toks:
89
+ like = f"%{t}%"
90
+ for r in c.execute("SELECT * FROM tools WHERE lower(name) LIKE ? OR lower(oneliner) LIKE ? "
91
+ "OR lower(when_to_use) LIKE ? OR lower(help_excerpt) LIKE ? LIMIT 60",
92
+ (like, like, like, like)):
93
+ hay = " ".join(filter(None, [r["name"], r["oneliner"], r["when_to_use"], r["help_excerpt"]]))
94
+ if has_word(hay, t):
95
+ cand[r["id"]] = r
96
+ rows = list(cand.values())
97
+ rows = rank_rows(rows, toks)
98
+ if not rows:
99
+ print(f"no match for: {q}\n (try 'howzo scan --deep' to index --help text)")
100
+ return 1
101
+ for r in rows[:3]:
102
+ print(render_tool(r, q))
103
+ print()
104
+ best_cov = max(coverage(r, toks) for r in rows[:3])
105
+ if best_cov < len(toks):
106
+ print(" (partial match - no tool advertises all terms)")
107
+ return 0
108
+
109
+
110
+ def cmd_whatis(args):
111
+ c = db()
112
+ for name in args:
113
+ row = find_by_name(c, name)
114
+ if not row:
115
+ print(f"unknown tool: {name}")
116
+ continue
117
+ t = {k: row[k] for k in row.keys()}
118
+ print(render_tool(t, " ".join(args)))
119
+ if t.get("help_excerpt"):
120
+ print(" help:")
121
+ for l in t["help_excerpt"].splitlines()[:12]:
122
+ print(" ", l)
123
+ print()
124
+ return 0
125
+
126
+
127
+ def cmd_add(args):
128
+ if len(args) < 2:
129
+ print('usage: howzo add <name> "what it does" (e.g. howzo add my-internal-tool \'syncs staging DB\')')
130
+ return 1
131
+ name = args[0]
132
+ desc = " ".join(args[1:])
133
+ c = db()
134
+ upsert(c, name, "custom", "", "", desc)
135
+ c.commit()
136
+ print(f"added: {name} (source=custom, survives rescans)")
137
+ return 0
138
+
139
+
140
+ def cmd_deep(args):
141
+ if not args:
142
+ print("usage: howzo deep <tool>")
143
+ return 1
144
+ name = args[0]
145
+ c = db()
146
+ row = c.execute("SELECT * FROM tools WHERE lower(name)=?", (name.lower(),)).fetchone()
147
+ if not row:
148
+ print(f"not in inventory: {name} (run 'howzo scan')")
149
+ return 1
150
+ t = {k: row[k] for k in row.keys()}
151
+ print(f"capturing help for {name}...")
152
+ h = capture_help(name, t.get("path"))
153
+ if not h:
154
+ print(" no help captured (binary not found or silent --help)")
155
+ return 1
156
+ c.execute("UPDATE tools SET help_excerpt=?, help_captured_at=? WHERE id=?",
157
+ (h, time.strftime("%Y-%m-%d"), t["id"]))
158
+ c.commit()
159
+ print(f" ok ({len(h)} chars)")
160
+ return 0
161
+
162
+
163
+ def cmd_list(args):
164
+ c = db()
165
+ src = None
166
+ if "--source" in args:
167
+ src = args[args.index("--source") + 1]
168
+ q = "SELECT * FROM tools"
169
+ params = ()
170
+ if src:
171
+ q += " WHERE source=?"
172
+ params = (src,)
173
+ q += " ORDER BY source, name"
174
+ rows = c.execute(q, params).fetchall()
175
+ print(f"{'TOOL':<28} {'SRC':<8} {'VER':<14} WHAT")
176
+ for r in rows:
177
+ t = {k: r[k] for k in r.keys()}
178
+ print(f"{t['name']:<28} {t['source']:<8} {str(t.get('version') or '')[:13]:<14} {t.get('oneliner','')[:70]}")
179
+ print(f"\n{len(rows)} tools")
180
+ return 0
howzo/config.py ADDED
@@ -0,0 +1,25 @@
1
+ """Platform constants and paths.
2
+
3
+ The database location can be overridden with the HOWZO_DB environment
4
+ variable (the test suite uses this to run against temp directories).
5
+ """
6
+ import os
7
+
8
+ IS_WINDOWS = os.name == "nt"
9
+ HOME = os.path.expanduser("~")
10
+
11
+ # Directories scanned for user scripts (see scan/scripts.py)
12
+ SCAN_DIRS = [os.path.join(HOME, "bin"), os.path.join(HOME, ".local", "bin")]
13
+
14
+
15
+ def db_dir():
16
+ override = os.environ.get("HOWZO_DB")
17
+ if override:
18
+ return override
19
+ if IS_WINDOWS:
20
+ return os.path.join(os.environ.get("LOCALAPPDATA") or HOME, "howzo")
21
+ return os.path.join(HOME, ".local", "share", "howzo")
22
+
23
+
24
+ def db_path():
25
+ return os.path.join(db_dir(), "howzo.db")
howzo/db.py ADDED
@@ -0,0 +1,74 @@
1
+ """SQLite + FTS5 inventory store."""
2
+ import os
3
+ import sqlite3
4
+ import time
5
+
6
+ from . import config
7
+
8
+ SCHEMA = """
9
+ CREATE TABLE IF NOT EXISTS tools (
10
+ id INTEGER PRIMARY KEY,
11
+ name TEXT UNIQUE NOT NULL,
12
+ source TEXT,
13
+ version TEXT,
14
+ path TEXT,
15
+ oneliner TEXT DEFAULT '',
16
+ when_to_use TEXT DEFAULT '',
17
+ help_excerpt TEXT DEFAULT '',
18
+ help_captured_at TEXT,
19
+ scanned_at TEXT
20
+ );
21
+ CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
22
+ name, oneliner, when_to_use, help_excerpt, content='tools', content_rowid='id',
23
+ tokenize='porter unicode61'
24
+ );
25
+ CREATE TRIGGER IF NOT EXISTS tools_ai AFTER INSERT ON tools BEGIN
26
+ INSERT INTO tools_fts(rowid, name, oneliner, when_to_use, help_excerpt)
27
+ VALUES (new.id, new.name, new.oneliner, new.when_to_use, new.help_excerpt);
28
+ END;
29
+ CREATE TRIGGER IF NOT EXISTS tools_ad AFTER DELETE ON tools BEGIN
30
+ INSERT INTO tools_fts(tools_fts, rowid, name, oneliner, when_to_use, help_excerpt)
31
+ VALUES ('delete', old.id, old.name, old.oneliner, old.when_to_use, old.help_excerpt);
32
+ END;
33
+ CREATE TRIGGER IF NOT EXISTS tools_au AFTER UPDATE ON tools BEGIN
34
+ INSERT INTO tools_fts(tools_fts, rowid, name, oneliner, when_to_use, help_excerpt)
35
+ VALUES ('delete', old.id, old.name, old.oneliner, old.when_to_use, old.help_excerpt);
36
+ INSERT INTO tools_fts(rowid, name, oneliner, when_to_use, help_excerpt)
37
+ VALUES (new.id, new.name, new.oneliner, new.when_to_use, new.help_excerpt);
38
+ END;
39
+ """
40
+
41
+
42
+ def db(path=None):
43
+ """Open (creating if needed) the inventory DB at path (default: config.db_path())."""
44
+ p = path or config.db_path()
45
+ d = os.path.dirname(p) or "."
46
+ os.makedirs(d, exist_ok=True)
47
+ try:
48
+ c = sqlite3.connect(p)
49
+ c.row_factory = sqlite3.Row
50
+ c.executescript(SCHEMA)
51
+ c.execute("SELECT count(*) FROM tools") # probe
52
+ return c
53
+ except sqlite3.DatabaseError:
54
+ # corrupted db (e.g. after a schema change) -> wipe and rebuild from a scan
55
+ print(f" warning: db corrupted, rebuilding ({p})")
56
+ for f in os.listdir(d):
57
+ if f.startswith("howzo.db"):
58
+ os.remove(os.path.join(d, f))
59
+ c = sqlite3.connect(p)
60
+ c.row_factory = sqlite3.Row
61
+ c.executescript(SCHEMA)
62
+ return c
63
+
64
+
65
+ def upsert(c, name, source, version, path, oneliner):
66
+ """Insert a tool row, or update version/path on conflict.
67
+
68
+ An empty new oneliner never clobbers an existing description.
69
+ """
70
+ c.execute("INSERT INTO tools(name, source, version, path, oneliner, scanned_at) VALUES(?,?,?,?,?,?) "
71
+ "ON CONFLICT(name) DO UPDATE SET source=excluded.source, version=excluded.version, "
72
+ "path=excluded.path, oneliner=CASE WHEN excluded.oneliner != '' THEN excluded.oneliner ELSE tools.oneliner END, "
73
+ "scanned_at=excluded.scanned_at",
74
+ (name, source, version, path, oneliner, time.strftime("%Y-%m-%d")))
howzo/helptext.py ADDED
@@ -0,0 +1,53 @@
1
+ """Human-readable text extraction: man pages and --help output."""
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+
6
+ MAN_AVAILABLE = shutil.which("man") is not None
7
+
8
+
9
+ def _parse_man(out):
10
+ """Pull (oneliner, excerpt) out of raw man-page text."""
11
+ if not out.strip():
12
+ return "", ""
13
+ lines = out.splitlines()
14
+ oneliner, excerpt = "", "\n".join(lines[:60])[:3500]
15
+ try: # NAME section first line: "name - description"
16
+ i = next(j for j, l in enumerate(lines) if l.strip() == "NAME")
17
+ for l in lines[i+1:i+6]:
18
+ if l.strip() and "-" in l:
19
+ oneliner = l.split("-", 1)[1].strip()
20
+ break
21
+ except StopIteration:
22
+ pass
23
+ return oneliner, excerpt
24
+
25
+
26
+ def man_oneliner(name):
27
+ """(oneliner, excerpt) from `man name`; ('', '') if man is missing/empty."""
28
+ if not MAN_AVAILABLE:
29
+ return "", ""
30
+ try:
31
+ p = subprocess.run(["man", name], capture_output=True, text=True,
32
+ encoding="utf-8", errors="replace", timeout=8)
33
+ out = p.stdout or ""
34
+ except Exception:
35
+ return "", ""
36
+ return _parse_man(out)
37
+
38
+
39
+ def capture_help(name, path=""):
40
+ """Run `tool --help` (then -h) and return the first 4000 chars, or None."""
41
+ p = path or shutil.which(name)
42
+ if not p or not os.path.exists(p):
43
+ return None
44
+ for flag in ("--help", "-h"):
45
+ try:
46
+ r = subprocess.run([p, flag], capture_output=True, text=True,
47
+ encoding="utf-8", errors="replace", timeout=5)
48
+ out = (r.stdout or r.stderr).strip()
49
+ if len(out) > 60:
50
+ return out[:4000]
51
+ except Exception:
52
+ continue
53
+ return None
howzo/match.py ADDED
@@ -0,0 +1,59 @@
1
+ """Query tokenization and ranking.
2
+
3
+ Match = FTS5 BM25 + a word-boundary token-coverage re-rank in Python,
4
+ so 'kill' never matches 'skill' and 'port' never matches 'report'.
5
+ """
6
+ import re
7
+
8
+ STOP = {"a", "an", "and", "are", "as", "at", "can", "do", "does", "for", "from",
9
+ "get", "how", "i", "in", "into", "is", "it", "me", "my", "on", "of", "or", "that",
10
+ "the", "to", "up", "what", "whats", "which", "with", "you", "your", "show", "shows",
11
+ "showing", "across", "by", "per", "use", "using"}
12
+
13
+
14
+ def query_tokens(q):
15
+ """Lowercase word tokens, stop words removed, de-duped, capped at 10."""
16
+ return list(dict.fromkeys(t for t in re.findall(r"[a-z0-9]+", q.lower())
17
+ if len(t) > 1 and t not in STOP))[:10]
18
+
19
+
20
+ def fts_query(q):
21
+ """FTS5 match expression (quoted OR of tokens), or None if nothing to search."""
22
+ toks = query_tokens(q)
23
+ if not toks:
24
+ return None
25
+ return " OR ".join('"%s"' % t for t in toks)
26
+
27
+
28
+ def coverage(row, toks):
29
+ """How many distinct query tokens appear in this row (word-boundary match)."""
30
+ hay = " ".join(filter(None, [row["name"], row["oneliner"], row["when_to_use"],
31
+ row["help_excerpt"]])).lower()
32
+ return sum(1 for t in toks if re.search(r"(?<![a-z0-9])" + re.escape(t), hay))
33
+
34
+
35
+ def has_word(hay, tok):
36
+ return bool(re.search(r"(?<![a-z0-9])" + re.escape(tok), hay.lower()))
37
+
38
+
39
+ def find_by_name(c, q):
40
+ """Exact (case-insensitive) or prefix name lookup."""
41
+ q = q.strip().lower()
42
+ if not q:
43
+ return None
44
+ row = c.execute("SELECT * FROM tools WHERE lower(name)=?", (q,)).fetchone()
45
+ if row:
46
+ return row
47
+ return c.execute("SELECT * FROM tools WHERE lower(name) LIKE ?", (q + "%",)).fetchone()
48
+
49
+
50
+ def rank_rows(rows, toks):
51
+ """Re-rank candidates: BM25 + token-coverage bonus (docs matching more
52
+ distinct query terms win). Rows without a bm25 score still rank by coverage."""
53
+ def rank_key(r):
54
+ try:
55
+ s = r["score"]
56
+ except (IndexError, KeyError):
57
+ s = 0.0
58
+ return (-(2 * coverage(r, toks) + 0.01 * max(0, -s)), r["name"])
59
+ return sorted(rows, key=rank_key)
howzo/mcp.py ADDED
@@ -0,0 +1,79 @@
1
+ """MCP stdio server (newline-delimited JSON-RPC 2.0).
2
+
3
+ Run `howzo mcp` to expose howzo_ask / howzo_whatis / howzo_list to MCP clients.
4
+ """
5
+ import contextlib
6
+ import io
7
+ import json
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .commands import cmd_ask, cmd_list, cmd_whatis
12
+
13
+ MCP_TOOLS = [
14
+ {"name": "howzo_ask",
15
+ "description": "Ask in English what you want to do (e.g. 'how do I rotate a pdf'). Returns the best-matching installed tool with usage hints.",
16
+ "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}},
17
+ {"name": "howzo_whatis",
18
+ "description": "Look up an installed tool by name and show what it does plus help.",
19
+ "inputSchema": {"type": "object", "properties": {"tool": {"type": "string"}}, "required": ["tool"]}},
20
+ {"name": "howzo_list",
21
+ "description": "List installed tools (name, source, one-liner), optionally filtered by source.",
22
+ "inputSchema": {"type": "object", "properties": {"source": {"type": "string"}}}},
23
+ ]
24
+
25
+
26
+ def mcp_handle(msg):
27
+ m, params = msg.get("method"), msg.get("params", {}) or {}
28
+ mid = msg.get("id")
29
+
30
+ def reply(result):
31
+ return {"jsonrpc": "2.0", "id": mid, "result": result} if mid is not None else None
32
+
33
+ def error(code, message):
34
+ if mid is None:
35
+ return None
36
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
37
+
38
+ if m == "initialize":
39
+ return reply({"protocolVersion": params.get("protocolVersion", "2024-11-05"),
40
+ "capabilities": {"tools": {}},
41
+ "serverInfo": {"name": "howzo", "version": __version__}})
42
+ if m == "notifications/initialized":
43
+ return None
44
+ if m == "ping":
45
+ return reply({})
46
+ if m == "tools/list":
47
+ return reply({"tools": MCP_TOOLS})
48
+ if m == "tools/call":
49
+ name, args = params.get("name"), params.get("arguments", {}) or {}
50
+ buf = io.StringIO()
51
+ try:
52
+ with contextlib.redirect_stdout(buf):
53
+ if name == "howzo_ask":
54
+ cmd_ask([args.get("query", "")])
55
+ elif name == "howzo_whatis":
56
+ cmd_whatis([args.get("tool", "")])
57
+ elif name == "howzo_list":
58
+ cmd_list(["--source", args["source"]] if args.get("source") else [])
59
+ else:
60
+ return reply({"content": [{"type": "text", "text": f"unknown tool {name}"}], "isError": True})
61
+ except Exception as e:
62
+ return reply({"content": [{"type": "text", "text": f"error: {e}"}], "isError": True})
63
+ return reply({"content": [{"type": "text", "text": buf.getvalue()}]})
64
+ return error(-32601, f"method not found: {m}")
65
+
66
+
67
+ def cmd_mcp(args):
68
+ for line in sys.stdin:
69
+ line = line.strip()
70
+ if not line:
71
+ continue
72
+ try:
73
+ msg = json.loads(line)
74
+ except Exception:
75
+ continue
76
+ resp = mcp_handle(msg)
77
+ if resp:
78
+ print(json.dumps(resp), flush=True)
79
+ return 0
howzo/proc.py ADDED
@@ -0,0 +1,23 @@
1
+ """Subprocess helpers (cross-platform, failure-tolerant)."""
2
+ import shutil
3
+ import subprocess
4
+
5
+
6
+ def which_cmd(name):
7
+ """Resolve a command to an executable path.
8
+
9
+ Needed so Windows .cmd/.bat shims (npm, pipx, ...) resolve correctly;
10
+ falls back to the bare name so a missing command fails softly in run().
11
+ """
12
+ return shutil.which(name) or name
13
+
14
+
15
+ def run(cmd, timeout=30):
16
+ """Run cmd and return stdout. Never raises; returns '' on any failure."""
17
+ try:
18
+ r = subprocess.run([which_cmd(cmd[0])] + list(cmd[1:]),
19
+ capture_output=True, text=True,
20
+ encoding="utf-8", errors="replace", timeout=timeout)
21
+ return r.stdout or ""
22
+ except Exception:
23
+ return ""
howzo/render.py ADDED
@@ -0,0 +1,32 @@
1
+ """Formatting for terminal and MCP output."""
2
+ import re
3
+ import sqlite3
4
+
5
+
6
+ def best_help_lines(help_text, q, n=3):
7
+ """The n help lines most relevant to query q (original order preserved)."""
8
+ toks = [t for t in re.findall(r"[a-z0-9]+", q.lower()) if len(t) > 2]
9
+ if not toks:
10
+ return []
11
+ lines = [l.strip() for l in help_text.splitlines() if l.strip()]
12
+ scored = []
13
+ for l in lines:
14
+ s = sum(l.lower().count(t) for t in toks[:5])
15
+ if s:
16
+ scored.append((s, l))
17
+ scored.sort(key=lambda x: -x[0])
18
+ top = [l for _, l in scored[:n * 2]][:n]
19
+ return top
20
+
21
+
22
+ def render_tool(row, q=""):
23
+ t = {k: row[k] for k in row.keys()} if isinstance(row, sqlite3.Row) else dict(row)
24
+ out = [f"{t['name']} ({t['source']}" + (f", {t['version']}" if t.get("version") else "") + ")"]
25
+ if t.get("oneliner"):
26
+ out.append(f" {t['oneliner']}")
27
+ if t.get("when_to_use"):
28
+ out.append(f" when: {t['when_to_use']}")
29
+ if t.get("help_excerpt") and q:
30
+ for l in best_help_lines(t["help_excerpt"], q):
31
+ out.append(f" | {l[:110]}")
32
+ return "\n".join(out)
howzo/scan/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Inventory scanners: one module per tool source.
2
+
3
+ `scanners()` returns the scanner callables appropriate for this platform,
4
+ in the order they should run.
5
+ """
6
+ from .. import config
7
+ from .brew import scan_brew
8
+ from .npm import scan_npm
9
+ from .pipx import scan_pipx
10
+ from .uv import scan_uv
11
+ from .scripts import scan_scripts
12
+ from .system import scan_system
13
+ from .path import scan_path
14
+ from .npx import scan_npx_history
15
+
16
+
17
+ def scanners():
18
+ return [scan_brew, scan_npm, scan_pipx, scan_uv, scan_scripts,
19
+ scan_path if config.IS_WINDOWS else scan_system, scan_npx_history]
howzo/scan/brew.py ADDED
@@ -0,0 +1,37 @@
1
+ """Homebrew formulae (macOS and Linuxbrew)."""
2
+ import json
3
+ import shutil
4
+
5
+ from ..db import upsert
6
+ from ..proc import run
7
+
8
+
9
+ def scan_brew(c):
10
+ if not shutil.which("brew"):
11
+ return
12
+ out = run(["brew", "list", "--versions"], timeout=60)
13
+ names = []
14
+ for line in out.splitlines():
15
+ parts = line.split()
16
+ if parts and parts[0] and not parts[0].startswith("("):
17
+ names.append(parts[0])
18
+ print(f" brew: {len(names)} formulae, fetching descriptions...")
19
+
20
+ def ingest(out):
21
+ try:
22
+ data = json.loads(out)
23
+ except Exception:
24
+ return False
25
+ for f in data.get("formulae", []):
26
+ ver = (f.get("versions") or {}).get("version") or (f.get("versions") or {}).get("stable", "")
27
+ upsert(c, f["name"], "brew", str(ver), f.get("homepage", ""), f.get("description", ""))
28
+ return True
29
+
30
+ for i in range(0, len(names), 40):
31
+ chunk = names[i:i+40]
32
+ if not ingest(run(["brew", "info", "--json=v2", *chunk], timeout=120)):
33
+ # chunk poisoned (e.g. untrusted tap) -> per-name, tolerate failures
34
+ for n in chunk:
35
+ if "/" in n:
36
+ continue
37
+ ingest(run(["brew", "info", "--json=v2", n], timeout=30))
howzo/scan/common.py ADDED
@@ -0,0 +1,48 @@
1
+ """Helpers shared by the scanners."""
2
+ import json
3
+ import os
4
+ import re
5
+ import urllib.request
6
+
7
+ from .. import config
8
+ from ..proc import run
9
+
10
+
11
+ def npm_prefix():
12
+ return run(["npm", "prefix", "-g"]).strip() or os.path.join(config.HOME, ".npm-global")
13
+
14
+
15
+ def pypi_oneliner(name):
16
+ """Short description of a PyPI package ('' on any failure)."""
17
+ try:
18
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=8) as r:
19
+ info = json.loads(r.read().decode())
20
+ return (info.get("info", {}).get("summary") or "").strip()
21
+ except Exception:
22
+ return ""
23
+
24
+
25
+ def header_oneliner(path):
26
+ """First meaningful comment line of a script (a natural one-liner)."""
27
+ try:
28
+ with open(path, "r", errors="ignore") as fh:
29
+ for i, line in enumerate(fh):
30
+ if i > 30:
31
+ break
32
+ s = line.strip()
33
+ if s.startswith("#!") or s in ("#", ""):
34
+ continue
35
+ m = re.match(r"^#\s*(.{12,160})$", s)
36
+ if m:
37
+ return m.group(1)
38
+ except Exception:
39
+ pass
40
+ return ""
41
+
42
+
43
+ def is_text_file(path):
44
+ try:
45
+ with open(path, "rb") as fh:
46
+ return fh.read(2) == b"#!"
47
+ except Exception:
48
+ return False
howzo/scan/npm.py ADDED
@@ -0,0 +1,29 @@
1
+ """Global npm packages (plus the binaries they install)."""
2
+ import json
3
+ import os
4
+ import shutil
5
+
6
+ from ..db import upsert
7
+ from ..proc import run
8
+ from .common import npm_prefix
9
+
10
+
11
+ def scan_npm(c):
12
+ if not shutil.which("npm"):
13
+ return
14
+ out = run(["npm", "ls", "-g", "--depth=0", "--json"], timeout=60)
15
+ try:
16
+ deps = json.loads(out).get("dependencies", {})
17
+ except Exception:
18
+ return
19
+ print(f" npm: {len(deps)} global packages")
20
+ for name, info in deps.items():
21
+ desc = run(["npm", "view", name, "description"], timeout=15).strip()
22
+ upsert(c, name, "npm", info.get("version", ""), "", desc)
23
+ # binaries npm installs (link name != package name, e.g. pi)
24
+ bindir = os.path.join(npm_prefix(), "bin")
25
+ if os.path.isdir(bindir):
26
+ for entry in os.listdir(bindir):
27
+ if entry in deps:
28
+ continue
29
+ upsert(c, entry, "npm", "", os.path.join(bindir, entry), "(npm global binary)")
howzo/scan/npx.py ADDED
@@ -0,0 +1,44 @@
1
+ """On-demand npx/bunx/dlx packages mined from shell history."""
2
+ import os
3
+ import re
4
+
5
+ from .. import config
6
+ from ..db import upsert
7
+ from ..proc import run
8
+
9
+ NPX_RE = re.compile(r"\b(npx|bunx|pnpm dlx)\s+(@?[a-z0-9][a-z0-9-]*(?:/@[a-z0-9-]+)?)(?:@[a-z0-9.]+)?")
10
+
11
+
12
+ def history_files():
13
+ cands = [os.path.join(config.HOME, ".zsh_history"),
14
+ os.path.join(config.HOME, ".bash_history")]
15
+ appdata = os.environ.get("APPDATA")
16
+ if appdata:
17
+ cands.append(os.path.join(appdata, "Microsoft", "Windows", "PowerShell",
18
+ "PSReadLine", "ConsoleHost_history.txt"))
19
+ return [h for h in cands if os.path.exists(h)]
20
+
21
+
22
+ def scan_npx_history(c):
23
+ """Index npx/bunx/dlx packages actually used (from shell history)."""
24
+ hist = next(iter(history_files()), None)
25
+ if not hist:
26
+ return
27
+ pkgs = set()
28
+ try:
29
+ with open(hist, errors="ignore") as fh:
30
+ for line in fh:
31
+ for m in NPX_RE.finditer(line):
32
+ pkgs.add(m.group(2))
33
+ except Exception:
34
+ return
35
+ known = {r["name"] for r in c.execute("SELECT name FROM tools")}
36
+ n = 0
37
+ for pkg in sorted(pkgs):
38
+ base = pkg.split("/")[-1]
39
+ if base in known:
40
+ continue # already covered by a real install
41
+ desc = run(["npm", "view", pkg, "description"], timeout=15).strip()
42
+ upsert(c, base, "npx", "", f"npx {pkg}", desc or f"(npx {pkg} - used from shell history)")
43
+ n += 1
44
+ print(f" npx history: {n} on-demand packages (from {os.path.basename(hist)})")
howzo/scan/path.py ADDED
@@ -0,0 +1,28 @@
1
+ """Windows: executables found on PATH (System32, Program Files, node, cargo, ...)."""
2
+ import os
3
+
4
+ from ..db import upsert
5
+
6
+ WINDOWS_EXTS = (".exe", ".cmd", ".bat", ".ps1")
7
+
8
+
9
+ def scan_path(c):
10
+ path_env = os.environ.get("Path") or os.environ.get("PATH") or ""
11
+ names, seen = [], set()
12
+ for d in path_env.split(os.pathsep):
13
+ if not d:
14
+ continue
15
+ # npm's global bin dir is already covered with versions/descriptions by scan_npm
16
+ if os.path.basename(d.rstrip("\\/")).lower() == "npm":
17
+ continue
18
+ try:
19
+ entries = os.listdir(d)
20
+ except OSError:
21
+ continue
22
+ for e in entries:
23
+ if e.lower().endswith(WINDOWS_EXTS) and e not in seen:
24
+ seen.add(e)
25
+ names.append((e, os.path.join(d, e)))
26
+ print(f" path: {len(names)} executables")
27
+ for name, p in names:
28
+ upsert(c, name, "path", "", p, "")
howzo/scan/pipx.py ADDED
@@ -0,0 +1,31 @@
1
+ """Tools installed via pipx (plus the binaries they provide)."""
2
+ import json
3
+ import os
4
+ import shutil
5
+
6
+ from ..db import upsert
7
+ from ..proc import run
8
+ from .common import pypi_oneliner
9
+
10
+
11
+ def scan_pipx(c):
12
+ if not shutil.which("pipx"):
13
+ return
14
+ out = run(["pipx", "list", "--json"], timeout=30)
15
+ try:
16
+ data = json.loads(out)
17
+ except Exception:
18
+ return
19
+ n = 0
20
+ for venv, v in (data.get("venvs") or {}).items():
21
+ main = (v.get("metadata") or {}).get("main_package") or {}
22
+ name = main.get("package") or venv
23
+ ver = main.get("package_version", "")
24
+ upsert(c, name, "pipx", ver, "", pypi_oneliner(name) or f"(pipx) {name}")
25
+ # also index the binaries this package provides (crwl -> crawl4ai)
26
+ for ap in main.get("app_paths") or []:
27
+ base = os.path.basename(ap.get("__Path__", "")) if isinstance(ap, dict) else os.path.basename(str(ap))
28
+ if base and base != name:
29
+ upsert(c, base, "pipx", ver, ap.get("__Path__", ""), f"(binary of pipx {name})")
30
+ n += 1
31
+ print(f" pipx: {n} packages")
howzo/scan/scripts.py ADDED
@@ -0,0 +1,41 @@
1
+ """User scripts in ~/bin and ~/.local/bin."""
2
+ import os
3
+
4
+ from .. import config
5
+ from ..db import upsert
6
+ from .common import header_oneliner, is_text_file, npm_prefix
7
+
8
+
9
+ def scan_scripts(c):
10
+ npm_root = npm_prefix()
11
+ pipx_root = os.path.join(config.HOME, ".local", "pipx", "venvs")
12
+ n = 0
13
+ seen = set()
14
+ for d in config.SCAN_DIRS:
15
+ if not os.path.isdir(d):
16
+ continue
17
+ for entry in sorted(os.listdir(d)):
18
+ p = os.path.join(d, entry)
19
+ if entry.endswith((".bak",)) or ".bak-" in entry:
20
+ continue
21
+ if os.path.islink(p):
22
+ tgt = os.path.realpath(p)
23
+ # skip links whose target is already covered by another source
24
+ if tgt.startswith(pipx_root) or tgt.startswith(npm_root):
25
+ continue
26
+ src = "local"
27
+ oneliner = header_oneliner(tgt) if is_text_file(tgt) else ""
28
+ path = tgt
29
+ elif os.path.isfile(p) and os.access(p, os.X_OK):
30
+ if is_text_file(p):
31
+ src, oneliner, path = "script", header_oneliner(p), p
32
+ else:
33
+ src, oneliner, path = "local", "", p
34
+ else:
35
+ continue
36
+ if path in seen:
37
+ continue
38
+ seen.add(path)
39
+ upsert(c, entry, src, "", path, oneliner)
40
+ n += 1
41
+ print(f" scripts/local: {n} from {', '.join(os.path.basename(x) for x in config.SCAN_DIRS)}")
howzo/scan/system.py ADDED
@@ -0,0 +1,30 @@
1
+ """System binaries on Unix (/usr/bin, /usr/sbin, /usr/local/bin) via man pages."""
2
+ import os
3
+ import time
4
+
5
+ from ..db import upsert
6
+ from ..helptext import man_oneliner
7
+
8
+ SYSTEM_DIRS = ("/usr/bin", "/usr/sbin", "/usr/local/bin")
9
+
10
+
11
+ def scan_system(c):
12
+ names, seen = [], set()
13
+ for d in SYSTEM_DIRS:
14
+ if not os.path.isdir(d):
15
+ continue
16
+ for n in os.listdir(d):
17
+ if n not in seen:
18
+ seen.add(n)
19
+ names.append(n)
20
+ print(f" system: {len(names)} binaries, fetching man pages (slow part)...")
21
+ n = 0
22
+ for name in names:
23
+ oneliner, excerpt = man_oneliner(name)
24
+ if oneliner or excerpt:
25
+ upsert(c, name, "system", "", f"/usr/bin/{name}", oneliner)
26
+ if excerpt:
27
+ c.execute("UPDATE tools SET help_excerpt=?, help_captured_at=? WHERE name=? AND (help_excerpt='' OR source='system')",
28
+ (excerpt, time.strftime("%Y-%m-%d"), name))
29
+ n += 1
30
+ print(f" system: {n} indexed with man text")
howzo/scan/uv.py ADDED
@@ -0,0 +1,21 @@
1
+ """Tools installed via `uv tool`."""
2
+ import re
3
+ import shutil
4
+
5
+ from ..db import upsert
6
+ from ..proc import run
7
+ from .common import pypi_oneliner
8
+
9
+
10
+ def scan_uv(c):
11
+ if not shutil.which("uv"):
12
+ return
13
+ out = run(["uv", "tool", "list"], timeout=30)
14
+ n = 0
15
+ for line in out.splitlines():
16
+ m = re.match(r"^(\S+) v?(\S+)\s*$", line.strip())
17
+ if m and not line.strip().startswith("-"):
18
+ name = m.group(1)
19
+ upsert(c, name, "uv", m.group(2), "", pypi_oneliner(name) or f"(uv) {name}")
20
+ n += 1
21
+ print(f" uv: {n} tools")
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: howzo
3
+ Version: 0.1.0
4
+ Summary: Knows your machine: ask 'how do I X' in English, get the installed tool + a runnable command.
5
+ Author: Sohail
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sohailchd/howzo
8
+ Project-URL: Repository, https://github.com/sohailchd/howzo
9
+ Keywords: cli,shell,commands,developer-tools,discovery,mcp
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # howzo
26
+
27
+ **Knows your machine.** Ask "how do I X" in English → get the tool that is actually installed on *this* machine, plus a runnable command.
28
+
29
+ howzo is a free, local, **zero-model** command router. It indexes the tools that are actually installed on your box — brew, npm, pipx, uv, system binaries, your own scripts — and answers plain-English questions by matching against that inventory. No accounts, no API keys, no telemetry, and no network access when answering.
30
+
31
+ ## Why
32
+
33
+ Generic command helpers (ShellGPT, mang.sh, Atuin) index a static corpus of popular commands. howzo indexes *your machine* instead — the exact tools, versions, and help text you actually have. If it's installed, howzo knows it; if it isn't, howzo doesn't waste your time suggesting it.
34
+
35
+ ## Install
36
+
37
+ Requires Python 3.9+. Runtime dependencies: **none** (stdlib only).
38
+
39
+ ### macOS / Linux
40
+
41
+ ```sh
42
+ pipx install howzo # or: uv tool install howzo
43
+ ```
44
+
45
+ macOS also has a Homebrew formula (tap required):
46
+
47
+ ```sh
48
+ brew tap sohailchd/howzo && brew install howzo
49
+ ```
50
+
51
+ ### Windows
52
+
53
+ ```powershell
54
+ py -m pipx install howzo # or: uv tool install howzo
55
+ ```
56
+
57
+ ### From source
58
+
59
+ ```sh
60
+ git clone https://github.com/sohailchd/howzo.git
61
+ pipx install --editable howzo # or: uv tool install --editable howzo
62
+ ```
63
+
64
+ ### First run
65
+
66
+ ```sh
67
+ howzo scan # one-time: build your machine's inventory (~2-3 min)
68
+ ```
69
+
70
+ Rescans are safe: captured help text, `when_to_use` notes, custom entries, and mined npx packages are preserved.
71
+
72
+ ## Usage
73
+
74
+ ```console
75
+ $ howzo "how do I rotate a pdf"
76
+ pdfq (pipx, 1.0)
77
+ rotate and convert pdf files
78
+
79
+ $ howzo whatis crwl
80
+ crwl (pipx, 0.3.1)
81
+ (binary of pipx crawl4ai)
82
+
83
+ $ howzo kill a process on port 8080
84
+ lsof (brew, 9.9)
85
+ list open files and network connections
86
+ ```
87
+
88
+ ### Commands
89
+
90
+ | Command | What it does |
91
+ |---|---|
92
+ | `howzo <query>` | Ask in plain English (implicit `ask`) |
93
+ | `howzo scan [--deep]` | Rebuild inventory; `--deep` also captures `--help` text for every tool |
94
+ | `howzo ask "query"` | Same as `<query>` |
95
+ | `howzo whatis <tool>` | Reverse lookup: what is this tool for? |
96
+ | `howzo deep <tool>` | Capture `--help`/man for one tool on demand |
97
+ | `howzo add <name> "desc"` | Register a tool the scanner can't see (internal CLIs, aliases) |
98
+ | `howzo list [--source S]` | Browse the inventory |
99
+ | `howzo mcp` | Run as a stdio MCP server |
100
+ | `howzo db` | Show the database path |
101
+
102
+ ## What it indexes
103
+
104
+ | Source | What's indexed |
105
+ |---|---|
106
+ | brew | formulae + versions + descriptions (`brew info`) |
107
+ | npm | global packages + their installed binaries |
108
+ | pipx | packages + the binaries they provide (e.g. `crwl` → crawl4ai) |
109
+ | uv | `uv tool` installs |
110
+ | scripts | executables in `~/bin` and `~/.local/bin` (one-liner from the script header) |
111
+ | system (Unix) | `/usr/bin` + `/usr/sbin` + `/usr/local/bin` binaries, described via man pages |
112
+ | path (Windows) | executables found on `PATH` (System32, Program Files, …) |
113
+ | npx | `npx`/`bunx`/`pnpm dlx` packages mined from your shell history (zsh, bash, PowerShell) |
114
+ | custom | anything you add with `howzo add` |
115
+
116
+ A typical machine indexes ~1,200 tools.
117
+
118
+ ## How it works
119
+
120
+ - **SQLite + FTS5** at `~/.local/share/howzo/howzo.db` (Windows: `%LOCALAPPDATA%\howzo`), one row per tool: name, source, version, oneliner, when-to-use, help excerpt.
121
+ - **Match = BM25 + word-boundary token-coverage re-rank** in Python. No models, no embeddings — `kill` never matches `skill`, `port` never matches `report`.
122
+ - **~20–30 MB RAM**, and answering is fully offline. The network is only touched while scanning, to fetch package descriptions from npm/PyPI.
123
+ - Set `HOWZO_DB=/some/dir` to relocate the database (also how the test suite isolates itself).
124
+
125
+ ## MCP
126
+
127
+ howzo runs as a stdio MCP server exposing `howzo_ask`, `howzo_whatis`, and `howzo_list`:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "howzo": { "command": "howzo", "args": ["mcp"] }
133
+ }
134
+ }
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ git clone https://github.com/sohailchd/howzo.git && cd howzo
141
+ uv venv .venv
142
+ VIRTUAL_ENV=$PWD/.venv uv pip install -e ".[dev]" # or: pip install -e ".[dev]"
143
+ pytest
144
+ ```
145
+
146
+ Layout:
147
+
148
+ ```
149
+ src/howzo/
150
+ ├── cli.py # entry point + dispatch
151
+ ├── commands.py # scan / ask / whatis / deep / add / list
152
+ ├── config.py # platform constants, DB path (HOWZO_DB override)
153
+ ├── proc.py # subprocess helpers (cross-platform)
154
+ ├── db.py # SQLite + FTS5 schema, upsert
155
+ ├── match.py # tokenization, FTS query, BM25 + coverage re-rank
156
+ ├── render.py # output formatting
157
+ ├── helptext.py # man pages, --help capture
158
+ ├── mcp.py # MCP stdio server
159
+ └── scan/ # one module per source: brew, npm, pipx, uv, scripts, system, path, npx
160
+ tests/ # pytest suite (runs against temp DBs, no network)
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,27 @@
1
+ howzo/__init__.py,sha256=wbsofBIcJjnWA8K6sYTyBLG59qWkqkpoam5mhzrd7Xc,206
2
+ howzo/__main__.py,sha256=PsemdtR6zysUlFr8MlCP9JGUQM2KmNrAevv_w8KqVc8,125
3
+ howzo/cli.py,sha256=1Fh8iPDf5oDY2ddjy7dx2VWfFyXjQ9rPl_1NETvyP8s,1623
4
+ howzo/commands.py,sha256=1Ni008S3A4ELMKratG2aVmZ_moHUV1D_4TfLkPJL4Zk,6633
5
+ howzo/config.py,sha256=_x7fjnP1zAJhH2GCpM7eiKjp1qfm3szfaHuNotn54Po,703
6
+ howzo/db.py,sha256=H_M2UfoxJRSbzg8k6BmRU4JefcRFmr_ZyH3a-xkQemw,2883
7
+ howzo/helptext.py,sha256=CAj3g5aff3IOvv0crQ7T5KG9zol3m1AZXVYDjlg8wcE,1716
8
+ howzo/match.py,sha256=ZVetkTRxdIeJvbJfjH9Sil1aRgsB9s5CgPnB28ZAQdA,2181
9
+ howzo/mcp.py,sha256=jW9yIwuwEFsUURGIEO-U1-aj3EGbw08VEHg3JQizbYA,3062
10
+ howzo/proc.py,sha256=TGlbwVybanndabwp6CinngBo9C6YFzaCWRj6KTqYoaM,749
11
+ howzo/render.py,sha256=jl9RXnI527So8DL3ZbUI0QexE3wH6gwHjradqDgzrqE,1129
12
+ howzo/scan/__init__.py,sha256=cUn0A2COGbxx00I0_1UMXlhGHgyHtZa4BwOcvwSILYY,580
13
+ howzo/scan/brew.py,sha256=DXJXiHWbQ0Hznq_2qEKtNKmJth6nyiB77pNEK2CqfcU,1275
14
+ howzo/scan/common.py,sha256=dFHw1t_I92y_Jd1UWlHN6ceCfZIS6x5gDxe0jT6d__k,1301
15
+ howzo/scan/npm.py,sha256=AKOZwpc5J3nWFb-aFqpAgWZ07q11O7bn8mEIGDQWVOg,970
16
+ howzo/scan/npx.py,sha256=XadO1t0zxjSzwjk6QNP_AYS1Bse3xnnyVpZQlN3HMUY,1565
17
+ howzo/scan/path.py,sha256=S6ssJbzaigVC3XhaYUQ84TII_x02xOnq20aZwbrzfuI,926
18
+ howzo/scan/pipx.py,sha256=yoFKgkL78hkGo44_6fNR61uEV3SUmgKBU4Z6-MKZe44,1106
19
+ howzo/scan/scripts.py,sha256=25FjfFCm7sXYAqS-e6vEAx8dMUNwMFlsj3K5RrqE1dQ,1506
20
+ howzo/scan/system.py,sha256=FK-KAB6X3DknNPdUjDC87Rz4LGvPaD50uD9G1nFUfHY,1040
21
+ howzo/scan/uv.py,sha256=3q2sAnS4IpdOwYFTRlgSQAjHon0B2amUojJC99ozTTI,578
22
+ howzo-0.1.0.dist-info/licenses/LICENSE,sha256=Id6vY80adeWmZFCXGn0ev8-BqTe-1TEKXB7fZcE83uM,1063
23
+ howzo-0.1.0.dist-info/METADATA,sha256=_sui4wRbdftkvss-jl5TJyb0b7oQ-3pyu-R91_NueCs,5717
24
+ howzo-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ howzo-0.1.0.dist-info/entry_points.txt,sha256=bEfbcSm5jzqqufz0Uzv9Z7RUSzyJzv5nU6DbyXjD4sE,41
26
+ howzo-0.1.0.dist-info/top_level.txt,sha256=3JxTqIhWpy1U8TyDoKn03VN5HtqSY1W7SOCeRNal8aw,6
27
+ howzo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ howzo = howzo.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sohail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ howzo