v0.3.0: rename app Daybook -> AgentFeed

Removes the "Daybook" name from everything user- and agent-facing, since no
agents are configured yet and this is the cheap moment to do it.

- UI brand, docs, README, CLAUDE.md -> AgentFeed.
- Ingest env vars DAYBOOK_URL/TOKEN/SOURCE/DATA_DIR/BIND_ADDR/HOST_DATA_DIR/
  KEY_PROVISIONING_API -> AGENTFEED_* (token *values* unchanged).
- Skill: skills/daybook-posting -> skills/agentfeed-posting, daybook_post.py
  -> agentfeed_post.py (+ scripts/ wrapper and its test).
- static/daybook.css -> static/agentfeed.css; localStorage daybook.* ->
  agentfeed.*; templatetag daybook_extras -> agentfeed_extras; multipart
  boundaries; json/csv viewer element ids; daybook_exception_handler ->
  agentfeed_exception_handler; compose service/container -> agentfeed.
- Key-provisioning API now OFF by default (independent of DEBUG); enable with
  AGENTFEED_KEY_PROVISIONING_API=1 on test installs only.
- Posting helper now points at Settings -> API Keys.

Deferred on purpose (a later, separate step): the Django package
`daybook_project`, the `/opt/daybook` install path, and the Forgejo repo name.
The sibling AGENT_DAYBOOK__LLM_TEST name is intentionally untouched.

Full suite: 129 passing; live server verified serving under the new name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-04 13:10:28 +01:00
parent 4cacd8f64e
commit d30e2436ab
39 changed files with 303 additions and 284 deletions

View file

