Ship a Logbuch skill and record token use
- docs/skill.md teaches a Claude what Logbuch is for, how an entry sounds and in which order the calls go, served at GET /api/v1/skill and as a download next to the spec and the guide - a test keeps the skill from drifting: frontmatter, only endpoints that exist, house rules - resolveClient stamps last_used_at, the access list said never used even after eleven entries
This commit is contained in:
@@ -229,6 +229,7 @@ Antwort: `{ "published": [...], "meta": { "total": 0 } }`.
|
||||
|---|---|
|
||||
| `GET /api/v1/openapi.json` | maschinenlesbare Beschreibung für Postman, Bruno, Insomnia oder einen KI-Assistenten |
|
||||
| `GET /api/v1/style-guide` | dieser Redaktionsleitfaden als Markdown |
|
||||
| `GET /api/v1/skill` | fertiger Skill für Claude, gehört als `~/.claude/skills/logbuch/SKILL.md` abgelegt |
|
||||
|
||||
Beide verlangen ein Token. In der Verwaltung unter API lädt ein angemeldeter Admin beide Dateien ohne Token herunter.
|
||||
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: logbuch
|
||||
description: Use when writing project updates into Logbuch, the update archive of the NYO group. Covers the API at /api/v1, the editorial rules, images, dating and publishing. Trigger on "Logbuch", "Eintrag schreiben", "Projekt-Update", "Änderung festhalten", or when work is finished and someone should hear about it.
|
||||
---
|
||||
|
||||
# Logbuch
|
||||
|
||||
Logbuch hält fest, was in den Produkten passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag über das, was fertig geworden ist. Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung, sondern für Kollegen und Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen.
|
||||
|
||||
## Was du wissen musst, bevor du schreibst
|
||||
|
||||
Ein Zugang kann **niemals veröffentlichen**. Alles, was du anlegst, ist ein Entwurf. Ein Mensch schaut drauf und gibt frei. Schreib entsprechend fertig, nicht halb.
|
||||
|
||||
Jede Anfrage trägt das Token im Kopf:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Die Basis-Adresse steht in deiner Aufgabe, im Zweifel `https://logbuch.nyo.de`. Alles liegt unter `/api/v1`.
|
||||
|
||||
Dein Zugang hat drei Eigenschaften, die bestimmen, was du darfst:
|
||||
|
||||
| Eigenschaft | Bedeutung |
|
||||
|---|---|
|
||||
| Modus | `read` liest, `write` schreibt zusätzlich |
|
||||
| Zielgruppe | `internal`, `customer` oder `public`. Du siehst und setzt nie etwas Offeneres |
|
||||
| Projektbindung | Ist ein Projekt gesetzt, gilt der Zugang ausschließlich dort |
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
1. `GET /api/v1/projects` - welche Projekte darfst du bespielen
|
||||
2. `GET /api/v1/post-types` - die gültigen Schlüssel der Beitragsarten, nicht raten
|
||||
3. `GET /api/v1/style-guide` - der Redaktionsleitfaden im Wortlaut
|
||||
4. `POST /api/v1/media` - Bilder hochladen, wenn du welche hast
|
||||
5. `POST /api/v1/posts` - Entwurf anlegen
|
||||
6. `PUT /api/v1/posts/:id/blocks` - Inhalt setzen
|
||||
7. `GET /api/v1/posts/:id` - zurücklesen und prüfen
|
||||
|
||||
## Einen Eintrag anlegen
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{
|
||||
"project": "trakk",
|
||||
"title": "SLA-Uhr zählt Feiertage nicht mehr mit",
|
||||
"teaser": "Reaktionszeiten liefen über Wochenenden weiter und lösten falsche Eskalationen aus.",
|
||||
"type": "fix",
|
||||
"audience": "customer",
|
||||
"cover_media_id": "<medien-id>",
|
||||
"publish_at": "2026-06-04T09:00:00Z",
|
||||
"idempotency_key": "sla-2026-06-04"
|
||||
}' \
|
||||
"$BASE/api/v1/posts"
|
||||
```
|
||||
|
||||
Die Antwort trägt die Kennung. **Merk sie dir**, du brauchst sie für Blöcke, Änderungen und Löschen. Hast du sie verloren, findest du den Entwurf über `GET /api/v1/posts/<slug>`.
|
||||
|
||||
`idempotency_key` setzen, wenn dein Lauf abbrechen könnte. Derselbe Schlüssel liefert denselben Eintrag zurück, statt einen zweiten anzulegen.
|
||||
|
||||
`number` ist bei Entwürfen `null`. Die laufende Nummer vergibt Logbuch beim Veröffentlichen, damit im Archiv keine Lücken entstehen.
|
||||
|
||||
## Inhalt setzen
|
||||
|
||||
```bash
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-d '{"blocks":[
|
||||
{"type":"text","data":{"text":"Bisher lief die Uhr durch. Wer freitags meldete, hatte montags eine Eskalation im Postfach."}},
|
||||
{"type":"image","data":{"mediaId":"<medien-id>"}},
|
||||
{"type":"text","data":{"text":"Jetzt zählt nur noch die vereinbarte **Servicezeit**."}}
|
||||
]}' \
|
||||
"$BASE/api/v1/posts/<id>/blocks"
|
||||
```
|
||||
|
||||
Der Aufruf setzt die Blöcke vollständig, in der übergebenen Reihenfolge. Höchstens 100.
|
||||
|
||||
| Typ | Felder in `data` |
|
||||
|---|---|
|
||||
| `text` | `text`, als Markdown: Absätze, **fett**, *kursiv*, Verweise, Listen, `###` als Zwischenüberschrift. Kein rohes HTML |
|
||||
| `image` | `mediaId` |
|
||||
| `gallery` | `mediaIds` |
|
||||
| `before_after` | `beforeMediaId`, `afterMediaId` |
|
||||
| `video` | `url`, `title` |
|
||||
| `quote` | `text`, `source` |
|
||||
| `code` | `code`, `language` |
|
||||
| `link` | `url`, `title`, `description` |
|
||||
| `callout` | `text`, `tone` |
|
||||
|
||||
## Bilder
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-F "[email protected]" \
|
||||
-F "project=trakk" \
|
||||
-F "alt=Spaltenmenü mit den neuen Einträgen" \
|
||||
"$BASE/api/v1/media"
|
||||
```
|
||||
|
||||
`project` ist der Slug, bei projektgebundenem Token darf er wegbleiben. Nur Bildformate, höchstens 15 MB. Die Antwort trägt die Medien-Kennung für `cover_media_id` und für Bildblöcke.
|
||||
|
||||
Ein Bild zeigt, was sich geändert hat. Kein Firmenlogo, kein Symbolbild. Findest du nichts, was den Punkt wirklich zeigt, lass es weg. Der Alt-Text beschreibt, was zu sehen ist, und ist bei öffentlichen Beiträgen Pflicht, sonst lässt sich der Beitrag nicht freigeben.
|
||||
|
||||
Ein Bild wieder loswerden: `DELETE /api/v1/media/:id`. Hängt es noch an einem Beitrag, kommt 409.
|
||||
|
||||
## Datum
|
||||
|
||||
`publish_at` nimmt eine ISO-Zeitangabe, beim Anlegen und beim Ändern. Du veröffentlichst damit nichts, du hinterlegst nur das Datum. Gibt ein Mensch frei, übernimmt Logbuch genau dieses statt der aktuellen Uhrzeit. So landen ältere Meldungen an der richtigen Stelle im Archiv.
|
||||
|
||||
## Wie es klingen soll
|
||||
|
||||
**Aufbau.** Titel, Anreißer, dann der Text. Der Titel sagt, was sich geändert hat, nicht dass sich etwas geändert hat. Der Anreißer fasst in zwei bis drei Sätzen zusammen, was der Leser davon hat. Der Text erklärt, was vorher war, was jetzt ist, und was zu tun bleibt.
|
||||
|
||||
Gute Titel:
|
||||
|
||||
- "SLA-Uhr zählt Feiertage nicht mehr mit"
|
||||
- "Spalten per Rechtsklick verwalten"
|
||||
|
||||
Schlechte Titel:
|
||||
|
||||
- "Verbesserungen an der Tabelle" - sagt nichts
|
||||
- "Wir haben ein spannendes neues Feature gebaut" - Werbung statt Information
|
||||
|
||||
**Ton.** Sachlich und direkt, aktive Verben. Keine Werbesprache, keine Superlative, keine Ausrufezeichen, keine Emojis. Keine Gedankenstriche, immer normales Minus. Echte Umlaute, niemals ae, oe, ue.
|
||||
|
||||
**Länge.** So lang wie nötig, meist drei bis sechs Absätze. Eine Fehlerbehebung darf aus zwei Sätzen bestehen.
|
||||
|
||||
**Zielgruppe.** `internal` für Umbauten, Zwischenstände und alles über Kunden. `customer` für das, was ein Kunde des Produkts wissen soll. `public` nur für das, was auch außerhalb stehen darf. Im Zweifel die engere wählen.
|
||||
|
||||
## Fehler
|
||||
|
||||
Antworten kommen als Problem-JSON mit `type`, `title`, `status` und `detail`.
|
||||
|
||||
| Status | Bedeutung |
|
||||
|---|---|
|
||||
| 400 | fehlerhafte Eingabe, `detail` sagt welches Feld |
|
||||
| 401 | Token fehlt oder ist widerrufen |
|
||||
| 403 | dem Zugang fehlt das Recht, etwa eine zu offene Zielgruppe |
|
||||
| 404 | nicht vorhanden oder für diesen Zugang nicht sichtbar |
|
||||
| 409 | bereits veröffentlicht, Änderung nicht mehr möglich |
|
||||
| 422 | unbekannte Beitragsart oder fehlende Projektbindung |
|
||||
|
||||
## Was du nicht tust
|
||||
|
||||
- Veröffentlichen. Geht nicht und soll nicht.
|
||||
- Eine offenere Zielgruppe setzen, als dein Zugang hat.
|
||||
- Beitragsarten raten statt `GET /api/v1/post-types` zu fragen.
|
||||
- Symbolbilder anhängen, damit etwas bunt ist.
|
||||
- Bei einem Abbruch blind neu anlegen. Erst `GET /api/v1/posts/<slug>` fragen oder `idempotency_key` verwenden.
|
||||
|
||||
## Vollständige Beschreibung
|
||||
|
||||
`GET /api/v1/openapi.json` liefert die maschinenlesbare Fassung mit allen Feldern und Antworten. `GET /api/v1/style-guide` liefert den Leitfaden. Beide verlangen dasselbe Token.
|
||||
@@ -11,6 +11,9 @@ const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
outputFileTracingIncludes: {
|
||||
'/api/v1/style-guide': ['./docs/style-guide.md'],
|
||||
'/api/v1/skill': ['./docs/skill.md'],
|
||||
'/admin/api/files/skill': ['./docs/skill.md'],
|
||||
'/admin/api/files/style-guide': ['./docs/style-guide.md'],
|
||||
'/': ['./node_modules/drizzle-orm/**'],
|
||||
'/api/v1/media': sharpNative,
|
||||
'/admin/media': sharpNative,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readSkill, skillResponse } from '~/lib/api-docs'
|
||||
import { requireAdminArea } from '~/lib/auth-guards'
|
||||
|
||||
export async function GET() {
|
||||
await requireAdminArea()
|
||||
|
||||
const text = await readSkill()
|
||||
|
||||
if (text === null) {
|
||||
return new Response('docs/skill.md fehlt.', { status: 500 })
|
||||
}
|
||||
|
||||
return skillResponse(text, true)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { TbBook, TbDownload } from 'react-icons/tb'
|
||||
import { TbBook, TbDownload, TbSparkles } from 'react-icons/tb'
|
||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||
import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
|
||||
import { cellClass, headCellClass } from '~/components/admin/styles'
|
||||
@@ -42,6 +42,7 @@ export default async function AdminApiPage() {
|
||||
const files = [
|
||||
{ label: 'OpenAPI', url: `${origin}/api/v1/openapi.json` },
|
||||
{ label: 'Leitfaden', url: `${origin}/api/v1/style-guide` },
|
||||
{ label: 'Skill', url: `${origin}/api/v1/skill` },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -74,7 +75,10 @@ export default async function AdminApiPage() {
|
||||
<div className="flex max-w-read flex-col gap-4 text-pretty text-ink-2">
|
||||
<p className="m-0">
|
||||
Die Beschreibung im OpenAPI-Format lässt sich in Postman, Bruno, Insomnia und ähnliche Werkzeuge
|
||||
einlesen, und ein KI-Assistent kann sie direkt verwenden.
|
||||
einlesen. Für Claude gibt es zusätzlich einen fertigen Skill: die Datei landet als
|
||||
{' '}
|
||||
<code className="font-mono text-small">~/.claude/skills/logbuch/SKILL.md</code>, danach weiß jede
|
||||
Sitzung, wofür Logbuch da ist, wie ein Eintrag klingt und in welcher Reihenfolge die Aufrufe gehen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
@@ -93,6 +97,14 @@ export default async function AdminApiPage() {
|
||||
<TbBook aria-hidden="true" className="size-4" />
|
||||
Leitfaden laden
|
||||
</a>
|
||||
<a
|
||||
href={adminApiFilePath('skill')}
|
||||
download
|
||||
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
|
||||
>
|
||||
<TbSparkles aria-hidden="true" className="size-4" />
|
||||
SKILL.md laden
|
||||
</a>
|
||||
</div>
|
||||
<p className="m-0 text-small">
|
||||
Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen
|
||||
@@ -189,6 +201,14 @@ export default async function AdminApiPage() {
|
||||
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/regel-engine'}
|
||||
/>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/api/v1/skill"
|
||||
auth="read"
|
||||
summary="Der Skill für Claude als Markdown. Gehört als ~/.claude/skills/logbuch/SKILL.md abgelegt."
|
||||
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/skill'}
|
||||
/>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/api/v1/post-types"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { resolveClient } from '~/lib/api-auth'
|
||||
import { readSkill, skillResponse } from '~/lib/api-docs'
|
||||
import { problem } from '~/lib/problem'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const client = await resolveClient(request)
|
||||
|
||||
if (!client) {
|
||||
return problem(401, 'unauthorized')
|
||||
}
|
||||
|
||||
const text = await readSkill()
|
||||
|
||||
if (text === null) {
|
||||
return problem(500, 'skill_missing', 'docs/skill.md fehlt.')
|
||||
}
|
||||
|
||||
return skillResponse(text)
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export async function findActiveClientByTokenHash(tokenHash: string): Promise<Ap
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function markClientUsed(id: string): Promise<void> {
|
||||
await db.update(apiClients).set({ lastUsedAt: new Date() }).where(eq(apiClients.id, id))
|
||||
}
|
||||
|
||||
export async function listClients(): Promise<ApiClient[]> {
|
||||
return db.select().from(apiClients).orderBy(asc(apiClients.revokedAt), desc(apiClients.createdAt))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const adminClientsPath = '/admin/clients' as Route
|
||||
export const adminPostTypesPath = '/admin/post-types' as Route
|
||||
export const adminNewPostTypePath = '/admin/post-types/new' as Route
|
||||
|
||||
export function adminApiFilePath(file: 'openapi' | 'style-guide'): Route {
|
||||
export function adminApiFilePath(file: 'openapi' | 'style-guide' | 'skill'): Route {
|
||||
return `/admin/api/files/${file}` as Route
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { findActiveClientByTokenHash } from '~/data/repositories/clients'
|
||||
import { findActiveClientByTokenHash, markClientUsed } from '~/data/repositories/clients'
|
||||
import type { ApiClient } from '~/data/schema'
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
@@ -19,5 +19,11 @@ export async function resolveClient(request: Request): Promise<ApiClient | undef
|
||||
return undefined
|
||||
}
|
||||
|
||||
return findActiveClientByTokenHash(hashToken(token))
|
||||
const client = await findActiveClientByTokenHash(hashToken(token))
|
||||
|
||||
if (client) {
|
||||
await markClientUsed(client.id)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
+20
-2
@@ -15,14 +15,32 @@ export function openApiResponse(origin: string): Response {
|
||||
})
|
||||
}
|
||||
|
||||
export async function readStyleGuide(): Promise<string | null> {
|
||||
async function readDoc(name: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
|
||||
return await readFile(join(process.cwd(), 'docs', name), 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readStyleGuide(): Promise<string | null> {
|
||||
return readDoc('style-guide.md')
|
||||
}
|
||||
|
||||
export async function readSkill(): Promise<string | null> {
|
||||
return readDoc('skill.md')
|
||||
}
|
||||
|
||||
export function skillResponse(text: string, download = false): Response {
|
||||
const headers: Record<string, string> = { 'content-type': 'text/markdown; charset=utf-8' }
|
||||
|
||||
if (download) {
|
||||
headers['content-disposition'] = 'attachment; filename="SKILL.md"'
|
||||
}
|
||||
|
||||
return new Response(text, { headers })
|
||||
}
|
||||
|
||||
export function styleGuideResponse(text: string, download = false): Response {
|
||||
const headers: Record<string, string> = { 'content-type': 'text/markdown; charset=utf-8' }
|
||||
|
||||
|
||||
@@ -129,6 +129,16 @@ export function openApiDocument(baseUrl: string) {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/skill': {
|
||||
get: {
|
||||
summary: 'Skill für Claude abrufen',
|
||||
description: 'Markdown mit Frontmatter, gehört als ~/.claude/skills/logbuch/SKILL.md abgelegt.',
|
||||
responses: {
|
||||
'200': { description: 'Skill als Markdown', content: { 'text/markdown': { schema: { type: 'string' } } } },
|
||||
...errors([401]),
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/style-guide': {
|
||||
get: {
|
||||
summary: 'Redaktionsleitfaden abrufen',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const skill = readFileSync(join(process.cwd(), 'docs', 'skill.md'), 'utf8')
|
||||
|
||||
function routes(): string[] {
|
||||
const found: string[] = []
|
||||
|
||||
function walk(dir: string, url: string) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
|
||||
if (statSync(path).isDirectory()) {
|
||||
walk(path, `${url}/${name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'route.ts') {
|
||||
found.push(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(join(process.cwd(), 'src', 'app', 'api', 'v1'), '/api/v1')
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
describe('Skill für Claude', () => {
|
||||
it('trägt Frontmatter mit Namen und Beschreibung', () => {
|
||||
const head = skill.split('---')[1] ?? ''
|
||||
|
||||
expect(head).toMatch(/\nname: logbuch\n/u)
|
||||
expect(head).toMatch(/\ndescription: .{40,}/u)
|
||||
})
|
||||
|
||||
it('nennt nur Endpunkte, die es gibt', () => {
|
||||
const known = new Set(routes().map(url => url.replace(/\/\[[^\]]+\]/gu, '')))
|
||||
const mentioned = [...skill.matchAll(/`?(?:GET|POST|PUT|PATCH|DELETE) (\/api\/v1[a-z0-9./-]*)/giu)]
|
||||
.map(match => match[1]!.replace(/\/(?::[a-z]+|<[a-z-]+>)/giu, '').replace(/\/$/u, ''))
|
||||
|
||||
expect(mentioned.length).toBeGreaterThan(5)
|
||||
|
||||
for (const url of new Set(mentioned)) {
|
||||
expect(known.has(url), `${url} steht im Skill, aber nicht im Code`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('hält sich an die Hausregeln', () => {
|
||||
expect(skill).not.toMatch(/[—–]/u)
|
||||
expect(skill).not.toMatch(/\p{Extended_Pictographic}/u)
|
||||
expect(skill).not.toMatch(/\b(fuer|ueber|koennen|muessen|loeschen|aendern|groesser|gehoert|laesst|veroeffentlich\w*)\b/iu)
|
||||
})
|
||||
|
||||
it('sagt, dass ein Zugang nicht veröffentlichen kann', () => {
|
||||
expect(skill).toMatch(/niemals veröffentlichen/u)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user