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 +7 -0
- enano-0.2.0/PKG-INFO +76 -0
- enano-0.2.0/README.md +61 -0
- enano-0.2.0/docs/screens/screen-1.jpg +0 -0
- enano-0.2.0/docs/screens/screen-1.png +0 -0
- enano-0.2.0/docs/screens/screen-2.jpg +0 -0
- enano-0.2.0/docs/screens/screen-2.png +0 -0
- enano-0.2.0/enano/__init__.py +6 -0
- enano-0.2.0/enano/__main__.py +4 -0
- enano-0.2.0/enano/cli.py +595 -0
- enano-0.2.0/enano/registry.py +112 -0
- enano-0.2.0/enano/store.py +164 -0
- enano-0.2.0/pyproject.toml +29 -0
enano-0.2.0/.gitignore
ADDED
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
|
enano-0.2.0/enano/cli.py
ADDED
|
@@ -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"]
|