Add sandbox template image with Claude configuration and plugins
build / build (push) Canceled after 0s
build / build (push) Canceled after 0s
Carries CLAUDE.md, AGENTS.md, hooks and skills verbatim from the host, plus a manifest of the 10 marketplaces and 17 plugins to reinstall at build time. The plugin directories themselves are not committed: ~/.claude/plugins is 831 MB and sits alongside credentials and transcripts, so the image is reproduced from the manifest instead and the build needs no access to the host. The Gitea registry is behind Cloudflare, which rejects request bodies over 100 MB against a base image with a 325 MB layer, so the workflow pushes chunked through regctl rather than docker push. sbx v0.37.0 and v0.37.1 cannot consume the result: layers stacked on the base are silently dropped (docker/sbx-releases#366). The image builds and pushes correctly and is a no-op at runtime until that is fixed, so README points at 'ai:sbx setup' as the mechanism that works today.
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract plain text from a document for backlog analysis.
|
||||
|
||||
Supports: .md .markdown .rst .txt (read), .odt / .docx (zip + XML strip),
|
||||
.pdf (pdftotext if available, else pypdf/pdfplumber). Writes text to stdout.
|
||||
|
||||
Usage: extract.py <path>
|
||||
"""
|
||||
import sys, os, re, html, zipfile, shutil, subprocess
|
||||
|
||||
|
||||
def _strip_xml(xml: str, para_close=("</text:p>", "</text:h>", "</w:p>")) -> str:
|
||||
for tag in para_close:
|
||||
xml = xml.replace(tag, "\n")
|
||||
xml = re.sub(r"<[^>]+>", "", xml)
|
||||
xml = html.unescape(xml)
|
||||
lines = [ln.strip() for ln in xml.split("\n")]
|
||||
return "\n".join(ln for ln in lines if ln)
|
||||
|
||||
|
||||
def from_zip_xml(path: str, inner: str) -> str:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
return _strip_xml(z.read(inner).decode("utf-8", "ignore"))
|
||||
|
||||
|
||||
def from_pdf(path: str) -> str:
|
||||
if shutil.which("pdftotext"):
|
||||
out = subprocess.run(["pdftotext", "-layout", path, "-"],
|
||||
capture_output=True, text=True)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return out.stdout
|
||||
try:
|
||||
import pypdf
|
||||
return "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(path).pages)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import pdfplumber
|
||||
with pdfplumber.open(path) as pdf:
|
||||
return "\n".join(pg.extract_text() or "" for pg in pdf.pages)
|
||||
except Exception:
|
||||
sys.exit("PDF extraction needs `pdftotext` (poppler) or `pip install pypdf`.")
|
||||
|
||||
|
||||
def extract(path: str) -> str:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".md", ".markdown", ".rst", ".txt", ".text", ""):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
if ext == ".odt":
|
||||
return from_zip_xml(path, "content.xml")
|
||||
if ext == ".docx":
|
||||
return from_zip_xml(path, "word/document.xml")
|
||||
if ext == ".pdf":
|
||||
return from_pdf(path)
|
||||
# last resort: try as text
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except UnicodeDecodeError:
|
||||
sys.exit(f"Unsupported binary format: {ext}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: extract.py <path>")
|
||||
if not os.path.isfile(sys.argv[1]):
|
||||
sys.exit(f"no such file: {sys.argv[1]}")
|
||||
sys.stdout.write(extract(sys.argv[1]))
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vikunja backlog helper for the sliders/feature-category model.
|
||||
|
||||
Idempotently ensures the 8 feature-category projects + framework labels exist
|
||||
under the root project, and creates tasks from an approved JSON breakdown
|
||||
(deduping, and working around the mark-done-zeros-priority quirk).
|
||||
|
||||
Auth/config (environment):
|
||||
VIKUNJA_API_KEY required — long-lived API token
|
||||
VIKUNJA_BASE_URL default https://tasks.mroberts.dev/api/v1
|
||||
VIKUNJA_ROOT_PROJECT default CareEvolution
|
||||
|
||||
Subcommands:
|
||||
ensure create missing category projects + acute/diminishing labels
|
||||
projects list category projects (name -> id)
|
||||
labels list labels (name -> id)
|
||||
create --file T.json create tasks from an approved breakdown (see --help)
|
||||
|
||||
Tasks JSON: a list of objects:
|
||||
{category, title, description, priority, labels:[...], done?, percent?}
|
||||
`category` must be one of the 8 exact names. Missing labels are created.
|
||||
Duplicates (same title + category + deployment labels) are skipped.
|
||||
"""
|
||||
import sys, os, json, argparse, urllib.request, urllib.error
|
||||
|
||||
BASE = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.mroberts.dev/api/v1")
|
||||
ROOT_NAME = os.environ.get("VIKUNJA_ROOT_PROJECT", "CareEvolution")
|
||||
|
||||
CATEGORIES = [
|
||||
"End-user experience",
|
||||
"Admin & operator experience",
|
||||
"Developer experience & adoption",
|
||||
"Data, integrations & ecosystem",
|
||||
"Performance & scale",
|
||||
"Reliability & observability",
|
||||
"Security, privacy & compliance",
|
||||
"Cost efficiency",
|
||||
]
|
||||
FRAMEWORK_LABELS = ["acute", "diminishing"]
|
||||
|
||||
|
||||
def _key():
|
||||
k = os.environ.get("VIKUNJA_API_KEY")
|
||||
if not k:
|
||||
sys.exit("VIKUNJA_API_KEY not set")
|
||||
return k
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, method=method,
|
||||
headers={"Authorization": "Bearer " + _key(),
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return resp.status, json.loads(resp.read() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:300]
|
||||
|
||||
|
||||
def get(path):
|
||||
return call("GET", path)[1]
|
||||
|
||||
|
||||
def root_id():
|
||||
for p in get("/projects") or []:
|
||||
if p["title"] == ROOT_NAME and p.get("parent_project_id") in (0, None):
|
||||
return p["id"]
|
||||
sys.exit(f"root project {ROOT_NAME!r} not found")
|
||||
|
||||
|
||||
def all_labels():
|
||||
return {l["title"]: l["id"] for l in (get("/labels") or [])}
|
||||
|
||||
|
||||
def category_projects():
|
||||
rid = root_id()
|
||||
return {p["title"]: p["id"] for p in (get("/projects") or [])
|
||||
if p.get("parent_project_id") == rid and p["title"] in CATEGORIES}
|
||||
|
||||
|
||||
def ensure_label(name, cache):
|
||||
if name in cache:
|
||||
return cache[name]
|
||||
s, l = call("PUT", "/labels", {"title": name})
|
||||
if not isinstance(l, dict):
|
||||
sys.exit(f"failed creating label {name!r}: {l}")
|
||||
cache[name] = l["id"]
|
||||
return l["id"]
|
||||
|
||||
|
||||
def cmd_ensure(_):
|
||||
rid = root_id()
|
||||
existing = category_projects()
|
||||
cats = dict(existing)
|
||||
for c in CATEGORIES:
|
||||
if c not in cats:
|
||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
||||
if not isinstance(p, dict):
|
||||
sys.exit(f"failed creating project {c!r}: {p}")
|
||||
cats[c] = p["id"]
|
||||
print(f"created project {c!r} -> {p['id']}", file=sys.stderr)
|
||||
labels = all_labels()
|
||||
for n in FRAMEWORK_LABELS:
|
||||
ensure_label(n, labels)
|
||||
print(json.dumps({"root": rid, "categories": cats, "labels": labels}, indent=2))
|
||||
|
||||
|
||||
def cmd_projects(_):
|
||||
print(json.dumps(category_projects(), indent=2))
|
||||
|
||||
|
||||
def cmd_labels(_):
|
||||
print(json.dumps(all_labels(), indent=2))
|
||||
|
||||
|
||||
def _deployment_labels(labels):
|
||||
return {l for l in labels if l not in FRAMEWORK_LABELS}
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
tasks = json.load(open(args.file))
|
||||
if not isinstance(tasks, list):
|
||||
sys.exit("tasks JSON must be a list")
|
||||
rid = root_id()
|
||||
cats = category_projects()
|
||||
# ensure all referenced categories exist
|
||||
for t in tasks:
|
||||
c = t["category"]
|
||||
if c not in CATEGORIES:
|
||||
sys.exit(f"unknown category {c!r} (must be one of the 8)")
|
||||
if c not in cats:
|
||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
||||
cats[c] = p["id"]
|
||||
label_cache = all_labels()
|
||||
# cache existing tasks per category for dedup
|
||||
existing = {}
|
||||
for c, pid in cats.items():
|
||||
existing[c] = get(f"/projects/{pid}/tasks") or []
|
||||
|
||||
created, skipped = [], []
|
||||
for t in tasks:
|
||||
c = t["category"]
|
||||
pid = cats[c]
|
||||
title = t["title"]
|
||||
want_dep = _deployment_labels(t.get("labels", []))
|
||||
dup = False
|
||||
for ex in existing[c]:
|
||||
if ex["title"].strip().lower() != title.strip().lower():
|
||||
continue
|
||||
ex_labels = {l["title"] for l in (ex.get("labels") or [])}
|
||||
if want_dep.issubset(ex_labels):
|
||||
dup = True
|
||||
break
|
||||
if dup:
|
||||
skipped.append((title, c, sorted(want_dep)))
|
||||
continue
|
||||
prio = int(t.get("priority", 0))
|
||||
s, task = call("PUT", f"/projects/{pid}/tasks", {
|
||||
"title": title,
|
||||
"description": (t.get("description") or "").replace("\n", "<br>"),
|
||||
"priority": prio,
|
||||
})
|
||||
if not isinstance(task, dict):
|
||||
sys.exit(f"create failed for {title!r}: {task}")
|
||||
tid = task["id"]
|
||||
for ln in t.get("labels", []):
|
||||
call("PUT", f"/tasks/{tid}/labels", {"label_id": ensure_label(ln, label_cache)})
|
||||
done = bool(t.get("done"))
|
||||
pct = int(t.get("percent", 0))
|
||||
if done or pct:
|
||||
# re-send priority: the update replaces the task and would zero it
|
||||
call("POST", f"/tasks/{tid}", {
|
||||
"done": done, "percent_done": pct / 100, "priority": prio})
|
||||
existing[c].append({"title": title,
|
||||
"labels": [{"title": x} for x in t.get("labels", [])]})
|
||||
created.append((tid, title, c, prio, sorted(want_dep), "done" if done else (f"{pct}%" if pct else "")))
|
||||
|
||||
print(f"created {len(created)} tasks, skipped {len(skipped)} duplicate(s)", file=sys.stderr)
|
||||
for r in created:
|
||||
print(f" + #{r[0]} [{r[3]}] {r[2]}: {r[1]} {r[4]} {r[5]}", file=sys.stderr)
|
||||
for r in skipped:
|
||||
print(f" = skip {r[1]}: {r[0]} {r[2]}", file=sys.stderr)
|
||||
print(json.dumps({"created": [r[0] for r in created],
|
||||
"skipped": len(skipped)}))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("ensure", help="create missing category projects + labels")
|
||||
sub.add_parser("projects", help="list category projects")
|
||||
sub.add_parser("labels", help="list labels")
|
||||
c = sub.add_parser("create", help="create tasks from approved JSON")
|
||||
c.add_argument("--file", required=True, help="path to tasks JSON (list)")
|
||||
args = ap.parse_args()
|
||||
{"ensure": cmd_ensure, "projects": cmd_projects,
|
||||
"labels": cmd_labels, "create": cmd_create}[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user