#!/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, bootstrap.")
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 "]*>(.*?)", text, re.IGNORECASE | re.DOTALL)
if match and match.group(1).strip():
return match.group(1).strip()
match = re.search(r"
]*>(.*?)
", 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 " blocks, not Markdown or unwrapped headings")
if kind == "html_fragment" and (" (and ideally )")
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""
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())