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 +7 -0
- howzo/__main__.py +7 -0
- howzo/cli.py +48 -0
- howzo/commands.py +180 -0
- howzo/config.py +25 -0
- howzo/db.py +74 -0
- howzo/helptext.py +53 -0
- howzo/match.py +59 -0
- howzo/mcp.py +79 -0
- howzo/proc.py +23 -0
- howzo/render.py +32 -0
- howzo/scan/__init__.py +19 -0
- howzo/scan/brew.py +37 -0
- howzo/scan/common.py +48 -0
- howzo/scan/npm.py +29 -0
- howzo/scan/npx.py +44 -0
- howzo/scan/path.py +28 -0
- howzo/scan/pipx.py +31 -0
- howzo/scan/scripts.py +41 -0
- howzo/scan/system.py +30 -0
- howzo/scan/uv.py +21 -0
- howzo-0.1.0.dist-info/METADATA +165 -0
- howzo-0.1.0.dist-info/RECORD +27 -0
- howzo-0.1.0.dist-info/WHEEL +5 -0
- howzo-0.1.0.dist-info/entry_points.txt +2 -0
- howzo-0.1.0.dist-info/licenses/LICENSE +21 -0
- howzo-0.1.0.dist-info/top_level.txt +1 -0
howzo/__init__.py
ADDED
howzo/__main__.py
ADDED
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,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
|