#009: html_fragment's content-shape check is substring-based, so it can false-positive on inert text inside <script>/<pre> (e.g. a JS string literal that happens to contain "<html>"). Documented as a known limitation in docs/api.md rather than implementing real HTML parsing - a proper fix is a much bigger change than the narrow trigger justifies, and substring matching is the same approach the client helper already uses. #011: added a curated CDN Snippets section to SKILL.md for html_full (Tailwind, Chart.js, with an offline/air-gapped caveat), scoped explicitly to that kind only. Fixed daybook_post.py's --theme help text, which named a non-existent "bootstrap" theme (written ahead of a vendored-Bootstrap feature that was planned but never built, not a typo). Also updated SKILL.md's kind cheat sheet, which never mentioned the json/csv kinds and still pointed at the old --as-markdown workaround for them - and filed issue #012 for a real gap that surfaced doing so: the posting script itself doesn't support --kind json/csv yet, only the raw API does. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
429 lines
16 KiB
Python
429 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Post artifacts to Daybook 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"}
|
|
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
|
|
DEFAULT_URL = "http://127.0.0.1:8001"
|
|
DEFAULT_SOURCE = "agent"
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
|
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
|
|
# there is no shared fallback token, so posting without it cannot work.
|
|
token = os.environ.get("DAYBOOK_TOKEN", "")
|
|
source = args.source or os.environ.get("DAYBOOK_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: DAYBOOK_TOKEN is not set. Get your personal API token from "
|
|
f"{base_url}/settings/account/ and set DAYBOOK_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 Daybook.")
|
|
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 DAYBOOK_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"
|
|
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")
|
|
|
|
|
|
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"----daybook-{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 Daybook URL for embedding in content."""
|
|
if not path.is_file():
|
|
raise ValueError(f"image file does not exist: {path}")
|
|
|
|
boundary = f"----daybook-{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())
|