vylth-annotator 0.0.1__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.
- vylth_annotator-0.0.1/.gitignore +10 -0
- vylth_annotator-0.0.1/LICENSE +21 -0
- vylth_annotator-0.0.1/PKG-INFO +122 -0
- vylth_annotator-0.0.1/README.md +85 -0
- vylth_annotator-0.0.1/pyproject.toml +64 -0
- vylth_annotator-0.0.1/src/vylth_annotator/.env.example +9 -0
- vylth_annotator-0.0.1/src/vylth_annotator/__init__.py +0 -0
- vylth_annotator-0.0.1/src/vylth_annotator/auth.py +56 -0
- vylth_annotator-0.0.1/src/vylth_annotator/cli.py +252 -0
- vylth_annotator-0.0.1/src/vylth_annotator/config.py +38 -0
- vylth_annotator-0.0.1/src/vylth_annotator/db.py +31 -0
- vylth_annotator-0.0.1/src/vylth_annotator/fanout.py +78 -0
- vylth_annotator-0.0.1/src/vylth_annotator/main.py +233 -0
- vylth_annotator-0.0.1/src/vylth_annotator/migrations/0001_initial.sql +38 -0
- vylth_annotator-0.0.1/src/vylth_annotator/models.py +79 -0
- vylth_annotator-0.0.1/src/vylth_annotator/schemas.py +61 -0
- vylth_annotator-0.0.1/src/vylth_annotator/sink.py +161 -0
- vylth_annotator-0.0.1/src/vylth_annotator/skill/SKILL.md +98 -0
- vylth_annotator-0.0.1/src/vylth_annotator/static/w.js +125 -0
- vylth_annotator-0.0.1/src/vylth_annotator/templates/dashboard.html +175 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vylth Labs
|
|
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,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vylth-annotator
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Drop-in design-feedback widget. Click → page freezes → drag a rect → engineer pulls the diagnostic envelope. Self-hostable on localhost.
|
|
5
|
+
Project-URL: Homepage, https://github.com/VYLTH/annotator
|
|
6
|
+
Project-URL: Repository, https://github.com/VYLTH/annotator
|
|
7
|
+
Project-URL: Issues, https://github.com/VYLTH/annotator/issues
|
|
8
|
+
Author-email: Vylth Labs <labs@vylth.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: annotation,developer-tools,diagnostics,feedback,screenshot,widget
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: aiosqlite>=0.20
|
|
23
|
+
Requires-Dist: click>=8.1
|
|
24
|
+
Requires-Dist: fastapi>=0.110
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
27
|
+
Requires-Dist: pydantic>=2.6
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
29
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: postgres
|
|
34
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
35
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# annotator
|
|
39
|
+
|
|
40
|
+
**Drop-in design-feedback widget for any web app.**
|
|
41
|
+
|
|
42
|
+
Click the bubble → page freezes → drag a rectangle → type one sentence → an annotation drops onto the engineer's filesystem with the screenshot, the URL, the clicked element selector, console logs, network errors, and JS exceptions captured at the moment of the click.
|
|
43
|
+
|
|
44
|
+
Self-hostable on localhost. Zero infrastructure.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install vylth-annotator # or: npm i -g vylth-annotator
|
|
48
|
+
annotator run # opens dashboard at http://localhost:8092
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Drop this into your dev site:
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<script src="http://localhost:8092/w.js"
|
|
55
|
+
data-project="local"
|
|
56
|
+
data-token="local"
|
|
57
|
+
data-webhook="http://localhost:8092/v1/feedback"></script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Done. Submitted annotations appear as a `.png` + `.md` pair under `./.annot/local/` and on the dashboard. Run `annotator skill install` once and Claude Code / Codex / Cursor will pick them up automatically.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
[bubble click]
|
|
66
|
+
↓
|
|
67
|
+
1. dom-to-image → static PNG of the viewport (≈80ms) ← page freezes
|
|
68
|
+
2. PNG becomes the backdrop, you draw rects on a <canvas>
|
|
69
|
+
3. Comment + Send → POST diagnostic envelope to the API
|
|
70
|
+
↓
|
|
71
|
+
4. Server writes:
|
|
72
|
+
- SQLite row (queryable)
|
|
73
|
+
- .annot/<project>/<id8>-<slug>.png (screenshot with rects baked in)
|
|
74
|
+
- .annot/<project>/<id8>-<slug>.md (frontmatter + readable envelope)
|
|
75
|
+
- dashboard updates live
|
|
76
|
+
- optional: webhook fanout (Slack / Discord / Linear / custom HTTP)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The diagnostic envelope is collected automatically — the user only types the comment. `console.log/warn/error`, `fetch`, `XHR`, `window.onerror`, and `unhandledrejection` are tapped the moment `w.js` loads, ring-buffered, dumped at submit time.
|
|
80
|
+
|
|
81
|
+
## Three install modes for the widget
|
|
82
|
+
|
|
83
|
+
| Mode | When to use |
|
|
84
|
+
|------|------------|
|
|
85
|
+
| `<script>` tag | Any web app you control. One line, ≤30KB, vanilla, Shadow DOM isolated. |
|
|
86
|
+
| Browser extension | Annotate any third-party site (competitor analysis, design refs). |
|
|
87
|
+
| `@vylth/annotator-react` | React app that wants the widget visible only in dev/staging. |
|
|
88
|
+
|
|
89
|
+
Mode 1 is shipped. Mode 2 + 3 are scaffolded — see `/mnt/vylth/labs/directives/annotator/DIR-0073-productize-annotator.md`.
|
|
90
|
+
|
|
91
|
+
## Layout
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
src/vylth_annotator/ — pip-installable Python service
|
|
95
|
+
cli.py — `annotator run | list | pull | resolve | init | skill`
|
|
96
|
+
main.py — FastAPI app
|
|
97
|
+
models.py — SQLAlchemy (SQLite locally, Postgres for hosted)
|
|
98
|
+
sink.py — disk writer (PNG + Markdown per annotation)
|
|
99
|
+
fanout.py — Slack / Discord / Linear / HTTP webhook dispatch
|
|
100
|
+
templates/dashboard.html
|
|
101
|
+
static/w.js — built widget bundle
|
|
102
|
+
skill/SKILL.md — agent instructions
|
|
103
|
+
|
|
104
|
+
packages/
|
|
105
|
+
widget/ — TypeScript widget source (Vite single-file)
|
|
106
|
+
cli/ — npm shim that forwards to the Python CLI
|
|
107
|
+
|
|
108
|
+
pyproject.toml — vylth-annotator on PyPI
|
|
109
|
+
LICENSE — MIT
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Agent integration
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
annotator skill install --target auto
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Drops `SKILL.md` into the right place for the agents present in your project — `.claude/skills/annotator/`, `AGENTS.md`, `.cursor/rules/`. Now the agent knows how to find new `.md` files in `.annot/`, read the diagnostic envelope, fix the underlying issue, and mark the annotation resolved.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT. Built by [Vylth Labs](https://vylth.com).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# annotator
|
|
2
|
+
|
|
3
|
+
**Drop-in design-feedback widget for any web app.**
|
|
4
|
+
|
|
5
|
+
Click the bubble → page freezes → drag a rectangle → type one sentence → an annotation drops onto the engineer's filesystem with the screenshot, the URL, the clicked element selector, console logs, network errors, and JS exceptions captured at the moment of the click.
|
|
6
|
+
|
|
7
|
+
Self-hostable on localhost. Zero infrastructure.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install vylth-annotator # or: npm i -g vylth-annotator
|
|
11
|
+
annotator run # opens dashboard at http://localhost:8092
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Drop this into your dev site:
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<script src="http://localhost:8092/w.js"
|
|
18
|
+
data-project="local"
|
|
19
|
+
data-token="local"
|
|
20
|
+
data-webhook="http://localhost:8092/v1/feedback"></script>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Done. Submitted annotations appear as a `.png` + `.md` pair under `./.annot/local/` and on the dashboard. Run `annotator skill install` once and Claude Code / Codex / Cursor will pick them up automatically.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
[bubble click]
|
|
29
|
+
↓
|
|
30
|
+
1. dom-to-image → static PNG of the viewport (≈80ms) ← page freezes
|
|
31
|
+
2. PNG becomes the backdrop, you draw rects on a <canvas>
|
|
32
|
+
3. Comment + Send → POST diagnostic envelope to the API
|
|
33
|
+
↓
|
|
34
|
+
4. Server writes:
|
|
35
|
+
- SQLite row (queryable)
|
|
36
|
+
- .annot/<project>/<id8>-<slug>.png (screenshot with rects baked in)
|
|
37
|
+
- .annot/<project>/<id8>-<slug>.md (frontmatter + readable envelope)
|
|
38
|
+
- dashboard updates live
|
|
39
|
+
- optional: webhook fanout (Slack / Discord / Linear / custom HTTP)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The diagnostic envelope is collected automatically — the user only types the comment. `console.log/warn/error`, `fetch`, `XHR`, `window.onerror`, and `unhandledrejection` are tapped the moment `w.js` loads, ring-buffered, dumped at submit time.
|
|
43
|
+
|
|
44
|
+
## Three install modes for the widget
|
|
45
|
+
|
|
46
|
+
| Mode | When to use |
|
|
47
|
+
|------|------------|
|
|
48
|
+
| `<script>` tag | Any web app you control. One line, ≤30KB, vanilla, Shadow DOM isolated. |
|
|
49
|
+
| Browser extension | Annotate any third-party site (competitor analysis, design refs). |
|
|
50
|
+
| `@vylth/annotator-react` | React app that wants the widget visible only in dev/staging. |
|
|
51
|
+
|
|
52
|
+
Mode 1 is shipped. Mode 2 + 3 are scaffolded — see `/mnt/vylth/labs/directives/annotator/DIR-0073-productize-annotator.md`.
|
|
53
|
+
|
|
54
|
+
## Layout
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
src/vylth_annotator/ — pip-installable Python service
|
|
58
|
+
cli.py — `annotator run | list | pull | resolve | init | skill`
|
|
59
|
+
main.py — FastAPI app
|
|
60
|
+
models.py — SQLAlchemy (SQLite locally, Postgres for hosted)
|
|
61
|
+
sink.py — disk writer (PNG + Markdown per annotation)
|
|
62
|
+
fanout.py — Slack / Discord / Linear / HTTP webhook dispatch
|
|
63
|
+
templates/dashboard.html
|
|
64
|
+
static/w.js — built widget bundle
|
|
65
|
+
skill/SKILL.md — agent instructions
|
|
66
|
+
|
|
67
|
+
packages/
|
|
68
|
+
widget/ — TypeScript widget source (Vite single-file)
|
|
69
|
+
cli/ — npm shim that forwards to the Python CLI
|
|
70
|
+
|
|
71
|
+
pyproject.toml — vylth-annotator on PyPI
|
|
72
|
+
LICENSE — MIT
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Agent integration
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
annotator skill install --target auto
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Drops `SKILL.md` into the right place for the agents present in your project — `.claude/skills/annotator/`, `AGENTS.md`, `.cursor/rules/`. Now the agent knows how to find new `.md` files in `.annot/`, read the diagnostic envelope, fix the underlying issue, and mark the annotation resolved.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT. Built by [Vylth Labs](https://vylth.com).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vylth-annotator"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Drop-in design-feedback widget. Click → page freezes → drag a rect → engineer pulls the diagnostic envelope. Self-hostable on localhost."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Vylth Labs", email = "labs@vylth.com" }]
|
|
13
|
+
keywords = ["annotation", "feedback", "screenshot", "widget", "diagnostics", "developer-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Web Environment",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
dependencies = [
|
|
27
|
+
"fastapi>=0.110",
|
|
28
|
+
"uvicorn[standard]>=0.27",
|
|
29
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
30
|
+
"aiosqlite>=0.20",
|
|
31
|
+
"pydantic>=2.6",
|
|
32
|
+
"pydantic-settings>=2.2",
|
|
33
|
+
"httpx>=0.27",
|
|
34
|
+
"click>=8.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
postgres = ["asyncpg>=0.29", "psycopg[binary]>=3.1"]
|
|
39
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/VYLTH/annotator"
|
|
43
|
+
Repository = "https://github.com/VYLTH/annotator"
|
|
44
|
+
Issues = "https://github.com/VYLTH/annotator/issues"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
annotator = "vylth_annotator.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/vylth_annotator"]
|
|
51
|
+
artifacts = [
|
|
52
|
+
"src/vylth_annotator/static/*",
|
|
53
|
+
"src/vylth_annotator/templates/*",
|
|
54
|
+
"src/vylth_annotator/migrations/*",
|
|
55
|
+
"src/vylth_annotator/skill/*",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = [
|
|
60
|
+
"/src",
|
|
61
|
+
"/README.md",
|
|
62
|
+
"/LICENSE",
|
|
63
|
+
"/pyproject.toml",
|
|
64
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token auth: X-Annot-Token header → project_id.
|
|
3
|
+
|
|
4
|
+
Tokens are stored hashed (SHA-256). The widget supplies the raw token; we hash
|
|
5
|
+
+ compare. No JWTs, no rotation logic in v0 — single shared token per project.
|
|
6
|
+
|
|
7
|
+
Local mode (CLI): a single project + token is auto-provisioned on first request,
|
|
8
|
+
so the user can `annotator run` and immediately point a widget at localhost
|
|
9
|
+
without any setup.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, Header, HTTPException, status
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from .config import settings
|
|
18
|
+
from .db import get_session
|
|
19
|
+
from .models import Project
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_token(raw: str) -> str:
|
|
23
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _ensure_local_project(session: AsyncSession) -> Project:
|
|
27
|
+
result = await session.execute(select(Project).where(Project.id == settings.local_project))
|
|
28
|
+
proj = result.scalar_one_or_none()
|
|
29
|
+
if proj is None:
|
|
30
|
+
proj = Project(
|
|
31
|
+
id=settings.local_project,
|
|
32
|
+
name="Local",
|
|
33
|
+
token_hash=hash_token(settings.local_token),
|
|
34
|
+
destinations=[],
|
|
35
|
+
)
|
|
36
|
+
session.add(proj)
|
|
37
|
+
await session.commit()
|
|
38
|
+
await session.refresh(proj)
|
|
39
|
+
return proj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def project_from_token(
|
|
43
|
+
x_annot_token: str | None = Header(None, alias="X-Annot-Token"),
|
|
44
|
+
session: AsyncSession = Depends(get_session),
|
|
45
|
+
) -> Project:
|
|
46
|
+
if settings.local_mode:
|
|
47
|
+
return await _ensure_local_project(session)
|
|
48
|
+
|
|
49
|
+
if not x_annot_token:
|
|
50
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing X-Annot-Token")
|
|
51
|
+
token_hash = hash_token(x_annot_token)
|
|
52
|
+
result = await session.execute(select(Project).where(Project.token_hash == token_hash))
|
|
53
|
+
project = result.scalar_one_or_none()
|
|
54
|
+
if project is None:
|
|
55
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
|
|
56
|
+
return project
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`annotator` CLI entry point.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
run Start the API + dashboard locally (SQLite, zero config).
|
|
6
|
+
list Show open feedback (your project, scoped by token).
|
|
7
|
+
pull Download feedback envelopes + images to ./.annot/<project>/.
|
|
8
|
+
resolve Mark a feedback item resolved.
|
|
9
|
+
init Print the <script> snippet to embed into your dev site.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import webbrowser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .config import settings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _set_local_mode() -> None:
|
|
27
|
+
"""Force local mode for this process (the CLI default)."""
|
|
28
|
+
os.environ.setdefault("ANNOTATOR_LOCAL_MODE", "true")
|
|
29
|
+
settings.local_mode = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _api_base(host: str | None = None, port: int | None = None) -> str:
|
|
33
|
+
h = host or settings.host or "127.0.0.1"
|
|
34
|
+
p = port or settings.port
|
|
35
|
+
return f"http://{h}:{p}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(help="Annotator — drop-in design-feedback widget. Self-hostable on localhost.")
|
|
39
|
+
@click.version_option(package_name="vylth-annotator")
|
|
40
|
+
def cli() -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli.command(help="Run the annotator API + dashboard locally on http://localhost:8092.")
|
|
45
|
+
@click.option("--host", default=None, help="Bind host (default 127.0.0.1).")
|
|
46
|
+
@click.option("--port", default=None, type=int, help="Bind port (default 8092).")
|
|
47
|
+
@click.option("--open/--no-open", "open_browser", default=True, help="Open dashboard in browser.")
|
|
48
|
+
@click.option("--db", default=None, help="Override SQLite path or sqlalchemy URL.")
|
|
49
|
+
@click.option("--sink", "sink_dir", default=".annot",
|
|
50
|
+
help="Write PNG + Markdown per annotation under this dir (default ./.annot). Pass '' to disable.")
|
|
51
|
+
def run(host: Optional[str], port: Optional[int], open_browser: bool, db: Optional[str], sink_dir: str) -> None:
|
|
52
|
+
import uvicorn
|
|
53
|
+
|
|
54
|
+
_set_local_mode()
|
|
55
|
+
if db:
|
|
56
|
+
os.environ["ANNOTATOR_DATABASE_URL"] = (
|
|
57
|
+
db if "://" in db else f"sqlite+aiosqlite:///{Path(db).resolve()}"
|
|
58
|
+
)
|
|
59
|
+
if host:
|
|
60
|
+
os.environ["ANNOTATOR_HOST"] = host
|
|
61
|
+
if port:
|
|
62
|
+
os.environ["ANNOTATOR_PORT"] = str(port)
|
|
63
|
+
|
|
64
|
+
abs_sink = str(Path(sink_dir).resolve()) if sink_dir else ""
|
|
65
|
+
os.environ["ANNOTATOR_SINK_DIR"] = abs_sink
|
|
66
|
+
|
|
67
|
+
# Re-import settings so env overrides take effect.
|
|
68
|
+
from .config import settings as fresh
|
|
69
|
+
fresh.local_mode = True
|
|
70
|
+
fresh.sink_dir = abs_sink
|
|
71
|
+
|
|
72
|
+
base = _api_base(host=host or fresh.host, port=port or fresh.port)
|
|
73
|
+
click.echo(f"\n annotator → {base}")
|
|
74
|
+
if abs_sink:
|
|
75
|
+
click.echo(f" sink → {abs_sink}/<project>/ (png + md per annotation)")
|
|
76
|
+
click.echo(f"\n embed this in your dev site:")
|
|
77
|
+
click.echo(f" <script src=\"{base}/w.js\" data-project=\"local\" data-token=\"local\" data-webhook=\"{base}/v1/feedback\"></script>\n")
|
|
78
|
+
|
|
79
|
+
if open_browser:
|
|
80
|
+
try:
|
|
81
|
+
webbrowser.open(base)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
uvicorn.run(
|
|
86
|
+
"vylth_annotator.main:app",
|
|
87
|
+
host=host or fresh.host,
|
|
88
|
+
port=port or fresh.port,
|
|
89
|
+
log_level="info",
|
|
90
|
+
reload=False,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@cli.command("list", help="List open feedback for your token's project.")
|
|
95
|
+
@click.option("--api", default=None, help=f"API base URL (default {_api_base()}).")
|
|
96
|
+
@click.option("--token", default=None, help="Token (default $ANNOTATOR_TOKEN or 'local').")
|
|
97
|
+
@click.option("--status", "status_filter", default="open", help="Filter by status.")
|
|
98
|
+
@click.option("--limit", default=20, type=int)
|
|
99
|
+
def list_cmd(api: Optional[str], token: Optional[str], status_filter: str, limit: int) -> None:
|
|
100
|
+
base = api or _api_base()
|
|
101
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
102
|
+
r = httpx.get(f"{base}/v1/feedback", params={"status": status_filter, "limit": limit}, headers={"X-Annot-Token": tok}, timeout=10)
|
|
103
|
+
if r.status_code != 200:
|
|
104
|
+
click.echo(f"error: {r.status_code} {r.text}", err=True)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
items = r.json().get("items", [])
|
|
107
|
+
if not items:
|
|
108
|
+
click.echo("no open feedback")
|
|
109
|
+
return
|
|
110
|
+
for fb in items:
|
|
111
|
+
click.echo(f" {fb['id'][:8]} {fb['pathname']:<32} {fb['comment'][:80]}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command(help="Download feedback (envelope JSON + image) to ./.annot/<project>/.")
|
|
115
|
+
@click.option("--api", default=None)
|
|
116
|
+
@click.option("--token", default=None)
|
|
117
|
+
@click.option("--out", default=".annot", help="Output directory.")
|
|
118
|
+
def pull(api: Optional[str], token: Optional[str], out: str) -> None:
|
|
119
|
+
base = api or _api_base()
|
|
120
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
121
|
+
headers = {"X-Annot-Token": tok}
|
|
122
|
+
r = httpx.get(f"{base}/v1/feedback", params={"status": "open", "limit": 100}, headers=headers, timeout=10)
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
items = r.json().get("items", [])
|
|
125
|
+
|
|
126
|
+
if not items:
|
|
127
|
+
click.echo("no open feedback to pull")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
out_dir = Path(out)
|
|
131
|
+
for fb in items:
|
|
132
|
+
proj_dir = out_dir / fb["project_id"]
|
|
133
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
slug = fb["id"][:8]
|
|
135
|
+
env_path = proj_dir / f"{slug}.json"
|
|
136
|
+
img_path = proj_dir / f"{slug}.png"
|
|
137
|
+
env_path.write_text(json.dumps(fb, indent=2, default=str))
|
|
138
|
+
img = httpx.get(f"{base}/v1/feedback/{fb['id']}/image", headers=headers, timeout=15)
|
|
139
|
+
if img.status_code == 200:
|
|
140
|
+
img_path.write_bytes(img.content)
|
|
141
|
+
click.echo(f" pulled {slug} → {env_path}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@cli.command(help="Mark a feedback item resolved.")
|
|
145
|
+
@click.argument("fb_id")
|
|
146
|
+
@click.option("--api", default=None)
|
|
147
|
+
@click.option("--token", default=None)
|
|
148
|
+
def resolve(fb_id: str, api: Optional[str], token: Optional[str]) -> None:
|
|
149
|
+
base = api or _api_base()
|
|
150
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
151
|
+
r = httpx.post(f"{base}/v1/feedback/{fb_id}/resolve", headers={"X-Annot-Token": tok}, timeout=10)
|
|
152
|
+
if r.status_code != 200:
|
|
153
|
+
click.echo(f"error: {r.status_code} {r.text}", err=True)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
click.echo(f"resolved {fb_id}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@cli.command(help="Print the <script> snippet to embed in your dev site.")
|
|
159
|
+
@click.option("--host", default="localhost")
|
|
160
|
+
@click.option("--port", default=8092, type=int)
|
|
161
|
+
@click.option("--project", default="local")
|
|
162
|
+
@click.option("--token", default="local")
|
|
163
|
+
def init(host: str, port: int, project: str, token: str) -> None:
|
|
164
|
+
base = f"http://{host}:{port}"
|
|
165
|
+
snippet = (
|
|
166
|
+
f'<script src="{base}/w.js"\n'
|
|
167
|
+
f' data-project="{project}"\n'
|
|
168
|
+
f' data-token="{token}"\n'
|
|
169
|
+
f' data-webhook="{base}/v1/feedback"></script>'
|
|
170
|
+
)
|
|
171
|
+
click.echo(snippet)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@cli.group(help="Install the annotator skill so agents (Claude Code, Codex, Cursor, …) read .annot/ automatically.")
|
|
175
|
+
def skill() -> None:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _skill_text() -> str:
|
|
180
|
+
"""Read the bundled SKILL.md."""
|
|
181
|
+
from importlib.resources import files
|
|
182
|
+
return (files("vylth_annotator") / "skill" / "SKILL.md").read_text(encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@skill.command("install", help="Drop SKILL.md into the right place for the detected agent(s). Defaults to all that apply.")
|
|
186
|
+
@click.option("--target", type=click.Choice(["auto", "claude", "codex", "cursor", "all"]), default="auto",
|
|
187
|
+
help="Which agent flavor(s) to install for.")
|
|
188
|
+
@click.option("--cwd", default=".", help="Project root (default current dir).")
|
|
189
|
+
def skill_install(target: str, cwd: str) -> None:
|
|
190
|
+
root = Path(cwd).resolve()
|
|
191
|
+
text = _skill_text()
|
|
192
|
+
written: list[Path] = []
|
|
193
|
+
|
|
194
|
+
targets: list[str]
|
|
195
|
+
if target == "auto":
|
|
196
|
+
targets = []
|
|
197
|
+
if (root / ".claude").exists() or (root / "CLAUDE.md").exists(): targets.append("claude")
|
|
198
|
+
if (root / "AGENTS.md").exists() or any(root.glob("*.codex*")): targets.append("codex")
|
|
199
|
+
if (root / ".cursor").exists() or (root / ".cursorrules").exists(): targets.append("cursor")
|
|
200
|
+
if not targets:
|
|
201
|
+
targets = ["claude", "codex"] # safe defaults
|
|
202
|
+
elif target == "all":
|
|
203
|
+
targets = ["claude", "codex", "cursor"]
|
|
204
|
+
else:
|
|
205
|
+
targets = [target]
|
|
206
|
+
|
|
207
|
+
for t in targets:
|
|
208
|
+
if t == "claude":
|
|
209
|
+
dst = root / ".claude" / "skills" / "annotator" / "SKILL.md"
|
|
210
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
dst.write_text(text, encoding="utf-8")
|
|
212
|
+
written.append(dst)
|
|
213
|
+
elif t == "codex":
|
|
214
|
+
# AGENTS.md convention — append a section if the file already has unrelated content.
|
|
215
|
+
dst = root / "AGENTS.md"
|
|
216
|
+
section = "\n\n## annotator\n\n" + text
|
|
217
|
+
if dst.exists():
|
|
218
|
+
existing = dst.read_text(encoding="utf-8")
|
|
219
|
+
if "## annotator" in existing:
|
|
220
|
+
# rewrite the annotator section
|
|
221
|
+
head, _, _ = existing.partition("## annotator")
|
|
222
|
+
dst.write_text(head.rstrip() + section, encoding="utf-8")
|
|
223
|
+
else:
|
|
224
|
+
dst.write_text(existing.rstrip() + section, encoding="utf-8")
|
|
225
|
+
else:
|
|
226
|
+
dst.write_text("# AGENTS\n" + section, encoding="utf-8")
|
|
227
|
+
written.append(dst)
|
|
228
|
+
elif t == "cursor":
|
|
229
|
+
dst = root / ".cursor" / "rules" / "annotator.md"
|
|
230
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
dst.write_text(text, encoding="utf-8")
|
|
232
|
+
written.append(dst)
|
|
233
|
+
|
|
234
|
+
click.echo("Installed:")
|
|
235
|
+
for p in written:
|
|
236
|
+
click.echo(f" {p.relative_to(root)}")
|
|
237
|
+
click.echo()
|
|
238
|
+
click.echo("Now any time you submit an annotation, the agent will see new files appear in .annot/")
|
|
239
|
+
click.echo("and know how to read + resolve them. Run `annotator run` to start collecting.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@skill.command("show", help="Print the SKILL.md content to stdout (for piping into custom locations).")
|
|
243
|
+
def skill_show() -> None:
|
|
244
|
+
click.echo(_skill_text())
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> None: # entry point for pyproject.toml
|
|
248
|
+
cli()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _default_db_url() -> str:
|
|
7
|
+
"""SQLite under the user's data dir — zero-config local mode."""
|
|
8
|
+
home = Path.home() / ".vylth-annotator"
|
|
9
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
return f"sqlite+aiosqlite:///{home / 'annotator.db'}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="ANNOTATOR_")
|
|
15
|
+
|
|
16
|
+
database_url: str = _default_db_url()
|
|
17
|
+
host: str = "127.0.0.1"
|
|
18
|
+
port: int = 8092
|
|
19
|
+
allowed_origins: str = "*"
|
|
20
|
+
|
|
21
|
+
# Local mode: when true, accept any token (single-user laptop scenario).
|
|
22
|
+
# The CLI sets this. Hosted deployments leave it false.
|
|
23
|
+
local_mode: bool = False
|
|
24
|
+
local_token: str = "local"
|
|
25
|
+
local_project: str = "local"
|
|
26
|
+
|
|
27
|
+
# Filesystem sink: when set, every accepted feedback writes a PNG + .md
|
|
28
|
+
# pair under <sink_dir>/<project_id>/. Agents (Claude Code, Codex, Cursor)
|
|
29
|
+
# read these directly — no API access required.
|
|
30
|
+
sink_dir: str = ""
|
|
31
|
+
|
|
32
|
+
r2_account_id: str = ""
|
|
33
|
+
r2_access_key: str = ""
|
|
34
|
+
r2_secret_key: str = ""
|
|
35
|
+
r2_bucket: str = "annotator"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
settings = Settings()
|