heartofgold 0.1.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.
Files changed (76) hide show
  1. heartofgold-0.1.0/LICENSE +21 -0
  2. heartofgold-0.1.0/PKG-INFO +167 -0
  3. heartofgold-0.1.0/README.md +140 -0
  4. heartofgold-0.1.0/pyproject.toml +71 -0
  5. heartofgold-0.1.0/pyproject.toml.orig +60 -0
  6. heartofgold-0.1.0/src/heartofgold/__init__.py +3 -0
  7. heartofgold-0.1.0/src/heartofgold/__main__.py +3 -0
  8. heartofgold-0.1.0/src/heartofgold/api/__init__.py +0 -0
  9. heartofgold-0.1.0/src/heartofgold/api/errors.py +92 -0
  10. heartofgold-0.1.0/src/heartofgold/api/files.py +85 -0
  11. heartofgold-0.1.0/src/heartofgold/api/headers.py +44 -0
  12. heartofgold-0.1.0/src/heartofgold/api/limits.py +53 -0
  13. heartofgold-0.1.0/src/heartofgold/api/routes.py +443 -0
  14. heartofgold-0.1.0/src/heartofgold/app.py +106 -0
  15. heartofgold-0.1.0/src/heartofgold/auth/__init__.py +0 -0
  16. heartofgold-0.1.0/src/heartofgold/auth/deps.py +66 -0
  17. heartofgold-0.1.0/src/heartofgold/auth/oauth.py +273 -0
  18. heartofgold-0.1.0/src/heartofgold/auth/ratelimit.py +31 -0
  19. heartofgold-0.1.0/src/heartofgold/auth/sessions.py +101 -0
  20. heartofgold-0.1.0/src/heartofgold/backup.py +48 -0
  21. heartofgold-0.1.0/src/heartofgold/cli.py +225 -0
  22. heartofgold-0.1.0/src/heartofgold/config.py +171 -0
  23. heartofgold-0.1.0/src/heartofgold/db.py +114 -0
  24. heartofgold-0.1.0/src/heartofgold/log.py +77 -0
  25. heartofgold-0.1.0/src/heartofgold/mcp_render.py +152 -0
  26. heartofgold-0.1.0/src/heartofgold/mcp_server.py +869 -0
  27. heartofgold-0.1.0/src/heartofgold/migrations/0001_initial.sql +162 -0
  28. heartofgold-0.1.0/src/heartofgold/migrations/0002_oauth.sql +37 -0
  29. heartofgold-0.1.0/src/heartofgold/migrations/__init__.py +0 -0
  30. heartofgold-0.1.0/src/heartofgold/models.py +220 -0
  31. heartofgold-0.1.0/src/heartofgold/py.typed +0 -0
  32. heartofgold-0.1.0/src/heartofgold/schemas.py +235 -0
  33. heartofgold-0.1.0/src/heartofgold/services/__init__.py +0 -0
  34. heartofgold-0.1.0/src/heartofgold/services/actors.py +343 -0
  35. heartofgold-0.1.0/src/heartofgold/services/attachments.py +363 -0
  36. heartofgold-0.1.0/src/heartofgold/services/claims.py +132 -0
  37. heartofgold-0.1.0/src/heartofgold/services/comments.py +391 -0
  38. heartofgold-0.1.0/src/heartofgold/services/common.py +204 -0
  39. heartofgold-0.1.0/src/heartofgold/services/dependencies.py +183 -0
  40. heartofgold-0.1.0/src/heartofgold/services/events.py +185 -0
  41. heartofgold-0.1.0/src/heartofgold/services/history.py +196 -0
  42. heartofgold-0.1.0/src/heartofgold/services/inbox.py +91 -0
  43. heartofgold-0.1.0/src/heartofgold/services/markdown.py +128 -0
  44. heartofgold-0.1.0/src/heartofgold/services/oauth.py +356 -0
  45. heartofgold-0.1.0/src/heartofgold/services/ordering.py +26 -0
  46. heartofgold-0.1.0/src/heartofgold/services/projects.py +443 -0
  47. heartofgold-0.1.0/src/heartofgold/services/search.py +102 -0
  48. heartofgold-0.1.0/src/heartofgold/services/tasks.py +697 -0
  49. heartofgold-0.1.0/src/heartofgold/services/waiting.py +152 -0
  50. heartofgold-0.1.0/src/heartofgold/web/__init__.py +0 -0
  51. heartofgold-0.1.0/src/heartofgold/web/routes.py +851 -0
  52. heartofgold-0.1.0/src/heartofgold/web/static/app.css +76 -0
  53. heartofgold-0.1.0/src/heartofgold/web/static/app.js +137 -0
  54. heartofgold-0.1.0/src/heartofgold/web/static/vendor/README.md +7 -0
  55. heartofgold-0.1.0/src/heartofgold/web/static/vendor/SHA256SUMS +3 -0
  56. heartofgold-0.1.0/src/heartofgold/web/static/vendor/Sortable.min.js +2 -0
  57. heartofgold-0.1.0/src/heartofgold/web/static/vendor/htmx.min.js +1 -0
  58. heartofgold-0.1.0/src/heartofgold/web/static/vendor/mermaid.min.js +2314 -0
  59. heartofgold-0.1.0/src/heartofgold/web/templates/_board.html +41 -0
  60. heartofgold-0.1.0/src/heartofgold/web/templates/_task_body.html +104 -0
  61. heartofgold-0.1.0/src/heartofgold/web/templates/_task_live.html +50 -0
  62. heartofgold-0.1.0/src/heartofgold/web/templates/_task_panel.html +4 -0
  63. heartofgold-0.1.0/src/heartofgold/web/templates/admin.html +79 -0
  64. heartofgold-0.1.0/src/heartofgold/web/templates/base.html +33 -0
  65. heartofgold-0.1.0/src/heartofgold/web/templates/board.html +29 -0
  66. heartofgold-0.1.0/src/heartofgold/web/templates/consent.html +22 -0
  67. heartofgold-0.1.0/src/heartofgold/web/templates/error.html +9 -0
  68. heartofgold-0.1.0/src/heartofgold/web/templates/history.html +12 -0
  69. heartofgold-0.1.0/src/heartofgold/web/templates/login.html +12 -0
  70. heartofgold-0.1.0/src/heartofgold/web/templates/macros.html +21 -0
  71. heartofgold-0.1.0/src/heartofgold/web/templates/plan.html +22 -0
  72. heartofgold-0.1.0/src/heartofgold/web/templates/projects.html +29 -0
  73. heartofgold-0.1.0/src/heartofgold/web/templates/revision.html +18 -0
  74. heartofgold-0.1.0/src/heartofgold/web/templates/settings.html +46 -0
  75. heartofgold-0.1.0/src/heartofgold/web/templates/task.html +3 -0
  76. heartofgold-0.1.0/src/heartofgold/web/templates/waiting.html +35 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Takacs
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,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: heartofgold
3
+ Version: 0.1.0
4
+ Summary: A small self-hosted kanban board shared by humans and AI agents, with REST and MCP access.
5
+ Author: Daniel Takacs
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: uvicorn>=0.30
10
+ Requires-Dist: pydantic>=2.7
11
+ Requires-Dist: pydantic-settings>=2.4
12
+ Requires-Dist: platformdirs>=4.2
13
+ Requires-Dist: typer>=0.12
14
+ Requires-Dist: sqlalchemy>=2.0
15
+ Requires-Dist: markdown-it-py>=3
16
+ Requires-Dist: mdit-py-plugins>=0.4
17
+ Requires-Dist: linkify-it-py>=2
18
+ Requires-Dist: nh3>=0.2.17
19
+ Requires-Dist: pillow>=10
20
+ Requires-Dist: argon2-cffi>=23
21
+ Requires-Dist: python-multipart>=0.0.9
22
+ Requires-Dist: mcp>=2.3,<3
23
+ Requires-Dist: jinja2>=3.1
24
+ Requires-Dist: itsdangerous>=2.2
25
+ Requires-Python: >=3.12
26
+ Description-Content-Type: text/markdown
27
+
28
+ # HeartOfGold
29
+
30
+ A small, self-hosted kanban board shared by you and your AI agents. You use the web UI; agents
31
+ (local Claude Code, Claude Code cloud sessions, claude.ai chat, Desktop, mobile and Cowork) use a
32
+ REST API and a remote MCP server. They hand work to each other, ask you questions, and you approve
33
+ and sign off. One Python package, one SQLite file, no other services.
34
+
35
+ - Projects with a Markdown plan and ordered columns; Draft → Ready → In Progress → Review → Done by
36
+ default, where Ready and Done are human-gated and Review/Done need a written resolution.
37
+ - Tasks with Markdown descriptions, comments with @mentions, dependencies, attachments (images,
38
+ PDFs, HTML reports, text), Mermaid diagrams and full revision history with one-click restore.
39
+ - Every agent has its own identity and inbox; parallel sessions claim tasks so they never collide.
40
+ - Reachable from anywhere at zero cost through Tailscale Funnel on the free plan.
41
+
42
+ Docs: [deployment](https://github.com/takacsd/HeartOfGold/blob/main/docs/deployment.md) ·
43
+ [client setup](https://github.com/takacsd/HeartOfGold/blob/main/docs/client-setup.md) ·
44
+ [agent conventions](https://github.com/takacsd/HeartOfGold/blob/main/docs/agent-conventions.md) ·
45
+ [spec](https://github.com/takacsd/HeartOfGold/blob/main/docs/spec.md)
46
+
47
+ ## Quick start (about 10 minutes)
48
+
49
+ You need a machine that stays on (Linux, macOS or a Raspberry Pi with a 64-bit OS) and a free
50
+ [Tailscale](https://tailscale.com) account.
51
+
52
+ **1. Install uv and HeartOfGold.** uv also fetches Python 3.12 if your system has none.
53
+
54
+ ```bash
55
+ curl -LsSf https://astral.sh/uv/install.sh | sh # see https://docs.astral.sh/uv/ for other ways
56
+ uv tool install heartofgold # or, before the first PyPI release:
57
+ # uv tool install git+https://github.com/takacsd/HeartOfGold
58
+ uv tool update-shell # puts ~/.local/bin on PATH; open a new shell
59
+ ```
60
+
61
+ (`pipx install heartofgold` works too if you prefer pipx; it needs Python 3.12 or newer.)
62
+
63
+ **2. Create your account and an agent token.**
64
+
65
+ ```bash
66
+ heartofgold init # data dir, secret, your owner account
67
+ heartofgold create-agent laptop
68
+ heartofgold issue-token laptop --name "laptop claude code" # prints hog_… once: keep it
69
+ ```
70
+
71
+ **3. Put it on the internet with Tailscale Funnel.** Install Tailscale 1.52 or newer from
72
+ [tailscale.com/download](https://tailscale.com/download) (distribution packages are often older;
73
+ on macOS use the open-source `tailscaled` variant; keep MagicDNS on), log in, then:
74
+
75
+ ```bash
76
+ sudo tailscale funnel --bg 8000 # approve the prompt that enables HTTPS and Funnel
77
+ tailscale funnel status # shows your URL, e.g. https://heartofgold.tail1234.ts.net
78
+ ```
79
+
80
+ The public DNS name can take up to 10 minutes to appear the first time.
81
+
82
+ **4. Tell HeartOfGold its public URL and run it as a service.** The board only accepts MCP requests
83
+ for the host in `HEARTOFGOLD_BASE_URL`, so this step is required.
84
+
85
+ ```bash
86
+ mkdir -p ~/.config/heartofgold
87
+ echo "HEARTOFGOLD_BASE_URL=https://heartofgold.tail1234.ts.net" > ~/.config/heartofgold/env
88
+
89
+ # Linux (systemd user service)
90
+ curl -fsSLo ~/.config/systemd/user/heartofgold.service --create-dirs \
91
+ https://raw.githubusercontent.com/takacsd/HeartOfGold/main/examples/heartofgold.service
92
+ systemctl --user daemon-reload && systemctl --user enable --now heartofgold
93
+ loginctl enable-linger "$USER" # keep it running when you are logged out
94
+ ```
95
+
96
+ On macOS use [`examples/com.heartofgold.plist`](https://github.com/takacsd/HeartOfGold/blob/main/examples/com.heartofgold.plist)
97
+ with `launchctl`. On Windows, run `heartofgold serve` in a terminal or from Task Scheduler (not
98
+ tested). To just try it: `uvx heartofgold serve` (with the same environment variable exported).
99
+
100
+ **5. Log in** at your `https://…ts.net` URL (not `http://127.0.0.1`: with an https base URL the
101
+ session cookie is HTTPS-only). Then connect your agents: see
102
+ [client setup](https://github.com/takacsd/HeartOfGold/blob/main/docs/client-setup.md). For Claude Code
103
+ on this machine:
104
+
105
+ ```bash
106
+ export HEARTOFGOLD_TOKEN=hog_… # also add this line to your shell profile
107
+ claude mcp add --transport http --scope user heartofgold https://heartofgold.tail1234.ts.net/mcp \
108
+ --header "Authorization: Bearer ${HEARTOFGOLD_TOKEN}"
109
+ claude mcp get heartofgold # check the header is not just "Bearer "
110
+ ```
111
+
112
+ Paste [`docs/agent-conventions.md`](https://github.com/takacsd/HeartOfGold/blob/main/docs/agent-conventions.md)
113
+ into your `CLAUDE.md`, open Claude Code and ask "what's in my HeartOfGold inbox?".
114
+
115
+ ## Everyday operations
116
+
117
+ - **CLI** (runs on the server, acts as admin): `init`, `serve`, `create-agent <handle> [--admin]`,
118
+ `issue-token <handle> --name <name>`, `revoke-token <prefix>`, `set-role <handle> admin|member`,
119
+ `reset-password <handle>`, `backup [--to PATH]`. If your env file sets anything besides the base
120
+ URL (such as `HEARTOFGOLD_DATA_DIR`), load it first: `set -a; . ~/.config/heartofgold/env; set +a`.
121
+ - **Undoing mistakes**: every text change has a History with diff and Restore; deleted comments and
122
+ attachments come back from Admin. Backups are for disasters.
123
+ - **Backups**: `heartofgold backup` writes a consistent snapshot, new attachment files and the
124
+ secret key to `<data dir>/backups` (or `--to` another disk) and keeps 14 snapshots. Schedule it
125
+ with [the backup timer](https://github.com/takacsd/HeartOfGold/blob/main/examples/heartofgold-backup.timer)
126
+ or cron. Restoring is described in
127
+ [deployment](https://github.com/takacsd/HeartOfGold/blob/main/docs/deployment.md#restore).
128
+ - **Upgrades**: `uv tool upgrade heartofgold` (or `pipx upgrade heartofgold`), then
129
+ `systemctl --user restart heartofgold`. Database migrations run at startup.
130
+
131
+ ## Configuration
132
+
133
+ All settings are environment variables; all have defaults. Invalid values stop startup with a
134
+ message naming the variable.
135
+
136
+ | Variable | Default | Purpose |
137
+ | --- | --- | --- |
138
+ | `HEARTOFGOLD_BASE_URL` | `http://127.0.0.1:<port>` | Public URL without a path, e.g. `https://heartofgold.tail1234.ts.net` (may include `:8443`) |
139
+ | `HEARTOFGOLD_PORT` | `8000` | Listen port (the server binds to 127.0.0.1; `serve --host` changes that) |
140
+ | `HEARTOFGOLD_DATA_DIR` | platform user data dir (`~/.local/share/heartofgold` on Linux) | Database, `files/`, `backups/`, generated secret |
141
+ | `HEARTOFGOLD_SECRET_KEY` | generated on first run (stored with mode 0600) | Signs session cookies and CSRF tokens |
142
+ | `HEARTOFGOLD_RATE_LIMIT` | `120/minute` | Per credential; `0` disables |
143
+ | `HEARTOFGOLD_MAX_UPLOAD_MB` | `10` | Per-file REST upload limit (MCP uploads: 256 KB) |
144
+ | `HEARTOFGOLD_MAX_STORAGE_MB` | `2048` | Total attachment storage |
145
+ | `HEARTOFGOLD_LOG_LEVEL` | `info` | JSON log lines on stdout |
146
+
147
+ ## API
148
+
149
+ - REST: `/api/v1` with `Authorization: Bearer hog_…`; interactive docs at `/api/v1/docs`; writes
150
+ accept an `X-Session-Label` header.
151
+ - MCP: `/mcp` (Streamable HTTP, stateless). Tools: whoami, projects, tasks, comments, questions,
152
+ claims, inbox, waiting, history, search, attachments.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ uv sync
158
+ uv run pytest
159
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
160
+ uv run heartofgold serve
161
+ ```
162
+
163
+ Decisions taken while building: [`docs/DECISIONS.md`](https://github.com/takacsd/HeartOfGold/blob/main/docs/DECISIONS.md).
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,140 @@
1
+ # HeartOfGold
2
+
3
+ A small, self-hosted kanban board shared by you and your AI agents. You use the web UI; agents
4
+ (local Claude Code, Claude Code cloud sessions, claude.ai chat, Desktop, mobile and Cowork) use a
5
+ REST API and a remote MCP server. They hand work to each other, ask you questions, and you approve
6
+ and sign off. One Python package, one SQLite file, no other services.
7
+
8
+ - Projects with a Markdown plan and ordered columns; Draft → Ready → In Progress → Review → Done by
9
+ default, where Ready and Done are human-gated and Review/Done need a written resolution.
10
+ - Tasks with Markdown descriptions, comments with @mentions, dependencies, attachments (images,
11
+ PDFs, HTML reports, text), Mermaid diagrams and full revision history with one-click restore.
12
+ - Every agent has its own identity and inbox; parallel sessions claim tasks so they never collide.
13
+ - Reachable from anywhere at zero cost through Tailscale Funnel on the free plan.
14
+
15
+ Docs: [deployment](https://github.com/takacsd/HeartOfGold/blob/main/docs/deployment.md) ·
16
+ [client setup](https://github.com/takacsd/HeartOfGold/blob/main/docs/client-setup.md) ·
17
+ [agent conventions](https://github.com/takacsd/HeartOfGold/blob/main/docs/agent-conventions.md) ·
18
+ [spec](https://github.com/takacsd/HeartOfGold/blob/main/docs/spec.md)
19
+
20
+ ## Quick start (about 10 minutes)
21
+
22
+ You need a machine that stays on (Linux, macOS or a Raspberry Pi with a 64-bit OS) and a free
23
+ [Tailscale](https://tailscale.com) account.
24
+
25
+ **1. Install uv and HeartOfGold.** uv also fetches Python 3.12 if your system has none.
26
+
27
+ ```bash
28
+ curl -LsSf https://astral.sh/uv/install.sh | sh # see https://docs.astral.sh/uv/ for other ways
29
+ uv tool install heartofgold # or, before the first PyPI release:
30
+ # uv tool install git+https://github.com/takacsd/HeartOfGold
31
+ uv tool update-shell # puts ~/.local/bin on PATH; open a new shell
32
+ ```
33
+
34
+ (`pipx install heartofgold` works too if you prefer pipx; it needs Python 3.12 or newer.)
35
+
36
+ **2. Create your account and an agent token.**
37
+
38
+ ```bash
39
+ heartofgold init # data dir, secret, your owner account
40
+ heartofgold create-agent laptop
41
+ heartofgold issue-token laptop --name "laptop claude code" # prints hog_… once: keep it
42
+ ```
43
+
44
+ **3. Put it on the internet with Tailscale Funnel.** Install Tailscale 1.52 or newer from
45
+ [tailscale.com/download](https://tailscale.com/download) (distribution packages are often older;
46
+ on macOS use the open-source `tailscaled` variant; keep MagicDNS on), log in, then:
47
+
48
+ ```bash
49
+ sudo tailscale funnel --bg 8000 # approve the prompt that enables HTTPS and Funnel
50
+ tailscale funnel status # shows your URL, e.g. https://heartofgold.tail1234.ts.net
51
+ ```
52
+
53
+ The public DNS name can take up to 10 minutes to appear the first time.
54
+
55
+ **4. Tell HeartOfGold its public URL and run it as a service.** The board only accepts MCP requests
56
+ for the host in `HEARTOFGOLD_BASE_URL`, so this step is required.
57
+
58
+ ```bash
59
+ mkdir -p ~/.config/heartofgold
60
+ echo "HEARTOFGOLD_BASE_URL=https://heartofgold.tail1234.ts.net" > ~/.config/heartofgold/env
61
+
62
+ # Linux (systemd user service)
63
+ curl -fsSLo ~/.config/systemd/user/heartofgold.service --create-dirs \
64
+ https://raw.githubusercontent.com/takacsd/HeartOfGold/main/examples/heartofgold.service
65
+ systemctl --user daemon-reload && systemctl --user enable --now heartofgold
66
+ loginctl enable-linger "$USER" # keep it running when you are logged out
67
+ ```
68
+
69
+ On macOS use [`examples/com.heartofgold.plist`](https://github.com/takacsd/HeartOfGold/blob/main/examples/com.heartofgold.plist)
70
+ with `launchctl`. On Windows, run `heartofgold serve` in a terminal or from Task Scheduler (not
71
+ tested). To just try it: `uvx heartofgold serve` (with the same environment variable exported).
72
+
73
+ **5. Log in** at your `https://…ts.net` URL (not `http://127.0.0.1`: with an https base URL the
74
+ session cookie is HTTPS-only). Then connect your agents: see
75
+ [client setup](https://github.com/takacsd/HeartOfGold/blob/main/docs/client-setup.md). For Claude Code
76
+ on this machine:
77
+
78
+ ```bash
79
+ export HEARTOFGOLD_TOKEN=hog_… # also add this line to your shell profile
80
+ claude mcp add --transport http --scope user heartofgold https://heartofgold.tail1234.ts.net/mcp \
81
+ --header "Authorization: Bearer ${HEARTOFGOLD_TOKEN}"
82
+ claude mcp get heartofgold # check the header is not just "Bearer "
83
+ ```
84
+
85
+ Paste [`docs/agent-conventions.md`](https://github.com/takacsd/HeartOfGold/blob/main/docs/agent-conventions.md)
86
+ into your `CLAUDE.md`, open Claude Code and ask "what's in my HeartOfGold inbox?".
87
+
88
+ ## Everyday operations
89
+
90
+ - **CLI** (runs on the server, acts as admin): `init`, `serve`, `create-agent <handle> [--admin]`,
91
+ `issue-token <handle> --name <name>`, `revoke-token <prefix>`, `set-role <handle> admin|member`,
92
+ `reset-password <handle>`, `backup [--to PATH]`. If your env file sets anything besides the base
93
+ URL (such as `HEARTOFGOLD_DATA_DIR`), load it first: `set -a; . ~/.config/heartofgold/env; set +a`.
94
+ - **Undoing mistakes**: every text change has a History with diff and Restore; deleted comments and
95
+ attachments come back from Admin. Backups are for disasters.
96
+ - **Backups**: `heartofgold backup` writes a consistent snapshot, new attachment files and the
97
+ secret key to `<data dir>/backups` (or `--to` another disk) and keeps 14 snapshots. Schedule it
98
+ with [the backup timer](https://github.com/takacsd/HeartOfGold/blob/main/examples/heartofgold-backup.timer)
99
+ or cron. Restoring is described in
100
+ [deployment](https://github.com/takacsd/HeartOfGold/blob/main/docs/deployment.md#restore).
101
+ - **Upgrades**: `uv tool upgrade heartofgold` (or `pipx upgrade heartofgold`), then
102
+ `systemctl --user restart heartofgold`. Database migrations run at startup.
103
+
104
+ ## Configuration
105
+
106
+ All settings are environment variables; all have defaults. Invalid values stop startup with a
107
+ message naming the variable.
108
+
109
+ | Variable | Default | Purpose |
110
+ | --- | --- | --- |
111
+ | `HEARTOFGOLD_BASE_URL` | `http://127.0.0.1:<port>` | Public URL without a path, e.g. `https://heartofgold.tail1234.ts.net` (may include `:8443`) |
112
+ | `HEARTOFGOLD_PORT` | `8000` | Listen port (the server binds to 127.0.0.1; `serve --host` changes that) |
113
+ | `HEARTOFGOLD_DATA_DIR` | platform user data dir (`~/.local/share/heartofgold` on Linux) | Database, `files/`, `backups/`, generated secret |
114
+ | `HEARTOFGOLD_SECRET_KEY` | generated on first run (stored with mode 0600) | Signs session cookies and CSRF tokens |
115
+ | `HEARTOFGOLD_RATE_LIMIT` | `120/minute` | Per credential; `0` disables |
116
+ | `HEARTOFGOLD_MAX_UPLOAD_MB` | `10` | Per-file REST upload limit (MCP uploads: 256 KB) |
117
+ | `HEARTOFGOLD_MAX_STORAGE_MB` | `2048` | Total attachment storage |
118
+ | `HEARTOFGOLD_LOG_LEVEL` | `info` | JSON log lines on stdout |
119
+
120
+ ## API
121
+
122
+ - REST: `/api/v1` with `Authorization: Bearer hog_…`; interactive docs at `/api/v1/docs`; writes
123
+ accept an `X-Session-Label` header.
124
+ - MCP: `/mcp` (Streamable HTTP, stateless). Tools: whoami, projects, tasks, comments, questions,
125
+ claims, inbox, waiting, history, search, attachments.
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ uv sync
131
+ uv run pytest
132
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
133
+ uv run heartofgold serve
134
+ ```
135
+
136
+ Decisions taken while building: [`docs/DECISIONS.md`](https://github.com/takacsd/HeartOfGold/blob/main/docs/DECISIONS.md).
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "heartofgold"
3
+ version = "0.1.0"
4
+ description = "A small self-hosted kanban board shared by humans and AI agents, with REST and MCP access."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "fastapi>=0.115",
11
+ "uvicorn>=0.30",
12
+ "pydantic>=2.7",
13
+ "pydantic-settings>=2.4",
14
+ "platformdirs>=4.2",
15
+ "typer>=0.12",
16
+ "sqlalchemy>=2.0",
17
+ "markdown-it-py>=3",
18
+ "mdit-py-plugins>=0.4",
19
+ "linkify-it-py>=2",
20
+ "nh3>=0.2.17",
21
+ "pillow>=10",
22
+ "argon2-cffi>=23",
23
+ "python-multipart>=0.0.9",
24
+ "mcp>=2.3,<3",
25
+ "jinja2>=3.1",
26
+ "itsdangerous>=2.2",
27
+ ]
28
+
29
+ [[project.authors]]
30
+ name = "Daniel Takacs"
31
+
32
+ [project.scripts]
33
+ heartofgold = "heartofgold.cli:app"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=8",
38
+ "pytest-cov>=5",
39
+ "httpx>=0.27",
40
+ "ruff>=0.6",
41
+ "mypy>=1.11",
42
+ ]
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.13.1,<0.14"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py312"
51
+
52
+ [tool.ruff.lint]
53
+ select = [
54
+ "E",
55
+ "F",
56
+ "I",
57
+ "UP",
58
+ "B",
59
+ "SIM",
60
+ "RUF",
61
+ "A",
62
+ ]
63
+
64
+ [tool.mypy]
65
+ strict = true
66
+ files = ["src/heartofgold"]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "-q"
71
+ filterwarnings = ["ignore:Using `httpx` with `starlette.testclient` is deprecated"]
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "heartofgold"
3
+ version = "0.1.0"
4
+ description = "A small self-hosted kanban board shared by humans and AI agents, with REST and MCP access."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Daniel Takacs" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi>=0.115",
12
+ "uvicorn>=0.30",
13
+ "pydantic>=2.7",
14
+ "pydantic-settings>=2.4",
15
+ "platformdirs>=4.2",
16
+ "typer>=0.12",
17
+ "sqlalchemy>=2.0",
18
+ "markdown-it-py>=3",
19
+ "mdit-py-plugins>=0.4",
20
+ "linkify-it-py>=2",
21
+ "nh3>=0.2.17",
22
+ "pillow>=10",
23
+ "argon2-cffi>=23",
24
+ "python-multipart>=0.0.9",
25
+ "mcp>=2.3,<3",
26
+ "jinja2>=3.1",
27
+ "itsdangerous>=2.2",
28
+ ]
29
+
30
+ [project.scripts]
31
+ heartofgold = "heartofgold.cli:app"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8",
36
+ "pytest-cov>=5",
37
+ "httpx>=0.27",
38
+ "ruff>=0.6",
39
+ "mypy>=1.11",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.13.1,<0.14"]
44
+ build-backend = "uv_build"
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ target-version = "py312"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "A"]
52
+
53
+ [tool.mypy]
54
+ strict = true
55
+ files = ["src/heartofgold"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ addopts = "-q"
60
+ filterwarnings = ["ignore:Using `httpx` with `starlette.testclient` is deprecated"]
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("heartofgold")
@@ -0,0 +1,3 @@
1
+ from heartofgold.cli import app
2
+
3
+ app(prog_name="heartofgold")
File without changes
@@ -0,0 +1,92 @@
1
+ """RFC 9457 problem+json responses for every error (section 6)."""
2
+
3
+ from collections.abc import Mapping
4
+ from http import HTTPStatus
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.exceptions import RequestValidationError
8
+ from fastapi.responses import JSONResponse, Response
9
+ from starlette.exceptions import HTTPException
10
+
11
+ from heartofgold.services.common import ServiceError
12
+
13
+ _CODES = {
14
+ 400: "invalid_request",
15
+ 401: "unauthenticated",
16
+ 403: "forbidden",
17
+ 404: "not_found",
18
+ 405: "invalid_request",
19
+ 413: "too_large",
20
+ 422: "validation_error",
21
+ 429: "rate_limited",
22
+ }
23
+
24
+
25
+ _API_PREFIXES = ("/api/", "/files/", "/mcp", "/.well-known/", "/healthz")
26
+
27
+
28
+ def _respond(request: Request, status: int, code: str, detail: str, **kw: object) -> Response:
29
+ """problem+json for API clients; a small HTML page for the browser UI."""
30
+ if not request.url.path.startswith(_API_PREFIXES):
31
+ from heartofgold.web.routes import error_page
32
+
33
+ return error_page(request, status, detail)
34
+ return problem(status, code, detail, **kw) # type: ignore[arg-type]
35
+
36
+
37
+ def problem(
38
+ status: int,
39
+ code: str,
40
+ detail: str,
41
+ headers: Mapping[str, str] | None = None,
42
+ extra: Mapping[str, object] | None = None,
43
+ ) -> JSONResponse:
44
+ body = {
45
+ "type": "about:blank",
46
+ "title": HTTPStatus(status).phrase,
47
+ "status": status,
48
+ "detail": detail,
49
+ "code": code,
50
+ **(extra or {}),
51
+ }
52
+ return JSONResponse(body, status, headers=headers, media_type="application/problem+json")
53
+
54
+
55
+ def _service_error(request: Request, exc: Exception) -> Response:
56
+ assert isinstance(exc, ServiceError)
57
+ return _respond(request, exc.status, exc.code, exc.detail, extra=exc.extra)
58
+
59
+
60
+ def _http_error(request: Request, exc: Exception) -> Response:
61
+ assert isinstance(exc, HTTPException)
62
+ code = _CODES.get(exc.status_code, "invalid_request")
63
+ detail = exc.detail if isinstance(exc.detail, str) else HTTPStatus(exc.status_code).phrase
64
+ return _respond(request, exc.status_code, code, detail, headers=exc.headers)
65
+
66
+
67
+ def _validation_error(request: Request, exc: Exception) -> JSONResponse:
68
+ assert isinstance(exc, RequestValidationError)
69
+ errors = exc.errors()
70
+ if any(e.get("type") == "json_invalid" for e in errors):
71
+ return problem(400, "invalid_request", "The request body is not valid JSON.")
72
+ parts = []
73
+ for error in errors:
74
+ loc = ".".join(str(p) for p in error["loc"] if p not in ("body", "query", "path"))
75
+ parts.append(f"{loc or 'body'}: {error['msg']}")
76
+ return problem(422, "validation_error", "; ".join(parts))
77
+
78
+
79
+ def _unexpected(request: Request, exc: Exception) -> JSONResponse:
80
+ return problem(500, "internal_error", "Unexpected server error; see the server log.")
81
+
82
+
83
+ def _overflow(request: Request, exc: Exception) -> JSONResponse:
84
+ return problem(422, "validation_error", "A number in the request is out of range.")
85
+
86
+
87
+ def install(app: FastAPI) -> None:
88
+ app.add_exception_handler(Exception, _unexpected)
89
+ app.add_exception_handler(OverflowError, _overflow)
90
+ app.add_exception_handler(ServiceError, _service_error)
91
+ app.add_exception_handler(HTTPException, _http_error)
92
+ app.add_exception_handler(RequestValidationError, _validation_error)
@@ -0,0 +1,85 @@
1
+ """Attachment upload, listing, delete/restore and download (FR-17..FR-23, section 8)."""
2
+
3
+ import os
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, File, Form, UploadFile
7
+ from fastapi.responses import FileResponse
8
+
9
+ from heartofgold.auth.deps import CallerDep, DbDep, StorageDep
10
+ from heartofgold.schemas import AttachmentOut
11
+ from heartofgold.services import attachments
12
+ from heartofgold.services.common import not_found
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ class BlobResponse(FileResponse):
18
+ """A file response carrying only the headers section 8 lists (no etag or last-modified)."""
19
+
20
+ def set_stat_headers(self, stat_result: os.stat_result) -> None:
21
+ self.headers.setdefault("content-length", str(stat_result.st_size))
22
+ del self.headers["accept-ranges"]
23
+
24
+ def _should_use_range(self, http_if_range: str) -> bool:
25
+ return False
26
+
27
+
28
+ CACHE = "private, max-age=31536000, immutable"
29
+ SVG_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
30
+ INLINE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}
31
+
32
+
33
+ @router.post("/api/v1/tasks/{ref}/attachments", status_code=201)
34
+ def upload(
35
+ ref: str,
36
+ file: Annotated[UploadFile, File()],
37
+ caller: CallerDep,
38
+ db: DbDep,
39
+ storage: StorageDep,
40
+ alt_text: Annotated[str | None, Form()] = None,
41
+ ) -> AttachmentOut:
42
+ data = file.file.read(storage.max_upload_bytes + 1)
43
+ return attachments.upload(db, caller, storage, ref, file.filename or "file", data, alt_text)
44
+
45
+
46
+ @router.get("/api/v1/tasks/{ref}/attachments")
47
+ def list_attachments(ref: str, caller: CallerDep, db: DbDep) -> list[AttachmentOut]:
48
+ return attachments.list_attachments(db, caller, ref)
49
+
50
+
51
+ @router.delete("/api/v1/attachments/{attachment_id}")
52
+ def delete_attachment(attachment_id: str, caller: CallerDep, db: DbDep) -> AttachmentOut:
53
+ return attachments.set_deleted(db, caller, attachment_id, True)
54
+
55
+
56
+ @router.post("/api/v1/attachments/{attachment_id}/restore")
57
+ def restore_attachment(attachment_id: str, caller: CallerDep, db: DbDep) -> AttachmentOut:
58
+ return attachments.set_deleted(db, caller, attachment_id, False)
59
+
60
+
61
+ @router.get("/files/{attachment_id}/{filename}")
62
+ def download(
63
+ attachment_id: str, filename: str, caller: CallerDep, db: DbDep, storage: StorageDep
64
+ ) -> FileResponse:
65
+ meta, path = attachments.open_blob(db, caller, storage, attachment_id)
66
+ if filename != meta.filename:
67
+ raise not_found(f"Attachment {attachment_id} is named {meta.filename}.")
68
+ kind = meta.content_type.split(";")[0]
69
+ headers = {"Cache-Control": CACHE, "X-Content-Type-Options": "nosniff"}
70
+ disposition = "inline"
71
+ if kind == "image/svg+xml":
72
+ headers["Content-Security-Policy"] = SVG_CSP
73
+ elif kind == "text/html":
74
+ headers["Content-Security-Policy"] = "sandbox allow-scripts"
75
+ elif kind not in INLINE:
76
+ headers["Content-Security-Policy"] = "sandbox"
77
+ disposition = "attachment"
78
+ response = BlobResponse(
79
+ path,
80
+ media_type=meta.content_type,
81
+ filename=meta.filename,
82
+ content_disposition_type=disposition,
83
+ headers=headers,
84
+ )
85
+ return response