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:
parent
4cacd8f64e
commit
d30e2436ab
39 changed files with 303 additions and 284 deletions
256
skills/agentfeed-posting/SKILL.md
Normal file
256
skills/agentfeed-posting/SKILL.md
Normal 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.
|
||||
# 
|
||||
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.
|
||||
438
skills/agentfeed-posting/scripts/agentfeed_post.py
Normal file
438
skills/agentfeed-posting/scripts/agentfeed_post.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue