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.
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ dist
3
+ .venv
4
+ __pycache__
5
+ *.pyc
6
+ .env
7
+ .env.local
8
+ .DS_Store
9
+ *.log
10
+ .annot/
@@ -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
+ ]
@@ -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
@@ -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()