@ -0,0 +1,256 @@
---
name: agentfeed-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 a AgentFeed instance and returns a URL.
---
# AgentFeed Posting
AgentFeed 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.
You post to a **running AgentFeed server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of AgentFeed's source. The only things that tie you to a particular instance are two environment variables: `AGENTFEED_URL` (where the server is) and `AGENTFEED_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run.
## Configuration
Set these as real process/shell environment variables (`export` on
Linux/macOS, `$env:` or `set` on Windows, or your agent harness's own env
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
AGENTFEED_URL=https://your-agentfeed-instance.example.com # your server's base URL
AGENTFEED_TOKEN=your-personal-api-key # from Settings -> API Keys
AGENTFEED_SOURCE=agent # a stable label for what's posting
```
- **`AGENTFEED_URL`** — the base URL of the AgentFeed 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 AgentFeed is running
locally on the same machine (its development default). For any remote or
deployed instance you **must** set this.
- **`AGENTFEED_TOKEN`** — a personal API key, created in the app under Settings →
API Keys (name one per agent so you can revoke them individually). Not a key
shared by everyone; artifacts posted with it land only in your feed.
- **`AGENTFEED_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/agentfeed_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).
AGENTFEED_POST=/abs/path/to/this/skill/scripts/agentfeed_post.py
```
Then:
```bash
python "$AGENTFEED_POST" --check
python "$AGENTFEED_POST" --kind markdown --title "Build Report" --content-file report.md
cat report.md | python "$AGENTFEED_POST" --kind markdown --title "Build Report"
python "$AGENTFEED_POST" --file diagram.svg --title "Architecture Diagram"
```
The artifact URL is printed as the final stdout line.
(If you *are* working inside a AgentFeed source checkout, a compatibility
wrapper also exists at `scripts/agentfeed_post.py` in the repo root, but the
canonical helper is the one bundled in this skill.)
## Kind Cheat Sheet
| Kind | Use for | Content shape |
|---|---|---|
| `markdown` | Reports, briefs, notes | Markdown text. Fenced code blocks get syntax highlighting; tables (pipe syntax) render styled automatically. |
| `html_fragment` | Themed HTML reports/cards/tables | Body-only HTML. No `html/head/body`. Tables render styled automatically, same as markdown. |
| `html_full` | Complete custom pages/frameworks | Full HTML document containing `<html>` (`<!doctype>` recommended, not required). See "CDN Snippets" below. |
| `deck` | Simple slide-like artifact | Top-level `<section>...</section>` blocks. |
| `json` | Structured data, metrics, run summaries | Valid JSON text. Renders as a collapsible tree. |
| `csv` | Tabular data | CSV text, first row as header. Renders as a sortable/searchable table. `.xlsx` files convert to this automatically — see below. |
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
**HTML in `markdown`, `html_fragment`, and `deck` is sanitized** — ordinary
formatting, tables, links, and images are kept, but `<script>`, event
handlers, `<style>`, and `javascript:`/`data:` URLs are stripped. If you
need JavaScript (a chart library, an interactive widget), use `html_full`,
which is served verbatim — that's what it's for. Don't put scripts in the
themed kinds expecting them to run; they won't.
`--theme` (for `markdown`/`html_fragment` only) accepts `report` (default —
serif), `tasks`, or `base` (minimal sans-serif). They share the same
off-white background and differ mainly in typography and width.
`deck`/`html_full`/`json`/`csv` ignore `--theme` — each has its own fixed
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 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`
`html_full` is AgentFeed's escape hatch for custom pages — nothing is
injected into it, so a poster wanting Tailwind or a charting library has to
supply its own `<link>`/`<script>` tags. Known-good ones:
```html
<!-- Tailwind (Play CDN - compiles in-browser on every load; fine for a
one-off artifact, not something you'd want on a high-traffic page) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
```
**Offline caveat:** AgentFeed can run fully self-hosted/air-gapped. Any
artifact that links a CDN script won't render fully (or at all, for
Tailwind's in-browser compiler) without internet access. If the deployment
target is offline, don't reach for these — use `markdown`/`html_fragment`
instead, which are styled entirely server-side with no external requests.
**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed already
vendors Grid.js and renderjson locally (used to render `csv`/`json`
artifacts), and they're reachable as plain static files from any artifact,
including `html_full`:
```html
<link rel="stylesheet" href="/static/vendor/gridjs/mermaid.min.css">
<script src="/static/vendor/gridjs/gridjs.umd.js"></script>
<!-- or, for a collapsible JSON tree: -->
<script src="/static/vendor/renderjson/renderjson.js"></script>
```
Prefer these over an external CDN for table/JSON rendering inside a custom
`html_full` page — same libraries, but self-hosted, so they work offline
and don't depend on a third party staying up. This only covers
tables/JSON; there's no vendored equivalent for Tailwind/Chart.js, so the
CDN snippets above are still the only option for those.
Only use this for `html_full` specifically — never add CDN references to
`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the
poster, owns styling; injecting a default CDN habit into them would
undermine that.
## Images Inside Content
To embed an image *within* a markdown or HTML artifact (rather than posting it as its own
artifact), upload it first and reference the returned URL:
```bash
# 1. Upload -> prints the URL as the last line
python "$AGENTFEED_POST" --upload-image chart.png
# 2. Reference that URL in your markdown, then post as usual.
# The printed URL is absolute against your AGENTFEED_URL, e.g.
# ![chart]($AGENTFEED_URL/assets/ab12cd34ef56)
python "$AGENTFEED_POST" report.md
```
The image is linked to the artifact that references it and is deleted along with it.
Unreferenced uploads are cleaned up after 24 hours.
## CSV, JSON, And Spreadsheets
For CSV and JSON, use `--kind csv`/`--kind json` with `--content-file` — or
just post the file positionally; auto-mode detects `.csv`/`.json` and picks
the right kind automatically:
```bash
python "$AGENTFEED_POST" results.csv
python "$AGENTFEED_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 "$AGENTFEED_POST" balance_sheet.xlsx
```
The server converts it to a `csv` artifact automatically. Known
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
software (Excel, LibreOffice, etc.) — a workbook a script wrote but never
opened in a real spreadsheet app will show those cells blank.
- Rich formatting (bold, colors, merged cells) isn't preserved — it's a
plain sortable/searchable table either way.
- Legacy `.xls` isn't supported, only `.xlsx`.
## Other Code
For code that isn't CSV/JSON but should render today rather than download
as a binary, use `--file path --as-markdown` to wrap it in a Markdown
fenced code block (still gets syntax highlighting):
```bash
python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script"
```
Without `--as-markdown`, it uploads as a downloadable binary file instead.
## Source & Provenance Fields
All optional, all default to something reasonable if omitted:
| Flag | Default | Notes |
|---|---|---|
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". |
| `--source-id` | Empty string | A stable external ID, if the poster has one. |
| `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. |
Use `--meta key=value` (repeatable) or `--meta-json '{"key": "value"}'`
(merged together if both are given) for structured, machine-readable
context like `run_id` or `model` — these end up in the artifact's
`metadata` field, distinct from tags.
## Rules
- Run `--check` first if unsure AgentFeed is reachable.
- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real.
- Use a stable `AGENTFEED_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 "$AGENTFEED_URL/api/artifacts" \
-H "Authorization: Bearer $AGENTFEED_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 "$AGENTFEED_URL/api/artifacts" \
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
-F "file=@diagram.png"
```
`GET $AGENTFEED_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.

View file

@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""Post artifacts to AgentFeed without shell-specific JSON or multipart quoting.
Zero third-party dependencies. Content comes from a file or stdin so agents do not
need to inline multiline JSON/HTML/Markdown in shell commands.
"""
import argparse
import json
import mimetypes
import os
import re
import sys
import uuid
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck", "json", "csv"}
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm", ".json", ".csv"}
DEFAULT_URL = "http://127.0.0.1:8001"
DEFAULT_SOURCE = "agent"
def main():
args = parse_args()
base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/")
# AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app);
# there is no shared fallback token, so posting without it cannot work.
token = os.environ.get("AGENTFEED_TOKEN", "")
source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE)
if args.check:
try:
response = request_json("GET", f"{base_url}/api/capabilities", token)
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
return 0
if not token:
print(
"error: AGENTFEED_TOKEN is not set. Get your personal API token from "
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
file=sys.stderr,
)
return 2
if args.upload_image:
try:
response = upload_asset(base_url, token, Path(args.upload_image))
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
print(response.get("url", ""))
return 0
try:
if args.path:
resolve_auto_path(args)
payload = build_payload(args, source)
validate_payload(payload, args)
if args.dry_run:
print_dry_run(base_url, token, payload, args)
return 0
response = post_payload(base_url, token, payload, args)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
print(response.get("url", ""))
return 0
def parse_args():
parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.")
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_SOURCE or 'agent'.")
parser.add_argument("--content-file", help="Read text artifact content from this file.")
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
parser.add_argument("--tags", help="Comma-separated tags.")
parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, base. See docs/api.md for the current list.")
parser.add_argument("--meta", action="append", default=[], help="Metadata key=value. Repeatable.")
parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.")
parser.add_argument("--source-name", help="Human-readable source name. Defaults to --source.")
parser.add_argument("--source-id", default="", help="Stable source identifier.")
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.")
parser.add_argument("--upload-image", help="Upload an image for embedding in artifact content. Prints the URL to reference in markdown/HTML.")
parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.")
parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.")
args = parser.parse_args()
if args.check or args.upload_image:
return args
if args.path:
if args.file or args.content_file:
parser.error("provide a positional path OR --file/--content-file, not both")
return args # kind and title are inferred in resolve_auto_path
if not args.title:
parser.error("--title is required (or pass a positional path to infer it, or use --check)")
if args.file and args.content_file:
parser.error("use either --file or --content-file, not both")
if args.file and args.kind and not args.as_markdown:
parser.error("omit --kind when using --file unless --as-markdown is set")
if not args.file and not args.kind:
parser.error("--kind is required for text artifacts")
return args
def resolve_auto_path(args):
"""Auto mode: turn a single positional path into the equivalent explicit args.
Text files (.md/.html/.txt) become a text artifact with a sniffed kind; every
other file is uploaded as multipart with an inferred content type. Title is
inferred from the content or the filename when not given. This is the path that
keeps agents from ever building JSON or choosing a kind by hand.
"""
path = Path(args.path)
if not path.is_file():
raise ValueError(f"path does not exist: {path}")
suffix = path.suffix.lower()
if suffix in TEXT_SUFFIXES and not args.as_markdown:
text = path.read_text(encoding="utf-8")
kind = sniff_text_kind(suffix, text)
args.content_file = str(path)
args.kind = kind
if not args.title:
args.title = derive_title(path, kind, text)
else:
# image / pdf / binary -> multipart upload; code/data -> markdown wrap if asked
args.file = str(path)
args.kind = None
if not args.title:
args.title = humanize(path.stem)
return args
def sniff_text_kind(suffix, text):
if suffix in (".md", ".markdown", ".txt"):
return "markdown"
if suffix == ".json":
return "json"
if suffix == ".csv":
return "csv"
lower = text.lower()
if "<!doctype" in lower or "<html" in lower:
return "html_full"
if "<section" in lower:
return "deck"
return "html_fragment"
def derive_title(path, kind, text):
if kind == "markdown":
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
else:
match = re.search(r"<title[^>]*>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
if match and match.group(1).strip():
return match.group(1).strip()
match = re.search(r"<h1[^>]*>(.*?)</h1>", text, re.IGNORECASE | re.DOTALL)
if match:
inner = re.sub(r"<[^>]+>", "", match.group(1)).strip()
if inner:
return inner
return humanize(path.stem)
def humanize(stem):
cleaned = stem.replace("-", " ").replace("_", " ").strip()
return cleaned.title() if cleaned else stem
def build_payload(args, source):
metadata = parse_metadata(args)
common = {
"source": source,
"title": args.title,
"tags": normalize_tags(args.tags),
"source_type": args.source_type,
"source_name": args.source_name or source,
"source_id": args.source_id,
"source_prompt": args.source_prompt,
"metadata": metadata,
}
if args.file and not args.as_markdown:
file_path = Path(args.file)
if not file_path.is_file():
raise ValueError(f"upload file does not exist: {file_path}")
return {**common, "upload_path": file_path}
kind = args.kind or "markdown"
if args.file and args.as_markdown:
file_path = Path(args.file)
if not file_path.is_file():
raise ValueError(f"content file does not exist: {file_path}")
content = markdown_from_file(file_path)
kind = "markdown"
else:
content = read_text_content(args.content_file)
payload = {**common, "kind": kind, "content": content}
if args.theme:
payload["theme"] = args.theme
return payload
def parse_metadata(args):
metadata = {}
if args.meta_json:
try:
metadata = json.loads(args.meta_json)
except json.JSONDecodeError as exc:
raise ValueError(f"--meta-json is invalid JSON: {exc}") from exc
if not isinstance(metadata, dict):
raise ValueError("--meta-json must be a JSON object")
for item in args.meta:
if "=" not in item:
raise ValueError(f"--meta must be key=value, got: {item}")
key, value = item.split("=", 1)
if not key:
raise ValueError("--meta key cannot be blank")
metadata[key] = value
return metadata
def normalize_tags(tags):
if not tags:
return []
return [tag.strip() for tag in tags.split(",") if tag.strip()]
def read_text_content(content_file):
if content_file:
path = Path(content_file)
if not path.is_file():
raise ValueError(f"content file does not exist: {path}")
return path.read_text(encoding="utf-8")
if sys.stdin.isatty():
raise ValueError("provide --content-file, --file, or pipe content on stdin")
return sys.stdin.read()
def markdown_from_file(path):
content = path.read_text(encoding="utf-8")
language = language_for_suffix(path.suffix)
return f"# {path.name}\n\n```{language}\n{content}\n```\n"
def language_for_suffix(suffix):
return {
".csv": "csv",
".json": "json",
".js": "javascript",
".ts": "typescript",
".py": "python",
".ps1": "powershell",
".sh": "bash",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".c": "c",
".h": "c",
".hpp": "cpp",
".html": "html",
".css": "css",
".xml": "xml",
".svg": "xml",
".md": "markdown",
".txt": "text",
}.get(suffix.lower(), "text")
def validate_payload(payload, args):
if "upload_path" in payload:
return
kind = payload["kind"]
content_lower = payload["content"].lower()
if kind == "deck" and "<section" not in content_lower:
raise ValueError("deck needs top-level <section>...</section> blocks, not Markdown or unwrapped headings")
if kind == "html_fragment" and ("<html" in content_lower or "<!doctype" in content_lower or "<body" in content_lower):
raise ValueError("html_fragment must be body-only HTML; do not include doctype/html/head/body")
if kind == "html_full" and "<html" not in content_lower:
raise ValueError("html_full must be a complete HTML document with <html> (and ideally <!doctype html>)")
if kind == "markdown" and not payload["content"].strip():
raise ValueError("markdown content cannot be blank")
if kind == "json":
try:
json.loads(payload["content"])
except json.JSONDecodeError as exc:
raise ValueError(f"json content must be valid JSON: {exc}") from exc
def print_dry_run(base_url, token, payload, args):
target = f"{base_url}/api/artifacts"
print(f"POST {target}")
print(f"Authorization: Bearer {mask(token)}")
if "upload_path" in payload:
path = payload["upload_path"]
print("Content-Type: multipart/form-data")
print(f"file: {path} ({content_type_for(path)})")
printable = {key: value for key, value in payload.items() if key != "upload_path"}
print(json.dumps(printable, indent=2, default=str))
else:
print("Content-Type: application/json")
printable = dict(payload)
printable["content"] = f"<content length={len(payload['content'])}>"
print(json.dumps(printable, indent=2))
def mask(token):
if len(token) <= 8:
return "***"
return f"{token[:4]}...{token[-4:]}"
def post_payload(base_url, token, payload, args):
url = f"{base_url}/api/artifacts"
if "upload_path" in payload:
body, content_type = multipart_body(payload)
request = Request(url, data=body, method="POST")
request.add_header("Content-Type", content_type)
else:
body = json.dumps(payload).encode("utf-8")
request = Request(url, data=body, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("Authorization", f"Bearer {token}")
request.add_header("Accept", "application/json")
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
def multipart_body(payload):
boundary = f"----agentfeed-{uuid.uuid4().hex}"
chunks = []
upload_path = payload["upload_path"]
for key, value in payload.items():
if key == "upload_path":
continue
if key == "metadata":
if not value:
continue
value = json.dumps(value)
elif key == "tags":
if not value:
continue
value = ",".join(value)
elif value in (None, ""):
continue
chunks.append(f"--{boundary}\r\n".encode("utf-8"))
chunks.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8"))
chunks.append(str(value).encode("utf-8"))
chunks.append(b"\r\n")
chunks.append(f"--{boundary}\r\n".encode("utf-8"))
chunks.append(
f'Content-Disposition: form-data; name="file"; filename="{upload_path.name}"\r\n'.encode("utf-8")
)
chunks.append(f"Content-Type: {content_type_for(upload_path)}\r\n\r\n".encode("utf-8"))
chunks.append(upload_path.read_bytes())
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def content_type_for(path):
suffix = path.suffix.lower()
if suffix == ".svg":
return "image/svg+xml"
if suffix == ".pdf":
return "application/pdf"
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
def upload_asset(base_url, token, path):
"""Upload an image and return its AgentFeed URL for embedding in content."""
if not path.is_file():
raise ValueError(f"image file does not exist: {path}")
boundary = f"----agentfeed-{uuid.uuid4().hex}"
chunks = [
f"--{boundary}\r\n".encode("utf-8"),
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
f"Content-Type: {content_type_for(path)}\r\n\r\n".encode("utf-8"),
path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode("utf-8"),
]
request = Request(f"{base_url}/api/assets", data=b"".join(chunks), method="POST")
request.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
request.add_header("Authorization", f"Bearer {token}")
request.add_header("Accept", "application/json")
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
def request_json(method, url, token=""):
request = Request(url, method=method)
request.add_header("Accept", "application/json")
if token:
request.add_header("Authorization", f"Bearer {token}")
with urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
def print_http_error(exc):
if isinstance(exc, HTTPError):
body = exc.read().decode("utf-8", errors="replace")
print(f"error: HTTP {exc.code} {exc.reason}", file=sys.stderr)
if body:
print(body, file=sys.stderr)
else:
print(f"error: {exc}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())