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/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index 51f4dc5..c972af9 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -1,26 +1,13 @@ --- name: daybook-posting -description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to Daybook and returns a URL. +description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a Daybook instance and returns a URL. --- # Daybook Posting Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations. -Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content. - -From the project root: - -```bash -python skills/daybook-posting/scripts/daybook_post.py --check -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md -cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" -python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" -``` - -The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill. - -The artifact URL is printed as the final stdout line. +You post to a **running Daybook server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of Daybook's source. The only things that tie you to a particular instance are two environment variables: `DAYBOOK_URL` (where the server is) and `DAYBOOK_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run. ## Configuration @@ -30,12 +17,54 @@ config) — the helper reads `os.environ` directly. There is no `.env` file support; a `.env` file in the working directory is not read automatically. ```text -DAYBOOK_URL=http://127.0.0.1:8001 -DAYBOOK_TOKEN=your-personal-api-token -DAYBOOK_SOURCE=agent +DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL +DAYBOOK_TOKEN=your-personal-api-token # from Settings -> Account +DAYBOOK_SOURCE=agent # a stable label for what's posting ``` -`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed. +- **`DAYBOOK_URL`** — the base URL of the Daybook instance you're posting to. + Set it to your server. If it's unset, the helper falls back to + `http://127.0.0.1:8001`, which is only correct when Daybook is running + locally on the same machine (its development default). For any remote or + deployed instance you **must** set this. +- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings → + Account in the app (not a token shared by everyone). Artifacts posted with + your token land only in your feed. +- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting + (agent-profile, script, or person name). Optional; can also be passed per + call with `--source`. + +## The Helper + +Always use the bundled helper script. Do not hand-build curl, JSON, +PowerShell multipart, or inline shell content (a `curl` fallback for +environments without the helper is documented at the end — the script is +still preferred everywhere it's available). + +The helper lives at **`scripts/daybook_post.py` inside this skill's own +directory** — not in any project you happen to be working in. Invoke it by +its absolute path. For brevity, the examples below assume you've captured +that path once: + +```bash +# Point this at the helper inside THIS skill directory (absolute path). +DAYBOOK_POST=/abs/path/to/this/skill/scripts/daybook_post.py +``` + +Then: + +```bash +python "$DAYBOOK_POST" --check +python "$DAYBOOK_POST" --kind markdown --title "Build Report" --content-file report.md +cat report.md | python "$DAYBOOK_POST" --kind markdown --title "Build Report" +python "$DAYBOOK_POST" --file diagram.svg --title "Architecture Diagram" +``` + +The artifact URL is printed as the final stdout line. + +(If you *are* working inside a Daybook source checkout, a compatibility +wrapper also exists at `scripts/daybook_post.py` in the repo root, but the +canonical helper is the one bundled in this skill.) ## Kind Cheat Sheet @@ -65,8 +94,9 @@ presentation. This script only **creates** artifacts — none of the following are possible through it. Updating an existing artifact's title/tags/metadata, -or deleting one, requires the raw API (see `docs/api.md`); enabling its -public share link is feed-UI-only, with no API equivalent at all yet. +or deleting one, requires the raw API (see the instance's `docs/api.md` or +`GET /api/capabilities`); enabling its public share link is feed-UI-only, +with no API equivalent at all yet. ## CDN Snippets For `html_full` @@ -119,11 +149,12 @@ artifact), upload it first and reference the returned URL: ```bash # 1. Upload -> prints the URL as the last line -python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png +python "$DAYBOOK_POST" --upload-image chart.png -# 2. Reference that URL in your markdown, then post as usual -# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56) -python skills/daybook-posting/scripts/daybook_post.py report.md +# 2. Reference that URL in your markdown, then post as usual. +# The printed URL is absolute against your DAYBOOK_URL, e.g. +# ![chart]($DAYBOOK_URL/assets/ab12cd34ef56) +python "$DAYBOOK_POST" report.md ``` The image is linked to the artifact that references it and is deleted along with it. @@ -136,19 +167,19 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks the right kind automatically: ```bash -python skills/daybook-posting/scripts/daybook_post.py results.csv -python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json +python "$DAYBOOK_POST" results.csv +python "$DAYBOOK_POST" --kind json --title "Run Summary" --content-file summary.json ``` `.xlsx` spreadsheets work too, with no special flag needed — just `--file` (or post it positionally, same as any other file): ```bash -python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx +python "$DAYBOOK_POST" balance_sheet.xlsx ``` The server converts it to a `csv` artifact automatically. Known -limitations (see `docs/api.md` for the full list): +limitations (see the instance's `docs/api.md` for the full list): - Only the workbook's **active sheet** converts; other sheets are dropped. - Formula cells need to have been calculated and saved by real spreadsheet @@ -165,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown fenced code block (still gets syntax highlighting): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" +python "$DAYBOOK_POST" --file script.py --as-markdown --title "Generated Script" ``` Without `--as-markdown`, it uploads as a downloadable binary file instead. @@ -189,8 +220,37 @@ context like `run_id` or `model` — these end up in the artifact's ## Rules - Run `--check` first if unsure Daybook is reachable. +- Make sure `DAYBOOK_URL` points at the right instance and `DAYBOOK_TOKEN` is set before posting anything real. - Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). - Use `--tags a,b,c` for filtering. - Use `--meta run_id=... --meta model=...` for machine-readable context. - Never inline multiline content into the shell command; use a file or stdin. - Add `--dry-run` to see exactly what would be sent (method, headers, payload) without actually posting — useful when unsure a payload is well-formed. + +## `curl` Fallback (No Helper Available) + +Prefer the helper above — it handles multipart, content-type inference, +JSON shaping, and provenance flags for you. Use raw `curl` **only** when you +genuinely can't run the bundled Python script (e.g. a minimal environment +with no Python). Text artifacts post as JSON to `POST /api/artifacts` with +bearer-token auth: + +```bash +curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ + -H "Authorization: Bearer $DAYBOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}' +``` + +File/image artifacts use multipart instead: + +```bash +curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ + -H "Authorization: Bearer $DAYBOOK_TOKEN" \ + -F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \ + -F "file=@diagram.png" +``` + +`GET $DAYBOOK_URL/api/capabilities` (no auth needed beyond a login/token per +that instance's config) lists supported kinds, methods, and the server +version if you need to confirm the contract before posting. 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):