vylth-annotator 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ DATABASE_URL=postgresql+asyncpg://annotator:annotator@localhost:5432/annotator
2
+ HOST=0.0.0.0
3
+ PORT=8092
4
+ ALLOWED_ORIGINS=*
5
+ # R2 (optional — used for images > 500KB)
6
+ R2_ACCOUNT_ID=
7
+ R2_ACCESS_KEY=
8
+ R2_SECRET_KEY=
9
+ R2_BUCKET=annotator
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
vylth_annotator/cli.py ADDED
@@ -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()
vylth_annotator/db.py ADDED
@@ -0,0 +1,31 @@
1
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
2
+ from sqlalchemy.orm import DeclarativeBase
3
+
4
+ from .config import settings
5
+
6
+
7
+ def _engine_kwargs(url: str) -> dict:
8
+ if url.startswith("sqlite"):
9
+ # SQLite doesn't use pool_pre_ping; future=True is the default in 2.x.
10
+ return {}
11
+ return {"pool_pre_ping": True}
12
+
13
+
14
+ engine = create_async_engine(settings.database_url, **_engine_kwargs(settings.database_url))
15
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
16
+
17
+
18
+ class Base(DeclarativeBase):
19
+ pass
20
+
21
+
22
+ async def get_session() -> AsyncSession:
23
+ async with SessionLocal() as session:
24
+ yield session
25
+
26
+
27
+ async def init_db() -> None:
28
+ """Create tables if missing — used by `annotator run` for zero-config local mode."""
29
+ from . import models # noqa: F401 ensure mappers are registered
30
+ async with engine.begin() as conn:
31
+ await conn.run_sync(Base.metadata.create_all)
@@ -0,0 +1,78 @@
1
+ """
2
+ Webhook fanout — fire envelope copies to each project's configured destinations.
3
+
4
+ Destinations are stored per-project in `annotator_projects.destinations` as
5
+ `[{type, url, format}, ...]`. Supported types: slack, discord, linear, http.
6
+ Failures are logged but don't fail the original POST.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ async def dispatch(destinations: list[dict[str, Any]], envelope: dict[str, Any]) -> None:
20
+ if not destinations:
21
+ return
22
+ async with httpx.AsyncClient(timeout=8.0) as client:
23
+ for dest in destinations:
24
+ kind = dest.get("type")
25
+ url = dest.get("url")
26
+ if not url:
27
+ continue
28
+ try:
29
+ if kind == "slack":
30
+ await _slack(client, url, envelope)
31
+ elif kind == "discord":
32
+ await _discord(client, url, envelope)
33
+ elif kind == "linear":
34
+ await _linear(client, url, envelope, dest)
35
+ else:
36
+ await client.post(url, json=envelope)
37
+ except Exception as e:
38
+ logger.warning("fanout failed: %s → %s: %s", kind, url, e)
39
+
40
+
41
+ def _summary(envelope: dict[str, Any]) -> str:
42
+ comment = envelope.get("comment", "")[:300]
43
+ href = envelope.get("url", {}).get("href", "")
44
+ rects = len(envelope.get("rects", []))
45
+ errors = len(envelope.get("errors", []))
46
+ net_errs = len(envelope.get("network", []))
47
+ return f"{comment}\n\n→ {href}\n{rects} rect(s) · {errors} JS errors · {net_errs} network errors"
48
+
49
+
50
+ async def _slack(client: httpx.AsyncClient, url: str, envelope: dict[str, Any]) -> None:
51
+ await client.post(url, json={"text": f"*New annotation*\n{_summary(envelope)}"})
52
+
53
+
54
+ async def _discord(client: httpx.AsyncClient, url: str, envelope: dict[str, Any]) -> None:
55
+ await client.post(url, json={"content": f"**New annotation**\n{_summary(envelope)}"})
56
+
57
+
58
+ async def _linear(client: httpx.AsyncClient, url: str, envelope: dict[str, Any], dest: dict[str, Any]) -> None:
59
+ api_key = dest.get("api_key")
60
+ team_id = dest.get("team_id")
61
+ if not (api_key and team_id):
62
+ return
63
+ title = (envelope.get("comment") or "annotation").splitlines()[0][:80]
64
+ body = (
65
+ f"{envelope.get('comment','')}\n\n"
66
+ f"**URL:** {envelope.get('url',{}).get('href','')}\n\n"
67
+ f"```json\n{json.dumps({k: envelope[k] for k in ('viewport','env','errors','network','perf') if k in envelope}, indent=2)}\n```"
68
+ )
69
+ query = """
70
+ mutation IssueCreate($input: IssueCreateInput!) {
71
+ issueCreate(input: $input) { success issue { id identifier } }
72
+ }
73
+ """
74
+ await client.post(
75
+ "https://api.linear.app/graphql",
76
+ headers={"Authorization": api_key, "Content-Type": "application/json"},
77
+ json={"query": query, "variables": {"input": {"title": title, "description": body, "teamId": team_id}}},
78
+ )