enano 0.2.0__tar.gz

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.
enano-0.2.0/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .DS_Store
enano-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: enano
3
+ Version: 0.2.0
4
+ Summary: Elevation models for personal goals and pitch decks: pull versioned bundles, scan documents deterministically, entirely on your machine.
5
+ License: Proprietary
6
+ Keywords: cli,deterministic,goals,habits,pitch-decks
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: End Users/Desktop
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Provides-Extra: semantic
13
+ Requires-Dist: onnxruntime; extra == 'semantic'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # enano: Explorable nano models for your second brain
17
+
18
+ 28,000+ pieces of advice distilled from key books on the topics of personal goals, career advice and startup pitch decks.
19
+
20
+ Each model package comes with an ONNX model, JSON source attributes, excerpts, custom advice, regex detectors, a vector index, and neural scoring engine.
21
+
22
+ Use cli tools to explore your own .md and .txt files via command or use Claude Cowork, OpenAI Chat GPT directly with the JSON fies and Python scripts.
23
+
24
+ **WEB APP & DEMO**
25
+ https://enano.ai
26
+ Web app is private and runs entirely in frontend, no user data or text is sent, models designed to run entirely in-browser.
27
+ <p align="center">
28
+ <a href="docs/screens/screen-1.png"><img src="docs/screens/screen-1.jpg" width="49%"></a>
29
+ <a href="docs/screens/screen-2.png"><img src="docs/screens/screen-2.jpg" width="49%"></a>
30
+ </p>
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install enano
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```bash
41
+ enano scan # checks .md in the folder against the default model
42
+ ```
43
+
44
+ | Model | Default | Sources | Corpus | Rules | Detectors | Advice | Pull |
45
+ |---|---|---|---|---|---|---|---|
46
+ | **personal-goals** | yes | 21 | 2.3 GB | 15,833 | 31 | 391 | 35.8 MB |
47
+ | **career-advice** | | 5 | 91 MB | 5,026 | 142 | 1,877 | 25 MB |
48
+ | **pitch-decks** | | 5 | 2.2 GB | 75 | 75 | 1,132 | 22.5 MB |
49
+
50
+
51
+ ## Usage
52
+ You can use the packages via the cli toos OR you can use with Claude Cowork et. al.
53
+
54
+ ```bash
55
+ enano pull personal-goals # fetch the personal-goals bundle (~36 MB, sha-verified)
56
+ enano scan notes.md # scan a document (regex plus the optional neural pass)
57
+ enano scan # every .md in the current folder (stdin when piped)
58
+ enano walk # every .md in every subfolder
59
+ enano elevators # list the source works with their counts
60
+ enano elevations "fear of failure" # search the rule corpus, any word or phrase
61
+ enano list # every model you can pull
62
+ enano models # what's installed
63
+ enano use pitch-decks # switch the default model
64
+ enano update # check for newer bundles
65
+ enano upgrade # fetch newer bundles
66
+ enano doctor # environment and store diagnostics
67
+ ```
68
+
69
+ **Global Parameters**
70
+ Every command takes `--json`; there are no other flags to learn. Bare `enano`
71
+ onboards: it pulls the default model and shows the shortest path to a first scan.
72
+
73
+ ## Contact
74
+
75
+ Found a bug or have a question? [Open an issue](https://github.com/enano-ai/enano/issues)
76
+ or visit [enano.ai](https://enano.ai).
enano-0.2.0/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # enano: Explorable nano models for your second brain
2
+
3
+ 28,000+ pieces of advice distilled from key books on the topics of personal goals, career advice and startup pitch decks.
4
+
5
+ Each model package comes with an ONNX model, JSON source attributes, excerpts, custom advice, regex detectors, a vector index, and neural scoring engine.
6
+
7
+ Use cli tools to explore your own .md and .txt files via command or use Claude Cowork, OpenAI Chat GPT directly with the JSON fies and Python scripts.
8
+
9
+ **WEB APP & DEMO**
10
+ https://enano.ai
11
+ Web app is private and runs entirely in frontend, no user data or text is sent, models designed to run entirely in-browser.
12
+ <p align="center">
13
+ <a href="docs/screens/screen-1.png"><img src="docs/screens/screen-1.jpg" width="49%"></a>
14
+ <a href="docs/screens/screen-2.png"><img src="docs/screens/screen-2.jpg" width="49%"></a>
15
+ </p>
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pipx install enano
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ enano scan # checks .md in the folder against the default model
27
+ ```
28
+
29
+ | Model | Default | Sources | Corpus | Rules | Detectors | Advice | Pull |
30
+ |---|---|---|---|---|---|---|---|
31
+ | **personal-goals** | yes | 21 | 2.3 GB | 15,833 | 31 | 391 | 35.8 MB |
32
+ | **career-advice** | | 5 | 91 MB | 5,026 | 142 | 1,877 | 25 MB |
33
+ | **pitch-decks** | | 5 | 2.2 GB | 75 | 75 | 1,132 | 22.5 MB |
34
+
35
+
36
+ ## Usage
37
+ You can use the packages via the cli toos OR you can use with Claude Cowork et. al.
38
+
39
+ ```bash
40
+ enano pull personal-goals # fetch the personal-goals bundle (~36 MB, sha-verified)
41
+ enano scan notes.md # scan a document (regex plus the optional neural pass)
42
+ enano scan # every .md in the current folder (stdin when piped)
43
+ enano walk # every .md in every subfolder
44
+ enano elevators # list the source works with their counts
45
+ enano elevations "fear of failure" # search the rule corpus, any word or phrase
46
+ enano list # every model you can pull
47
+ enano models # what's installed
48
+ enano use pitch-decks # switch the default model
49
+ enano update # check for newer bundles
50
+ enano upgrade # fetch newer bundles
51
+ enano doctor # environment and store diagnostics
52
+ ```
53
+
54
+ **Global Parameters**
55
+ Every command takes `--json`; there are no other flags to learn. Bare `enano`
56
+ onboards: it pulls the default model and shows the shortest path to a first scan.
57
+
58
+ ## Contact
59
+
60
+ Found a bug or have a question? [Open an issue](https://github.com/enano-ai/enano/issues)
61
+ or visit [enano.ai](https://enano.ai).
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("enano")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,595 @@
1
+ """The enano CLI: pull-based bundle management plus a front door to the
2
+ bundle's own deterministic scan tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json as jsonlib
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from . import registry as reg
17
+ from . import store
18
+
19
+
20
+ def _parse_selector(selector: str) -> tuple[str, str | None]:
21
+ model, _, version = selector.partition("@")
22
+ return model.strip(), (version.strip() or None) if version else None
23
+
24
+
25
+ def _require_bundle(selector: str, auto_pull: bool, quiet: bool = False) -> Path:
26
+ model, version = _parse_selector(selector)
27
+ path = store.resolve_version(model, version)
28
+ if path:
29
+ return path
30
+ entry = reg.resolve_entry(reg.fetch_registry(refresh=True), model, version)
31
+ size_mb = round(entry["bytes"] / 1e6, 1)
32
+ if not auto_pull:
33
+ print(f"{model} is not installed locally. Run: enano pull {model} ({size_mb} MB)")
34
+ raise SystemExit(2)
35
+ prompt = f"{model}@{entry['version']} is not installed — download it now ({size_mb} MB)? [y/N] "
36
+ if sys.stdin.isatty() and input(prompt).strip().lower() not in ("y", "yes"):
37
+ raise SystemExit(2)
38
+ if not quiet:
39
+ print(f"pulling {model}@{entry['version']} …")
40
+ _pull(model, entry)
41
+ path = store.resolve_version(model, version)
42
+ if not path:
43
+ raise SystemExit(f"pull finished but {selector} still not resolvable")
44
+ return path
45
+
46
+
47
+ def _run_tool(bundle: Path, tool: str, forward: list[str]) -> int:
48
+ tool_path = bundle / "tools" / f"{tool}.py"
49
+ if not tool_path.is_file():
50
+ runnable = (bundle / "tools" / "scan.py").is_file()
51
+ hint = "" if runnable else " (this bundle ships no scanner — weights/corpus only)"
52
+ raise SystemExit(f"tool '{tool}' not found in {bundle.name}{hint}")
53
+ return subprocess.call([sys.executable, str(tool_path), *forward], cwd=str(bundle))
54
+
55
+
56
+ def _pull(model: str, entry: dict, quiet: bool = False) -> Path:
57
+ archive = reg.download_archive(
58
+ entry.get("url") or entry["mirrorUrl"],
59
+ entry.get("bytes", 0),
60
+ entry["sha256"],
61
+ store.store_root() / "archives",
62
+ )
63
+ target = store.install_bundle(model, entry["version"], archive)
64
+ if not quiet:
65
+ print(f"installed {model}@{entry['version']} -> {target}")
66
+ return target
67
+
68
+
69
+ # ------------------------------------------------------------- subcommands
70
+
71
+
72
+ def cmd_pull(args: argparse.Namespace) -> int:
73
+ registry = reg.fetch_registry(refresh=True)
74
+ selectors = list(args.selectors or [])
75
+ if selectors == ["all"]:
76
+ selectors = sorted(reg.registry_models(registry))
77
+ if not selectors:
78
+ raise SystemExit("nothing to pull: give a model (or: enano pull all)")
79
+ results = []
80
+ for selector in selectors:
81
+ model, version = _parse_selector(selector)
82
+ entry = reg.resolve_entry(registry, model, version)
83
+ if store.model_dir(model, entry["version"]).is_dir():
84
+ if not args.json:
85
+ print(f"{model}@{entry['version']} already installed")
86
+ results.append({"model": model, "version": entry["version"], "status": "already-installed"})
87
+ continue
88
+ _pull(model, entry, quiet=args.json)
89
+ results.append({"model": model, "version": entry["version"], "status": "installed"})
90
+ if args.json:
91
+ print(jsonlib.dumps(results, indent=2))
92
+ return 0
93
+
94
+
95
+ def _human_bytes(size: float) -> str:
96
+ for unit in ("B", "KB", "MB", "GB"):
97
+ if size < 1000:
98
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
99
+ size /= 1000
100
+ return f"{size:.1f} TB"
101
+
102
+
103
+ def _clip(text: str, width: int) -> str:
104
+ return text if len(text) <= width else text[: width - 1].rstrip() + "…"
105
+
106
+
107
+ def cmd_list(args: argparse.Namespace) -> int:
108
+ """Remote: the registry catalog — one row per model at latest."""
109
+ registry = reg.fetch_registry(refresh=True)
110
+ models = reg.registry_models(registry)
111
+ if not models:
112
+ raise SystemExit("registry has no models published")
113
+ rows = []
114
+ for name in sorted(models):
115
+ entry = models[name]
116
+ version = entry.get("latest") or sorted(entry.get("versions", {}))[-1]
117
+ meta = entry.get("versions", {}).get(version, {})
118
+ local = store.read_latest(name)
119
+ if local == version:
120
+ state = "installed"
121
+ elif local:
122
+ state = f"update ({local})"
123
+ else:
124
+ state = "-"
125
+ rows.append(
126
+ {
127
+ "model": name,
128
+ "version": version,
129
+ "description": entry.get("description", ""),
130
+ "bytes": meta.get("bytes", 0),
131
+ "state": state,
132
+ }
133
+ )
134
+ if args.json:
135
+ print(jsonlib.dumps(rows, indent=2))
136
+ return 0
137
+ name_w = max([len("NAME")] + [len(r["model"]) for r in rows])
138
+ desc_w = 44
139
+ print(f"{'NAME':<{name_w}} {'DESCRIPTION':<{desc_w}} {'SIZE':>8} {'UPDATED':<11} STATE")
140
+ for r in rows:
141
+ print(
142
+ f"{r['model']:<{name_w}} {_clip(r['description'], desc_w):<{desc_w}}"
143
+ f" {_human_bytes(r['bytes']):>8} {r['version']:<11} {r['state']}"
144
+ )
145
+ return 0
146
+
147
+
148
+ def cmd_models(args: argparse.Namespace) -> int:
149
+ """Local: what's installed in the store."""
150
+ rows = []
151
+ for m in store.local_models():
152
+ rows.append(
153
+ {
154
+ "name": f"{m.model}@{m.version}",
155
+ "bytes": m.bytes,
156
+ "pulled": time.strftime("%Y-%m-%d", time.localtime(m.path.stat().st_mtime)),
157
+ "state": "latest" if store.read_latest(m.model) == m.version else "old",
158
+ }
159
+ )
160
+ if not rows:
161
+ print("nothing installed — try: enano pull personal-goals (enano list shows what exists)")
162
+ return 0
163
+ if args.json:
164
+ print(jsonlib.dumps(rows, indent=2))
165
+ return 0
166
+ name_w = max(len(r["name"]) for r in rows)
167
+ print(f"{'NAME':<{name_w}} {'SIZE':>8} PULLED STATE")
168
+ for r in rows:
169
+ print(f"{r['name']:<{name_w}} {_human_bytes(r['bytes']):>8} {r['pulled']} {r['state']}")
170
+ return 0
171
+
172
+
173
+ def _update_run(args: argparse.Namespace, fetch: bool) -> int:
174
+ registry = reg.fetch_registry(refresh=True)
175
+ targets = [args.model] if args.model else sorted({m.model for m in store.local_models()})
176
+ rows = []
177
+ for model in targets:
178
+ entry = reg.registry_models(registry).get(model)
179
+ if not entry:
180
+ rows.append({"model": model, "status": "not-in-registry"})
181
+ if not args.json:
182
+ print(f"{model}: not in registry")
183
+ continue
184
+ latest = entry.get("latest")
185
+ local_latest = store.read_latest(model)
186
+ if latest == local_latest:
187
+ rows.append({"model": model, "current": local_latest, "latest": latest, "status": "up-to-date"})
188
+ if not args.json:
189
+ print(f"{model}: up to date ({latest})")
190
+ continue
191
+ rows.append({"model": model, "current": local_latest, "latest": latest, "status": "update-available"})
192
+ if not args.json:
193
+ print(f"{model}: {local_latest or 'none'} -> {latest}")
194
+ if fetch:
195
+ _pull(model, reg.resolve_entry(registry, model, latest), quiet=args.json)
196
+ if args.json:
197
+ print(jsonlib.dumps(rows, indent=2))
198
+ return 0
199
+
200
+
201
+ def cmd_update(args: argparse.Namespace) -> int:
202
+ return _update_run(args, fetch=False)
203
+
204
+
205
+ def cmd_upgrade(args: argparse.Namespace) -> int:
206
+ return _update_run(args, fetch=True)
207
+
208
+
209
+ def cmd_use(args: argparse.Namespace) -> int:
210
+ if args.model:
211
+ registry = reg.fetch_registry(refresh=True)
212
+ if args.model not in reg.registry_models(registry):
213
+ raise SystemExit(f"unknown model {args.model!r} (enano list shows what exists)")
214
+ store.write_default_model(args.model)
215
+ current = store.default_model()
216
+ if args.json:
217
+ print(jsonlib.dumps({"default_model": current}))
218
+ return 0
219
+ note = "" if store.resolve_version(current, None) else f" (not installed — enano pull {current})"
220
+ print(f"default model: {current}{note}")
221
+ return 0
222
+
223
+
224
+ def cmd_info(args: argparse.Namespace) -> int:
225
+ model, version = _parse_selector(args.model)
226
+ path = store.resolve_version(model, version)
227
+ if not path:
228
+ raise SystemExit(f"{args.model} is not installed (try: enano pull {model})")
229
+ m = next((lm for lm in store.local_models() if lm.path == path), None)
230
+ doc = m.version_doc if m else {}
231
+ info = {
232
+ "model": model,
233
+ "version": doc.get("built_at") or path.name,
234
+ "latest_local": store.read_latest(model),
235
+ "path": str(path),
236
+ "disk_mb": round((m.bytes if m else 0) / 1e6, 1),
237
+ "counts": doc.get("counts", {}),
238
+ "runnable": (path / "tools" / "scan.py").is_file(),
239
+ }
240
+ if args.json:
241
+ print(jsonlib.dumps(info, indent=2))
242
+ else:
243
+ for key, value in info.items():
244
+ print(f"{key:>12}: {value}")
245
+ return 0
246
+
247
+
248
+ def cmd_rm(args: argparse.Namespace) -> int:
249
+ model, version = _parse_selector(args.model)
250
+ if not version:
251
+ versions = [lm.version for lm in store.local_models() if lm.model == model]
252
+ if len(versions) == 1:
253
+ version = versions[0]
254
+ elif versions:
255
+ raise SystemExit(f"multiple versions installed: {versions} — pick {model}@<version>")
256
+ if store.read_latest(model) == version:
257
+ prompt = f"{model}@{version} is what 'latest' points at — remove it anyway? [y/N] "
258
+ if sys.stdin.isatty():
259
+ if input(prompt).strip().lower() not in ("y", "yes"):
260
+ raise SystemExit(2)
261
+ else:
262
+ raise SystemExit(
263
+ f"refusing to remove {model}@{version}: 'latest' points at it (confirm on a terminal)"
264
+ )
265
+ if store.remove_version(model, version, force=True):
266
+ if args.json:
267
+ print(jsonlib.dumps({"removed": f"{model}@{version}"}))
268
+ else:
269
+ print(f"removed {model}@{version}")
270
+ return 0
271
+ raise SystemExit(f"{model}@{version} is not installed")
272
+
273
+
274
+ def cmd_prune(args: argparse.Namespace) -> int:
275
+ everything = args.target == "all"
276
+ removed = []
277
+ for m in store.local_models():
278
+ if not everything and store.read_latest(m.model) == m.version:
279
+ continue
280
+ store.remove_version(m.model, m.version, force=True)
281
+ removed.append(f"{m.model}@{m.version}")
282
+ if everything:
283
+ shutil.rmtree(store.models_root(), ignore_errors=True)
284
+ if args.json:
285
+ print(jsonlib.dumps({"removed": removed, "store_emptied": everything}))
286
+ return 0
287
+ for name in removed:
288
+ print(f"removed {name}")
289
+ if everything and removed:
290
+ print("store emptied")
291
+ if not removed:
292
+ print("nothing to prune")
293
+ return 0
294
+
295
+
296
+ def cmd_verify(args: argparse.Namespace) -> int:
297
+ if args.model:
298
+ bundle = _require_bundle(args.model, auto_pull=False)
299
+ targets = [m for m in store.local_models() if m.path == bundle]
300
+ else:
301
+ targets = store.local_models()
302
+ results = []
303
+ for m in targets:
304
+ tool = m.path / "tools" / "verify.py"
305
+ if not tool.is_file():
306
+ results.append({"model": m.model, "version": m.version, "ok": None, "note": "no verify tool in bundle"})
307
+ continue
308
+ out = subprocess.run(
309
+ [sys.executable, str(tool)], cwd=str(m.path), capture_output=True, text=True
310
+ )
311
+ results.append(
312
+ {"model": m.model, "version": m.version, "ok": out.returncode == 0, "output": out.stdout.strip()}
313
+ )
314
+ ok = all(r.get("ok") for r in results)
315
+ if args.json:
316
+ print(jsonlib.dumps({"ok": ok, "results": results}, indent=2))
317
+ else:
318
+ for r in results:
319
+ mark = "ok" if r.get("ok") else ("n/a" if r.get("ok") is None else "FAIL")
320
+ print(f"[{mark}] {r['model']}@{r['version']}")
321
+ if r.get("output") and not r.get("ok"):
322
+ print(" " + r["output"].replace("\n", "\n "))
323
+ return 0 if ok else 1
324
+
325
+
326
+ def cmd_scan(args: argparse.Namespace) -> int:
327
+ bundle = _require_bundle(store.default_model(), auto_pull=True, quiet=args.json)
328
+ forward = list(args.forward)
329
+ json_flag = ["--json"] if args.json else []
330
+ if forward:
331
+ return _run_tool(bundle, "scan", forward + json_flag)
332
+ if not sys.stdin.isatty():
333
+ return _run_tool(bundle, "scan", ["-"] + json_flag)
334
+ docs = sorted(Path.cwd().glob("*.md"))
335
+ if not docs:
336
+ raise SystemExit("no .md files in the current directory (or pass a file: enano scan notes.md)")
337
+ print(f"scanning {len(docs)} markdown file(s) in {Path.cwd()}", flush=True)
338
+ failed = 0
339
+ for doc in docs:
340
+ print(f"\n===== {doc.name} =====", flush=True)
341
+ failed += _run_tool(bundle, "scan", [str(doc)] + json_flag) != 0
342
+ return 1 if failed else 0
343
+
344
+
345
+ SKIP_DIRS = {"node_modules", "__pycache__", ".venv"}
346
+
347
+
348
+ def _walk_docs(root: Path) -> list[Path]:
349
+ docs = []
350
+ for path in sorted(root.rglob("*.md")):
351
+ dirs = path.relative_to(root).parts[:-1]
352
+ if any(part in SKIP_DIRS or part.startswith(".") for part in dirs):
353
+ continue
354
+ docs.append(path)
355
+ return docs
356
+
357
+
358
+ def cmd_walk(args: argparse.Namespace) -> int:
359
+ bundle = _require_bundle(store.default_model(), auto_pull=True, quiet=args.json)
360
+ tool = bundle / "tools" / "scan.py"
361
+ if not tool.is_file():
362
+ raise SystemExit(f"this bundle ships no scanner ({bundle.name})")
363
+ root = Path(args.directory) if args.directory else Path.cwd()
364
+ if not root.is_dir():
365
+ raise SystemExit(f"not a directory: {root}")
366
+ docs = _walk_docs(root)
367
+ if not docs:
368
+ raise SystemExit(f"no .md files under {root}")
369
+ if args.json:
370
+ results = []
371
+ failed = 0
372
+ for doc in docs:
373
+ out = subprocess.run(
374
+ [sys.executable, str(tool), str(doc), "--json"],
375
+ cwd=str(bundle), capture_output=True, text=True,
376
+ )
377
+ ok = out.returncode == 0
378
+ failed += 0 if ok else 1
379
+ try:
380
+ parsed = jsonlib.loads(out.stdout)
381
+ except jsonlib.JSONDecodeError:
382
+ parsed = out.stdout.strip()
383
+ results.append({"path": str(doc.relative_to(root)), "ok": ok, "result": parsed})
384
+ print(jsonlib.dumps(
385
+ {"root": str(root), "files": len(results), "failed": failed, "results": results}, indent=2
386
+ ))
387
+ return 1 if failed else 0
388
+ print(f"walking {len(docs)} markdown file(s) under {root}", flush=True)
389
+ failed = 0
390
+ for doc in docs:
391
+ print(f"\n===== {doc.relative_to(root)} =====", flush=True)
392
+ failed += _run_tool(bundle, "scan", [str(doc)]) != 0
393
+ return 1 if failed else 0
394
+
395
+
396
+ def cmd_elevators(args: argparse.Namespace) -> int:
397
+ bundle = _require_bundle(store.default_model(), auto_pull=False)
398
+ forward = list(args.forward)
399
+ if args.json:
400
+ forward.append("--json")
401
+ return _run_tool(bundle, "elevators", forward)
402
+
403
+
404
+ def cmd_elevations(args: argparse.Namespace) -> int:
405
+ bundle = _require_bundle(store.default_model(), auto_pull=False)
406
+ forward = list(args.forward)
407
+ if args.query and not any(a in forward for a in ("--search", "--id", "--elevator")):
408
+ forward = ["--search", args.query, *forward]
409
+ if args.json:
410
+ forward.append("--json")
411
+ return _run_tool(bundle, "elevations", forward)
412
+
413
+
414
+ def cmd_doctor(args: argparse.Namespace) -> int:
415
+ checks = []
416
+
417
+ def line(label: str, detail: str, good: bool) -> None:
418
+ checks.append({"check": label, "detail": detail, "ok": good})
419
+
420
+ import importlib.util
421
+
422
+ line("python", sys.version.split()[0], sys.version_info >= (3, 9))
423
+ root = store.store_root()
424
+ try:
425
+ root.mkdir(parents=True, exist_ok=True)
426
+ probe = root / ".write-test"
427
+ probe.write_text("x")
428
+ probe.unlink()
429
+ line("store", str(root), True)
430
+ except OSError as exc:
431
+ line("store", f"{root}: {exc}", False)
432
+ line("disk free", f"{shutil.disk_usage(root).free / 1e9:.1f} GB", shutil.disk_usage(root).free > 500e6)
433
+ try:
434
+ registry = reg.fetch_registry(refresh=True)
435
+ line("registry", f"{len(reg.registry_models(registry))} model(s) indexed", True)
436
+ except SystemExit as exc:
437
+ line("registry", str(exc), False)
438
+ line("default model", store.default_model(), True)
439
+ line("onnxruntime", "installed (semantic on)" if importlib.util.find_spec("onnxruntime") else "absent (regex-only fallback)", True)
440
+ for m in store.local_models():
441
+ doc = m.version_doc
442
+ line(
443
+ f"{m.model}@{m.version}",
444
+ f"verified manifest, runnable={m.runnable}" if doc else "VERSION.json unreadable",
445
+ bool(doc),
446
+ )
447
+ ok = all(c["ok"] for c in checks)
448
+ if args.json:
449
+ print(jsonlib.dumps({"ok": ok, "checks": checks}, indent=2))
450
+ else:
451
+ for c in checks:
452
+ print(f" [{'ok' if c['ok'] else 'FAIL'}] {c['check']:<20} {c['detail']}")
453
+ print("doctor:", "healthy" if ok else "problems found")
454
+ return 0 if ok else 1
455
+
456
+
457
+ # ------------------------------------------------------------------- parser
458
+
459
+
460
+ def build_parser() -> argparse.ArgumentParser:
461
+ parser = argparse.ArgumentParser(
462
+ prog="enano",
463
+ description="Elevation models on your machine: pull versioned bundles, scan documents deterministically.",
464
+ )
465
+ parser.add_argument("--version", action="version", version=f"enano {__version__}")
466
+ parser.add_argument("--no-color", action="store_true", help=argparse.SUPPRESS)
467
+ sub = parser.add_subparsers(dest="command")
468
+
469
+ p = sub.add_parser("pull", help="download and install a model bundle")
470
+ p.add_argument("selectors", nargs="*", help="model[@version] … or 'all' (default version: latest)")
471
+ p.add_argument("--json", action="store_true")
472
+ p.set_defaults(func=cmd_pull)
473
+
474
+ p = sub.add_parser("list", help="list registry models (what you can pull)")
475
+ p.add_argument("--json", action="store_true")
476
+ p.set_defaults(func=cmd_list)
477
+
478
+ p = sub.add_parser("models", help="list installed models")
479
+ p.add_argument("--json", action="store_true")
480
+ p.set_defaults(func=cmd_models)
481
+
482
+ p = sub.add_parser("update", help="check for newer versions of local models")
483
+ p.add_argument("model", nargs="?", help="limit to one model")
484
+ p.add_argument("--json", action="store_true")
485
+ p.set_defaults(func=cmd_update)
486
+
487
+ p = sub.add_parser("upgrade", help="fetch newer versions and re-point latest")
488
+ p.add_argument("model", nargs="?", help="limit to one model")
489
+ p.add_argument("--json", action="store_true")
490
+ p.set_defaults(func=cmd_upgrade)
491
+
492
+ p = sub.add_parser("use", help="show or set the default model")
493
+ p.add_argument("model", nargs="?", help="model name (omit to just show)")
494
+ p.add_argument("--json", action="store_true")
495
+ p.set_defaults(func=cmd_use)
496
+
497
+ p = sub.add_parser("info", help="manifest + counts + path for an installed model")
498
+ p.add_argument("model", help="model[@version]")
499
+ p.add_argument("--json", action="store_true")
500
+ p.set_defaults(func=cmd_info)
501
+
502
+ p = sub.add_parser("rm", help="remove an installed version")
503
+ p.add_argument("model", help="model[@version]")
504
+ p.add_argument("--json", action="store_true")
505
+ p.set_defaults(func=cmd_rm)
506
+
507
+ p = sub.add_parser("prune", help="remove non-latest local versions ('all': empty the store)")
508
+ p.add_argument("target", nargs="?", help="'all' to remove everything")
509
+ p.add_argument("--json", action="store_true")
510
+ p.set_defaults(func=cmd_prune)
511
+
512
+ p = sub.add_parser("verify", help="verify bundle checksums against VERSION.json")
513
+ p.add_argument("model", nargs="?", help="model[@version]; omit to verify everything local")
514
+ p.add_argument("--json", action="store_true")
515
+ p.set_defaults(func=cmd_verify)
516
+
517
+ p = sub.add_parser("scan", help="scan a document with the default model (enano use switches it); no file: every .md in the current folder")
518
+ p.add_argument("--json", action="store_true")
519
+ p.add_argument("forward", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
520
+ p.set_defaults(func=cmd_scan)
521
+
522
+ p = sub.add_parser("walk", help="scan every .md under a folder, recursively")
523
+ p.add_argument("directory", nargs="?", help="folder to walk (default: the current one)")
524
+ p.add_argument("--json", action="store_true")
525
+ p.set_defaults(func=cmd_walk)
526
+
527
+ p = sub.add_parser("elevators", help="list the default model's source works (enano use switches it)")
528
+ p.add_argument("--json", action="store_true")
529
+ p.add_argument("forward", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
530
+ p.set_defaults(func=cmd_elevators)
531
+
532
+ p = sub.add_parser("elevations", help="search the default model's rule corpus (enano use switches it)")
533
+ p.add_argument("query", nargs="?", help="search text (positional)")
534
+ p.add_argument("--json", action="store_true")
535
+ p.add_argument("forward", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
536
+ p.set_defaults(func=cmd_elevations)
537
+
538
+ p = sub.add_parser("doctor", help="environment + store diagnostics")
539
+ p.add_argument("--json", action="store_true")
540
+ p.set_defaults(func=cmd_doctor)
541
+
542
+ p = sub.add_parser("help", help="show help (for one command: enano help scan)")
543
+ p.add_argument("topic", nargs="?", help="command name (default: top-level help)")
544
+ p.set_defaults(func=cmd_help)
545
+
546
+ return parser
547
+
548
+
549
+ def cmd_help(args: argparse.Namespace) -> int:
550
+ parser = build_parser()
551
+ if not args.topic:
552
+ parser.print_help()
553
+ return 0
554
+ subaction = next((a for a in parser._actions if isinstance(a, argparse._SubParsersAction)), None)
555
+ target = subaction.choices.get(args.topic) if subaction else None
556
+ if target is None:
557
+ raise SystemExit(f"unknown command {args.topic!r} — try: enano help")
558
+ target.print_help()
559
+ return 0
560
+
561
+
562
+ def cmd_bare() -> int:
563
+ """Bare `enano`: onboard with the default model — pull it if missing,
564
+ then show the shortest path to a first scan."""
565
+ model = store.default_model()
566
+ path = store.resolve_version(model, None)
567
+ if not path:
568
+ print(f"no models installed — pulling the default ({model}) …")
569
+ entry = reg.resolve_entry(reg.fetch_registry(refresh=True), model, None)
570
+ _pull(model, entry)
571
+ path = store.resolve_version(model, None)
572
+ if path:
573
+ print(f"ready: {model} is installed ({path.name})")
574
+ print()
575
+ print(" enano scan notes.md scan one document")
576
+ print(" enano scan scan every .md in the current folder")
577
+ print(" enano walk scan every .md in every subfolder")
578
+ print(" enano list see every model you can pull")
579
+ print(f" enano use <model> switch the default (now: {model})")
580
+ return 0
581
+
582
+
583
+ def main(argv: list[str] | None = None) -> int:
584
+ parser = build_parser()
585
+ args = parser.parse_args(argv)
586
+ if not getattr(args, "command", None):
587
+ return cmd_bare()
588
+ forward = getattr(args, "forward", None)
589
+ if forward and forward[:1] == ["--"]:
590
+ args.forward = forward[1:]
591
+ return args.func(args) or 0
592
+
593
+
594
+ if __name__ == "__main__":
595
+ raise SystemExit(main())
@@ -0,0 +1,112 @@
1
+ """Registry client: fetch the index, verify and download bundle artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import tempfile
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ DEFAULT_REGISTRY_URL = (
13
+ "https://amplify-ddcl74bt0u5nd-mai-enanopacksbucket30a8cb47-dzorvtabphoz"
14
+ ".s3.us-east-2.amazonaws.com/builtin-data/model-zips/registry.json"
15
+ )
16
+
17
+
18
+ def registry_url() -> str:
19
+ import os
20
+
21
+ return os.environ.get("ETINY_REGISTRY", DEFAULT_REGISTRY_URL)
22
+
23
+
24
+ def fetch_registry(refresh: bool = False, cache_path: Path | None = None) -> dict[str, Any]:
25
+ """Fetch the registry index; cache it in the store between commands."""
26
+ from .store import registry_cache_path
27
+
28
+ path = cache_path or registry_cache_path()
29
+ if not refresh and path.is_file():
30
+ try:
31
+ return json.loads(path.read_text(encoding="utf-8"))
32
+ except (json.JSONDecodeError, OSError):
33
+ pass
34
+ doc = _get_json(registry_url())
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ path.write_text(json.dumps(doc, indent=2), encoding="utf-8")
37
+ return doc
38
+
39
+
40
+ def _get_json(url: str) -> Any:
41
+ with urllib.request.urlopen(url, timeout=30) as response:
42
+ if response.status != 200:
43
+ raise SystemExit(f"registry fetch failed: {url} -> {response.status}")
44
+ return json.loads(response.read().decode("utf-8"))
45
+
46
+
47
+ def registry_models(registry: dict[str, Any]) -> dict[str, Any]:
48
+ return registry.get("models", {})
49
+
50
+
51
+ def resolve_entry(registry: dict[str, Any], model: str, version: str | None) -> dict[str, Any]:
52
+ models = registry_models(registry)
53
+ if model not in models:
54
+ known = ", ".join(sorted(models)) or "none published"
55
+ raise SystemExit(f"unknown model {model!r} (registry has: {known})")
56
+ entry = models[model]
57
+ if version is None:
58
+ version = entry.get("latest")
59
+ if not version:
60
+ raise SystemExit(f"model {model!r} has no 'latest' version in the registry")
61
+ versions = entry.get("versions", {})
62
+ if version not in versions:
63
+ raise SystemExit(
64
+ f"unknown version {model}@{version} (available: {', '.join(sorted(versions))})"
65
+ )
66
+ return {"model": model, "version": version, **versions[version]}
67
+
68
+
69
+ def sha256_file(path: Path) -> str:
70
+ digest = hashlib.sha256()
71
+ with path.open("rb") as handle:
72
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
73
+ digest.update(chunk)
74
+ return digest.hexdigest()
75
+
76
+
77
+ def download_archive(url: str, expected_bytes: int, sha256: str, dest_dir: Path) -> Path:
78
+ """Stream a bundle zip to disk, verifying size + sha. Progress readout
79
+ only when attached to a terminal; piped/CI output stays clean."""
80
+ import sys
81
+
82
+ dest_dir.mkdir(parents=True, exist_ok=True)
83
+ dest = dest_dir / url.rsplit("/", 1)[-1]
84
+ tty = sys.stdout.isatty()
85
+ with tempfile.NamedTemporaryFile(dir=dest_dir, delete=False, suffix=".part") as tmp:
86
+ with urllib.request.urlopen(url, timeout=60) as response:
87
+ if response.status != 200:
88
+ raise SystemExit(f"download failed: {url} -> {response.status}")
89
+ total = int(response.headers.get("Content-Length") or expected_bytes)
90
+ done = 0
91
+ if not tty:
92
+ print(f" downloading {total / 1e6:.1f} MB …", flush=True)
93
+ while True:
94
+ chunk = response.read(1 << 18)
95
+ if not chunk:
96
+ break
97
+ tmp.write(chunk)
98
+ done += len(chunk)
99
+ if tty:
100
+ pct = min(100, round(done * 100 / total)) if total else 0
101
+ print(f"\r {pct:>3}% {done / 1e6:6.1f} / {total / 1e6:.1f} MB", end="", flush=True)
102
+ if tty:
103
+ print()
104
+ if expected_bytes and done != expected_bytes:
105
+ raise SystemExit(f"size mismatch: got {done} bytes, registry says {expected_bytes}")
106
+ tmp_path = Path(tmp.name)
107
+ digest = sha256_file(tmp_path)
108
+ if digest != sha256:
109
+ tmp_path.unlink(missing_ok=True)
110
+ raise SystemExit(f"sha256 mismatch: {digest} != {sha256} — download corrupted or tampered")
111
+ tmp_path.replace(dest)
112
+ return dest
@@ -0,0 +1,164 @@
1
+ """Local bundle store: ~/.enano layout (legacy ~/.etiny still honored)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import zipfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ DEFAULT_HOME = Path.home() / ".enano"
13
+ _LEGACY_HOME = Path.home() / ".etiny"
14
+
15
+
16
+ def store_root() -> Path:
17
+ env = os.environ.get("ENANO_HOME") or os.environ.get("ETINY_HOME")
18
+ if env:
19
+ return Path(env)
20
+ if _LEGACY_HOME.is_dir():
21
+ return _LEGACY_HOME
22
+ return DEFAULT_HOME
23
+
24
+
25
+ def registry_cache_path() -> Path:
26
+ return store_root() / "registry.json"
27
+
28
+
29
+ def default_model() -> str:
30
+ """The model `use` selected (persisted); falls back to the global default."""
31
+ pointer = store_root() / "default-model"
32
+ if pointer.is_file():
33
+ value = pointer.read_text(encoding="utf-8").strip()
34
+ if value:
35
+ return value
36
+ return "personal-goals"
37
+
38
+
39
+ def write_default_model(model: str) -> None:
40
+ root = store_root()
41
+ root.mkdir(parents=True, exist_ok=True)
42
+ (root / "default-model").write_text(model, encoding="utf-8")
43
+
44
+
45
+ def models_root() -> Path:
46
+ return store_root() / "models"
47
+
48
+
49
+ def model_dir(model: str, version: str) -> Path:
50
+ return models_root() / model / version
51
+
52
+
53
+ @dataclass
54
+ class LocalModel:
55
+ model: str
56
+ version: str
57
+ path: Path
58
+ bytes: int
59
+
60
+ @property
61
+ def version_doc(self) -> dict:
62
+ path = self.path / "VERSION.json"
63
+ if not path.is_file():
64
+ return {}
65
+ try:
66
+ return json.loads(path.read_text(encoding="utf-8"))
67
+ except (json.JSONDecodeError, OSError):
68
+ return {}
69
+
70
+ @property
71
+ def runnable(self) -> bool:
72
+ return (self.path / "tools" / "scan.py").is_file()
73
+
74
+
75
+ def read_latest(model: str) -> str | None:
76
+ pointer = models_root() / model / "latest"
77
+ if pointer.is_symlink():
78
+ target = pointer.resolve()
79
+ return target.name if target.is_dir() else None
80
+ if pointer.is_file():
81
+ value = pointer.read_text(encoding="utf-8").strip()
82
+ return value or None
83
+ return None
84
+
85
+
86
+ def write_latest(model: str, version: str) -> None:
87
+ pointer = models_root() / model / "latest"
88
+ if pointer.is_symlink() or pointer.is_file():
89
+ pointer.unlink()
90
+ try:
91
+ pointer.symlink_to(version, target_is_directory=True)
92
+ except OSError:
93
+ pointer.write_text(version, encoding="utf-8")
94
+
95
+
96
+ def resolve_version(model: str, version: str | None) -> Path | None:
97
+ """Resolve name or name@version to a bundle directory on disk."""
98
+ if version:
99
+ path = model_dir(model, version)
100
+ return path if path.is_dir() else None
101
+ latest = read_latest(model)
102
+ if not latest:
103
+ return None
104
+ path = model_dir(model, latest)
105
+ return path if path.is_dir() else None
106
+
107
+
108
+ def local_models() -> list[LocalModel]:
109
+ out: list[LocalModel] = []
110
+ root = models_root()
111
+ if not root.is_dir():
112
+ return out
113
+ for model_path in sorted(root.iterdir()):
114
+ if not model_path.is_dir():
115
+ continue
116
+ for version_path in sorted(model_path.iterdir()):
117
+ if not version_path.is_dir() or version_path.name == "latest":
118
+ continue
119
+ out.append(
120
+ LocalModel(
121
+ model=model_path.name,
122
+ version=version_path.name,
123
+ path=version_path,
124
+ bytes=sum(f.stat().st_size for f in version_path.rglob("*") if f.is_file()),
125
+ )
126
+ )
127
+ return out
128
+
129
+
130
+ def install_bundle(model: str, version: str, archive: Path) -> Path:
131
+ """Extract a bundle zip into the store and point latest at it.
132
+
133
+ The zip contains a single top-level directory (enano-<model>-model-<date>
134
+ or etiny-...); its contents are lifted into models/<model>/<version>/ so
135
+ the store is addressed by clean names regardless of the artifact naming.
136
+ """
137
+ staging = model_dir(model, f".{version}.tmp")
138
+ if staging.exists():
139
+ shutil.rmtree(staging)
140
+ staging.mkdir(parents=True)
141
+ with zipfile.ZipFile(archive) as bundle:
142
+ bundle.extractall(staging)
143
+ top = [p for p in staging.iterdir() if p.is_dir()]
144
+ payload = top[0] if len(top) == 1 else staging
145
+ target = model_dir(model, version)
146
+ if target.exists():
147
+ shutil.rmtree(target)
148
+ target.parent.mkdir(parents=True, exist_ok=True)
149
+ shutil.move(str(payload), str(target))
150
+ shutil.rmtree(staging, ignore_errors=True)
151
+ write_latest(model, version)
152
+ return target
153
+
154
+
155
+ def remove_version(model: str, version: str, force: bool = False) -> bool:
156
+ path = model_dir(model, version)
157
+ if not path.is_dir():
158
+ return False
159
+ if read_latest(model) == version and not force:
160
+ raise SystemExit(
161
+ f"refusing to remove {model}@{version}: 'latest' points at it (use --force)"
162
+ )
163
+ shutil.rmtree(path)
164
+ return True
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "enano"
7
+ version = "0.2.0"
8
+ description = "Elevation models for personal goals and pitch decks: pull versioned bundles, scan documents deterministically, entirely on your machine."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ keywords = ["goals", "habits", "pitch-decks", "deterministic", "cli"]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Intended Audience :: End Users/Desktop",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ semantic = ["onnxruntime"]
22
+
23
+ [project.scripts]
24
+ enano = "enano.cli:main"
25
+ # Legacy alias so existing muscle memory keeps working.
26
+ etiny = "enano.cli:main"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["enano"]