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 <noreply@anthropic.com>
This commit is contained in:
parent
caeb2bedc7
commit
fb69261218
5 changed files with 126 additions and 3 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -6,7 +6,16 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.0] — 2026-09-03
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ urlpatterns = [
|
||||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-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.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
path("settings/", views.settings_home, name="settings-home"),
|
path("settings/", views.settings_home, name="settings-home"),
|
||||||
|
|
|
||||||
|
|
@ -322,11 +322,57 @@ class CapabilitiesView(APIView):
|
||||||
"/api/artifacts": ["GET", "POST"],
|
"/api/artifacts": ["GET", "POST"],
|
||||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||||
"/api/assets": ["POST"],
|
"/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
|
FEED_WINDOW_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
34
docs/api.md
34
docs/api.md
|
|
@ -529,8 +529,11 @@ Example response:
|
||||||
"methods": {
|
"methods": {
|
||||||
"/api/artifacts": ["GET", "POST"],
|
"/api/artifacts": ["GET", "POST"],
|
||||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
"/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 <your-personal-token>" http://127.0.0.1:8001/api/capabilities
|
curl -H "Authorization: Bearer <your-personal-token>" 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 <your-personal-token>" http://127.0.0.1:8001/api/skill
|
||||||
|
```
|
||||||
|
|
||||||
|
Discover it programmatically via `skill_url` in the capabilities response.
|
||||||
|
|
||||||
## Current MVP Constraints
|
## Current MVP Constraints
|
||||||
|
|
||||||
- Each user has a personal ingest API token; there is no shared token.
|
- Each user has a personal ingest API token; there is no shared token.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from django.utils import timezone
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from daybook_project import __version__
|
||||||
from artifacts.models import Artifact
|
from artifacts.models import Artifact
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -78,6 +79,42 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
response = self.client.get(reverse("capabilities-api"))
|
response = self.client.get(reverse("capabilities-api"))
|
||||||
self.assertEqual(response.status_code, 200)
|
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) --------------------------------------------------
|
# -- create (POST) --------------------------------------------------
|
||||||
|
|
||||||
def test_create_markdown_artifact(self):
|
def test_create_markdown_artifact(self):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue