From fb692612187f09bb32a5b36ea40e6553c5006b0e Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 3 Sep 2026 15:50:04 +0100 Subject: [PATCH] Add GET /api/skill endpoint serving the posting skill Serves skills/daybook-posting/SKILL.md so users/agents can fetch the posting instructions from the running instance instead of hunting for the file. The server rewrites the DAYBOOK_URL config line to the request's own origin, so the text handed to an agent already points at the right server; the recipient only supplies their token. - Same auth as /api/capabilities (session or bearer token); no user data. - Raw text/markdown by default, ?format=json returns {version, url, skill}. - Advertised via skill_url + a /api/skill methods entry in capabilities. - Ships in the Docker image (COPY . /app, skills/ not dockerignored). - Tests for auth, origin rewrite, JSON mode, session auth, capabilities ad. - docs/api.md + CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 ++++++++- artifacts/urls.py | 1 + artifacts/views.py | 46 +++++++++++++++++++++++++++++++++++++ docs/api.md | 34 +++++++++++++++++++++++++-- tests/api/test_artifacts.py | 37 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce888e..4281694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,16 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`. ## [Unreleased] -_Nothing yet._ +### Added +- `GET /api/skill` serves the bundled agent-posting skill as Markdown (or + `?format=json`), with `DAYBOOK_URL` pre-filled to the instance's own + origin so the instructions are ready to hand to an AI. Advertised as + `skill_url` in `/api/capabilities`. + +### Changed +- Rewrote the posting skill (`skills/daybook-posting/SKILL.md`) to be + location-agnostic: no longer assumes a local source checkout, reframes + `DAYBOOK_URL` as set-your-instance, and adds a raw `curl` fallback. ## [0.1.0] — 2026-09-03 diff --git a/artifacts/urls.py b/artifacts/urls.py index 98323e3..dae96dc 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("assets/", views.asset_view, name="asset"), path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), + path("api/skill", views.SkillView.as_view(), name="skill-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), path("settings/", views.settings_home, name="settings-home"), diff --git a/artifacts/views.py b/artifacts/views.py index 555c121..a6ec867 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -322,11 +322,57 @@ class CapabilitiesView(APIView): "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"], }, + "skill_url": request.build_absolute_uri(reverse("skill-api")), } ) +SKILL_MD_PATH = settings.BASE_DIR / "skills" / "daybook-posting" / "SKILL.md" + + +def render_posting_skill(origin): + """Return the bundled posting SKILL.md with its DAYBOOK_URL placeholder + rewritten to this instance's origin, so text handed to an agent already + points at the right server. Only the config-block assignment is touched; + the surrounding prose (which explains DAYBOOK_URL) is left alone.""" + text = SKILL_MD_PATH.read_text(encoding="utf-8") + return re.sub( + r"(?m)^(DAYBOOK_URL=)\S+", + lambda m: m.group(1) + origin, + text, + ) + + +class SkillView(APIView): + """Serve the bundled agent-posting skill (skills/daybook-posting/SKILL.md), + with DAYBOOK_URL pre-filled to this instance so a recipient posts to the + right server. Same auth as capabilities: a logged-in user can copy it, or + an agent can fetch it with its API token. + + GET /api/skill -> raw Markdown (text/markdown) + GET /api/skill?format=json -> {"version", "url", "skill"} + """ + + def get(self, request): + if not request.user.is_authenticated and resolve_token_user(request) is None: + return HttpResponseForbidden("Login or a valid API token is required") + + origin = request.build_absolute_uri("/").rstrip("/") + try: + skill = render_posting_skill(origin) + except FileNotFoundError: + raise Http404("Posting skill is not bundled with this deployment") + + if request.GET.get("format") == "json": + from daybook_project import __version__ + + return Response({"version": __version__, "url": origin, "skill": skill}) + return HttpResponse(skill, content_type="text/markdown; charset=utf-8") + + FEED_WINDOW_DAYS = 7 diff --git a/docs/api.md b/docs/api.md index 8d95c6f..b3778c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -529,8 +529,11 @@ Example response: "methods": { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"] - } + "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"] + }, + "skill_url": "http://127.0.0.1:8001/api/skill" } ``` @@ -545,6 +548,33 @@ endpoints, it accepts a bearer token as well as a browser session: curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities ``` +## Posting Skill + +Endpoint: + +```http +GET /api/skill +GET /api/skill?format=json +``` + +Returns the bundled agent-posting skill (`skills/daybook-posting/SKILL.md`) — +the same instructions you'd give an AI so it knows how to publish to this +instance. The server rewrites the `DAYBOOK_URL` line to **this instance's own +origin**, so the text a caller receives already points at the right server; +a recipient only has to fill in their `DAYBOOK_TOKEN`. + +Same auth as capabilities (a browser session **or** a bearer token), so a +logged-in user can copy it to hand to their agent, or an agent can fetch it +directly. The default response is raw Markdown (`text/markdown`); add +`?format=json` for `{"version", "url", "skill"}`. + +```bash +# Fetch the ready-to-use instructions for this instance +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/skill +``` + +Discover it programmatically via `skill_url` in the capabilities response. + ## Current MVP Constraints - Each user has a personal ingest API token; there is no shared token. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 967ed4f..f801988 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -12,6 +12,7 @@ from django.utils import timezone from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase +from daybook_project import __version__ from artifacts.models import Artifact User = get_user_model() @@ -78,6 +79,42 @@ class ArtifactAPITestCase(APITestCase): response = self.client.get(reverse("capabilities-api")) self.assertEqual(response.status_code, 200) + def test_capabilities_advertises_skill(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + self.assertEqual(response.data["methods"]["/api/skill"], ["GET"]) + self.assertTrue(response.data["skill_url"].endswith("/api/skill")) + + # -- skill ---------------------------------------------------------- + + def test_skill_requires_auth(self): + response = self.client.get(reverse("skill-api")) + self.assertEqual(response.status_code, 403) + + def test_skill_returns_markdown_with_origin_filled_in(self): + response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/markdown; charset=utf-8") + body = response.content.decode() + # Frontmatter is intact and the config placeholder is rewritten to this + # instance's origin (http://testserver under the test client). + self.assertIn("name: daybook-posting", body) + self.assertIn("DAYBOOK_URL=http://testserver", body) + self.assertNotIn("your-daybook-instance.example.com", body) + + def test_skill_json_format(self): + response = self.client.get( + reverse("skill-api"), {"format": "json"}, **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["version"], __version__) + self.assertEqual(response.data["url"], "http://testserver") + self.assertIn("name: daybook-posting", response.data["skill"]) + + def test_skill_with_session(self): + self.client.force_login(self.user) + response = self.client.get(reverse("skill-api")) + self.assertEqual(response.status_code, 200) + # -- create (POST) -------------------------------------------------- def test_create_markdown_artifact(self):