Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,18 +370,29 @@ ceki contract history <eid> # audit history
ceki contract create <cid> --label "X" [--status N] [--type N] \
[--kal-schedule N] [--start ..] [--end ..] [--date ..] \
[--duration N] [--amount N] [--currency USD] \
[--benefitable agent:8|user:61] [--desc ".."]
[--benefitable agent:8|user:61] [--desc ".."] [--file PATH]...
ceki contract comment <eid> --label ".." [--status N] [--duration N] \
[--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."]
[--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."] \
[--file PATH]...
ceki contract propose <eid> [--status N] [--label ..] [--desc ..] \
[--duration N] [--amount N] [--currency USD] [--benefitable agent:8]
[--duration N] [--amount N] [--currency USD] [--benefitable agent:8] \
[--file PATH]...
ceki contract edit <eid> [--status N] [--label ..] [--desc ..] [--file PATH]...
ceki contract progress <eid> [--status N] --desc ".." [--file PATH]...
ceki contract upload-file PATH [--filename NAME] [--mime TYPE]
ceki contract vote <eid> --ids 1,2 --vote true|false
ceki contract poll # single tick (returns [] on 429)
ceki contract watch [sec] # continuous (min 6s, 10/min/token)
ceki contract tools # list available MCP tools
ceki contract raw <tool> '<json-args>' # call any tool directly
```

`--file PATH` (repeatable) attaches a file to the task / comment / correction:
the SDK uploads it to the backend (MCP `upload-file`) and attaches the returned
`user_files.id` to the event. `ceki contract upload-file PATH` uploads a single
file and prints its `{id, url}` record. Files also render in markdown as
`![](url)` — the `url` appears in the event payload under `files[].url`.

#### Environment

| Variable | Meaning |
Expand Down
69 changes: 69 additions & 0 deletions ceki_sdk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,7 @@ def _cmd_contract(args: argparse.Namespace) -> int:
qa=args.qa,
participants=extra_parts or None,
tags=tags,
files=args.file or None,
))
elif action == "comment":
# `--label` → label (short header), `--desc` → description
Expand Down Expand Up @@ -1023,6 +1024,7 @@ def _cmd_contract(args: argparse.Namespace) -> int:
amount=args.amount,
currency=args.currency,
benefitable=args.benefitable,
files=args.file or None,
))
elif action == "propose":
tags = _parse_tags(args.tags) if getattr(args, "tags", None) else None
Expand All @@ -1041,6 +1043,7 @@ def _cmd_contract(args: argparse.Namespace) -> int:
amount=args.amount,
currency=args.currency,
benefitable=args.benefitable,
files=args.file or None,
settings=settings,
))
elif action == "edit":
Expand All @@ -1060,13 +1063,21 @@ def _cmd_contract(args: argparse.Namespace) -> int:
amount=args.amount,
currency=args.currency,
benefitable=args.benefitable,
files=args.file or None,
settings=settings,
))
elif action == "progress":
_contract_dump(cli.progress(
args.eid,
status=args.status,
desc=args.desc,
files=args.file or None,
))
elif action == "upload-file":
_contract_dump(cli.upload_file(
args.path,
filename=args.filename,
mime=args.mime,
))
elif action == "vote":
ids = [int(s) for s in str(args.ids).split(",") if s.strip()]
Expand Down Expand Up @@ -1412,6 +1423,16 @@ def build_parser() -> argparse.ArgumentParser:
"'backend:Backend:#ff0000'."
),
)
p_cc.add_argument(
"--file",
action="append",
default=[],
dest="file",
help=(
"Repeatable. Local file path to attach to the task "
"(uploaded to the backend first)."
),
)

p_cco = csub.add_parser("comment", help="Post comment on event")
p_cco.add_argument("eid", type=int)
Expand All @@ -1426,6 +1447,16 @@ def build_parser() -> argparse.ArgumentParser:
p_cco.add_argument("--currency")
p_cco.add_argument("--benefitable")
p_cco.add_argument("--desc")
p_cco.add_argument(
"--file",
action="append",
default=[],
dest="file",
help=(
"Repeatable. Local file path to attach to the comment "
"(uploaded to the backend first)."
),
)

p_cp = csub.add_parser("propose", help="Propose correction")
p_cp.add_argument("eid", type=int)
Expand All @@ -1447,6 +1478,16 @@ def build_parser() -> argparse.ArgumentParser:
"'backend:Backend:#ff0000'. back/2796 persists onto the event."
),
)
p_cp.add_argument(
"--file",
action="append",
default=[],
dest="file",
help=(
"Repeatable. Local file path to attach in the correction "
"(uploaded to the backend first)."
),
)

p_edit = csub.add_parser("edit", help="Edit task (semantic sugar for propose)")
p_edit.add_argument("eid", type=int)
Expand All @@ -1468,6 +1509,16 @@ def build_parser() -> argparse.ArgumentParser:
"'backend:Backend:#ff0000'."
),
)
p_edit.add_argument(
"--file",
action="append",
default=[],
dest="file",
help=(
"Repeatable. Local file path to attach in the edit/correction "
"(uploaded to the backend first)."
),
)

p_cpr = csub.add_parser(
"progress",
Expand All @@ -1476,12 +1527,30 @@ def build_parser() -> argparse.ArgumentParser:
p_cpr.add_argument("eid", type=int)
p_cpr.add_argument("--status", type=int)
p_cpr.add_argument("--desc", required=True)
p_cpr.add_argument(
"--file",
action="append",
default=[],
dest="file",
help=(
"Repeatable. Local file path to attach to the progress comment "
"(uploaded to the backend first)."
),
)

p_cv = csub.add_parser("vote", help="Vote on correction(s)")
p_cv.add_argument("eid", type=int)
p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs")
p_cv.add_argument("--vote", required=True, help="true|false")

p_cuf = csub.add_parser(
"upload-file",
help="Upload a file, print the user_files record (id/url)",
)
p_cuf.add_argument("path", help="Local file path to upload")
p_cuf.add_argument("--filename", help="Override filename (default: basename)")
p_cuf.add_argument("--mime", help="Override MIME type (default: guessed)")

csub.add_parser("poll", help="Single agent polling tick")

p_cw = csub.add_parser("watch", help="Continuous polling")
Expand Down
101 changes: 100 additions & 1 deletion ceki_sdk/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

import base64
import json
import mimetypes
import os
import time
from pathlib import Path
from typing import Any

import httpx
Expand Down Expand Up @@ -424,6 +427,7 @@ def create(
qa: str | None = None,
participants: list[dict[str, Any]] | None = None,
tags: list[dict[str, Any]] | None = None,
files: list[int | str | Path] | None = None,
) -> Any:
# back/2542: reviewer/qa now live inside users[] (renamed from
# participants[]). Element shape unchanged. The `participants`
Expand All @@ -439,6 +443,7 @@ def create(
if participants:
users.extend(participants)

files_resolved = self._resolve_files(files)
args = _clean({
"contract_id": int(contract_id),
"label": label,
Expand All @@ -456,6 +461,7 @@ def create(
"data": data,
"benefitable": _benefitable(benefitable),
"users": users if users else None,
"files": files_resolved,
# back/3165: project tags live in events.settings.tags[]. `tags`
# is CLI/SDK sugar — a bare list of {key,label?,color?} dicts —
# emitted on the wire under the `settings` blob the backend expects.
Expand All @@ -478,6 +484,7 @@ def comment(
amount: int | None = None,
currency: str | None = None,
benefitable: str | None = None,
files: list[int | str | Path] | None = None,
) -> Any:
"""Post a comment event.

Expand All @@ -487,8 +494,12 @@ def comment(
at a word boundary – the first part goes to `label`, the remainder
to `description`. This matches backend API behavior and keeps
the UI clean without visible duplication.

`files` attaches user_files to the comment: pass user_files ids
(int) or local paths (str/Path) which are uploaded first.
"""
label_out, desc_out = _split_label_desc(label, description)
files_resolved = self._resolve_files(files)
args = _clean({
"event_id": int(event_id),
"label": label_out,
Expand All @@ -502,6 +513,7 @@ def comment(
"amount": amount,
"currency": currency,
"benefitable": _benefitable(benefitable),
"files": files_resolved,
})
return self.call(_TOOL_MAP["comment"], args)

Expand All @@ -519,9 +531,11 @@ def propose(
amount: int | None = None,
currency: str | None = None,
benefitable: str | None = None,
files: list[int | str | Path] | None = None,
settings: dict[str, Any] | None = None,
) -> Any:
label_out, desc_out = _split_label_desc(label, description)
files_resolved = self._resolve_files(files)
args = _clean({
"event_id": int(event_id),
"status_id": status_id,
Expand All @@ -534,6 +548,7 @@ def propose(
"amount": amount,
"currency": currency,
"benefitable": _benefitable(benefitable),
"files": files_resolved,
# back/2796: ProposeCorrectionTool persists settings (tags,
# reply_to, blocked_by, do_after) onto the event. Forwarded
# verbatim — only attached when the caller supplies it.
Expand All @@ -547,13 +562,17 @@ def progress(
*,
status: int | None = None,
desc: str,
files: list[int | str | Path] | None = None,
) -> dict[str, Any]:
"""Status correction (optional) + progress comment in one shot.

The event's own description is NOT touched. `--desc` becomes the
body of a child comment-event, not a label/description overwrite
on the parent event. Use this for Hand/QA/Reviewer progress
reports — `propose --desc` would clobber the parent spec.

`files` attaches user_files to the progress comment (ids or local
paths — paths are uploaded first).
"""
status_result: Any = None
if status is not None:
Expand All @@ -567,7 +586,7 @@ def progress(
# `description` is never set on a comment (the UI renders both,
# which would duplicate the body for SDK-posted comments).
label = desc if (desc or "").strip() else "progress"
comment_result = self.comment(event_id, label=label)
comment_result = self.comment(event_id, label=label, files=files)
return {"status_correction": status_result, "comment": comment_result}

def vote(self, event_id: int, ids: list[int], vote: bool) -> Any:
Expand All @@ -577,6 +596,86 @@ def vote(self, event_id: int, ids: list[int], vote: bool) -> Any:
"vote": bool(vote),
})

# ── files ────────────────────────────────────────────────────

def upload_file(
self,
file: bytes | str | Path,
*,
filename: str | None = None,
mime: str | None = None,
) -> dict[str, Any]:
"""Upload a file to the backend, returning its ``user_files`` record.

Wraps the MCP ``upload-file`` tool (base64 over JSON-RPC). The
returned ``id`` is what create()/comment()/propose() expect in
their ``files`` param (backend: ``user_files.id``).

Args:
file: Local path OR raw bytes. Paths are read from disk.
filename: Original filename with extension (defaults to the
basename of a path, or a mime-derived name for raw bytes).
mime: MIME type (e.g. image/png, application/pdf). Auto-guessed
from the filename extension when omitted; the backend sniffs
magic bytes as a final fallback.

Returns:
Dict shaped ``{"id", "name", "url", "size", "disk"}``.
"""
if isinstance(file, (str, Path)):
path = Path(file)
data = path.read_bytes()
if filename is None:
filename = path.name
else:
data = bytes(file)
if filename is None:
filename = self._default_filename(mime)

if not filename:
raise ValueError("upload_file: could not derive a filename")

if mime is None:
guessed, _ = mimetypes.guess_type(filename)
mime = guessed

b64 = base64.b64encode(data).decode()
result = self.call("upload-file", {
"file_data": b64,
"file_name": filename,
"mime_type": mime,
})
if not isinstance(result, dict) or not result.get("id"):
raise ContractError(f"upload-file returned no id: {result!r}")
return result

def _resolve_files(
self, files: list[int | str | Path] | None
) -> list[int] | None:
"""Map a files[] arg to user_files ids.

Ints pass through (already-uploaded ids); str/Path are uploaded
via upload_file() first and replaced with the returned id.
"""
if not files:
return None
ids: list[int] = []
for f in files:
if isinstance(f, int):
ids.append(int(f))
continue
up = self.upload_file(f)
fid = up.get("id")
if not fid:
raise ContractError(f"upload-file returned no id for {f!r}")
ids.append(int(fid))
return ids

@staticmethod
def _default_filename(mime: str | None) -> str:
ext = (mimetypes.guess_extension(mime or "") or ".bin").lstrip(".")
return f"file.{ext or 'bin'}"

# ── polling (REST, not MCP) ───────────────────────────────────

def poll(self) -> list[Any]:
Expand Down
Loading
Loading