The image is published to a public registry, and the skill named the employer as its Vikunja root project and pointed at tasks.mroberts.dev. Neither is a secret, but neither belongs in an artefact anyone can pull. It is a host-side planning skill with nothing to do inside a sandbox, so removing it costs the sandbox no capability.
This commit is contained in:
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: vikunja-backlog
|
||||
description: Use when you have a brainstorming, design, discussion, meeting-notes, or specification document (any format — md, txt, pdf, docx, odt) and want it turned into Vikunja backlog tasks under the "sliders, not checkboxes" feature-category planning model at tasks.mroberts.dev.
|
||||
---
|
||||
|
||||
# Vikunja Backlog From a Document
|
||||
|
||||
## Overview
|
||||
|
||||
Turn a discussion/design/brainstorming document into Vikunja tasks using the
|
||||
**"Sliders, Not Checkboxes"** framework: work is organized by **feature
|
||||
category** (the project), each item is restated as the **need** it addresses
|
||||
(the task title), the proposed solution lives in the description, and value is
|
||||
expressed as **priority** (rank within the category).
|
||||
|
||||
The mapping and the 8 categories are the contract. Read
|
||||
[reference/sliders-framework.md](reference/sliders-framework.md) before
|
||||
classifying — it defines each category, the need-vs-solution discipline, and
|
||||
the priority/label rules.
|
||||
|
||||
**Core principle:** the title is the *need*, never the solution. "Limit a
|
||||
compromised container's blast radius", not "add read-only rootfs".
|
||||
|
||||
## When to Use
|
||||
|
||||
- You have a doc (any format) describing work, decisions, or a backlog and want
|
||||
it in Vikunja.
|
||||
- Symptoms: a design spec, meeting notes, a brainstorm, a PR/backlog writeup, a
|
||||
Slack/email dump pasted into a file.
|
||||
|
||||
Not for: editing tasks one-off (use the API directly), or non-planning docs.
|
||||
|
||||
## Object Mapping (the contract)
|
||||
|
||||
| Framework concept | Vikunja object |
|
||||
|---|---|
|
||||
| Feature category | **Project** (child of the root `CareEvolution` project) |
|
||||
| Candidate item | **Task** |
|
||||
| Need | Task **title** (phrased as the goal) |
|
||||
| Solution(s) + source | Task **description** |
|
||||
| Value rank within category | Task **priority** (1–4) |
|
||||
| Urgency / returns | Label `acute` / `diminishing` |
|
||||
| Platform | Label (e.g. `Orchestrate`) |
|
||||
| Deployment / repo | Label (e.g. `Rosetta`) |
|
||||
|
||||
The same task viewed by **project** = cross-deployment value lens; filtered by
|
||||
**deployment label** = per-repo execution lens (one dataset, many views).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Extract** the doc text:
|
||||
`scripts/extract.py <path>` (handles md/txt/pdf/docx/odt → stdout).
|
||||
2. **Analyze** the text. Pull out candidate items. For each, restate the
|
||||
**need** (what the user/admin/dev is trying to accomplish), and capture the
|
||||
proposed **solution(s)** separately. Discard pure discussion that isn't a
|
||||
candidate. See the framework reference for the need-restatement discipline.
|
||||
3. **Classify** each need into exactly one of the 8 categories. Borderline
|
||||
items: pick the category of the *primary value*, note the alternative in the
|
||||
description.
|
||||
4. **Rank** — assign a priority (4 = most acute/frequent/broad … 1 = small/late
|
||||
on the curve) reflecting value *within its category*, not build effort. Tag
|
||||
`acute` / `diminishing` per the reference rules.
|
||||
5. **Tag platform + deployment.** Infer the platform (tag) and deployment(s)
|
||||
(label) from the doc, then **confirm/override with the user** before
|
||||
creating. If a doc spans several deployments and granularity is "per
|
||||
deployment", emit one task per (need × deployment).
|
||||
6. **Propose, then create.** Present the full breakdown as a table
|
||||
(need | category | priority | labels | done?). Get the user's approval/edits.
|
||||
**Do not create anything before approval.**
|
||||
7. On approval, write the approved tasks to a JSON file and run
|
||||
`scripts/vikunja.py create --file <tasks.json>`. It idempotently ensures the
|
||||
8 category projects + labels exist, creates missing labels, skips duplicates,
|
||||
and handles the done-priority gotcha.
|
||||
8. **Report** created task IDs and the view hints above.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/extract.py <path>` — extract plain text from md/txt/pdf/docx/odt.
|
||||
- `scripts/vikunja.py ensure` — idempotently create the 8 category projects +
|
||||
`acute`/`diminishing` labels under `CareEvolution`. Prints name→id maps.
|
||||
- `scripts/vikunja.py projects` / `labels` — list current ids.
|
||||
- `scripts/vikunja.py create --file <tasks.json>` — create tasks. Run `--help`
|
||||
for the JSON schema and all flags.
|
||||
|
||||
Auth: reads `VIKUNJA_API_KEY` from the environment. Base URL defaults to
|
||||
`https://tasks.mroberts.dev/api/v1` (`VIKUNJA_BASE_URL` overrides); root project
|
||||
defaults to `CareEvolution` (`VIKUNJA_ROOT_PROJECT` overrides).
|
||||
|
||||
## Tasks JSON schema
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"category": "Security, privacy & compliance",
|
||||
"title": "Limit a compromised container's runtime blast radius",
|
||||
"description": "**Need:** ...\n\n**Solution:** ...\n\n**Source:** doc.md",
|
||||
"priority": 4,
|
||||
"labels": ["Orchestrate", "Rosetta", "acute"],
|
||||
"done": false,
|
||||
"percent": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`category` must be one of the 8 exact names (see reference). `labels` are
|
||||
created if missing. `done`/`percent` optional.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Title is the solution, not the need.** "Add Dependabot auto-merge" → wrong.
|
||||
"Stay patched with less manual toil" → right; Dependabot goes in the body.
|
||||
- **Priority = build effort.** No. Priority = value of the need within its
|
||||
category. A cheap rank-1 outranks an expensive rank-6.
|
||||
- **Creating before approval.** The framework's whole point is the judgment
|
||||
call on need + category + rank. Always propose first.
|
||||
- **Re-running creates duplicates.** `create` dedupes on (title + category +
|
||||
deployment labels); don't bypass it with ad-hoc API calls.
|
||||
- **Mark-done zeros priority.** The Vikunja update replaces the task; `create`
|
||||
re-sends priority on the done update. Don't hand-roll the done call.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Sliders, Not Checkboxes — classification reference
|
||||
|
||||
The framework models work as a small set of **feature categories**. A backlog
|
||||
item is restated as the **need** it addresses (not the proposed solution), and
|
||||
needs are value-ranked within each category. Source: the "Sliders, Not
|
||||
Checkboxes" paper (CareEvolution).
|
||||
|
||||
## The need-restatement discipline
|
||||
|
||||
Before an item enters a category, restate it as the user/admin/developer
|
||||
**need** — what they are trying to accomplish — and treat the proposed solution
|
||||
as *one way* to address it.
|
||||
|
||||
- A doc says "build a self-serve provisioning UI." The **need** is "add/remove a
|
||||
user without filing a support ticket." The UI is one solution; SCIM or a
|
||||
declarative API are others. Title = the need; list the candidate solutions in
|
||||
the description.
|
||||
- Ranking reflects what addressing the need means for the people who hold it:
|
||||
**how often** it's hit, **how acute** the pain, **how broadly** held. Not what
|
||||
is easiest to build or most recently requested.
|
||||
- Discussion, debate, and context that isn't a candidate item → do not create a
|
||||
task. Capture only genuine candidate needs.
|
||||
|
||||
## The 8 feature categories
|
||||
|
||||
Classify each need into exactly one. Pick the category of the **primary value**;
|
||||
note a close alternative in the description.
|
||||
|
||||
| Category (use this exact name) | What it covers | Whose need |
|
||||
|---|---|---|
|
||||
| **End-user experience** | User-facing product: features, flows, learnability, polish | People using the front-end to do their own tasks |
|
||||
| **Admin & operator experience** | Configuration, governance, monitoring, operational tooling | Customer-side admins / IT staff deploying & overseeing the product |
|
||||
| **Developer experience & adoption** | API design, SDKs, docs, sandbox, time-to-first-call; community, mindshare | Developers integrating against or building on the product |
|
||||
| **Data, integrations & ecosystem** | Data quality, lineage, connectors, standards conformance (FHIR, HL7, OAuth, OpenAPI), marketplace, partners | Integrating systems and partner platforms |
|
||||
| **Performance & scale** | Latency, throughput, behavior under load | Anyone depending on the product being fast and handling their volume |
|
||||
| **Reliability & observability** | Uptime, fault tolerance, graceful degradation, recovery; logs, metrics, traces, alerting, runbooks | Customers needing it up; operators keeping it up |
|
||||
| **Security, privacy & compliance** | Authn/authz, encryption, audit logging, certifications, regulatory posture, attack-surface reduction, vulnerability/CVE management, supply-chain integrity, runtime threat detection | Security & compliance teams; regulators; users trusting the platform |
|
||||
| **Cost efficiency** | Cost per unit work — per record, per API call, per active user; compute/arch cost (e.g. ARM64/Graviton) | Internal P&L; customers indirectly via pricing |
|
||||
|
||||
### Classification hints / common overlaps
|
||||
|
||||
- **Hardening** (read-only rootfs, drop caps, minimal/chiseled base images, image
|
||||
CVE scanning, SHA-pinning CI actions, dependency patch currency, supply-chain
|
||||
inventory, runtime threat detection) → **Security, privacy & compliance**.
|
||||
- **ARM64/Graviton migration, smaller images for cost** → **Cost efficiency**
|
||||
(its perf side can be **Performance & scale** — pick the primary driver named
|
||||
in the doc).
|
||||
- **Perf vs cost** are often two sides of one coin — categorize by the value the
|
||||
doc emphasizes (latency/throughput → Performance; $/unit → Cost).
|
||||
- **A correctness/data-race bug surfaced by infra work** → **Reliability &
|
||||
observability** (it's about the service being correct/up), not Security.
|
||||
- **CI/CD integrity** (pinning actions, blocking tampering) → Security; **CI
|
||||
ergonomics/speed for devs** → Developer experience. Pick by the value.
|
||||
- **Runtime anomaly detection** (GuardDuty etc.) → Security (threat detection)
|
||||
even though it touches observability.
|
||||
|
||||
## Value rank → priority
|
||||
|
||||
Vikunja priority is 1–5. Use **1–4** for value tiers (reserve 5 for true
|
||||
DO-NOW). Rank within the category, by value of the need:
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| **4** | Rank-1 tier: most acute / frequent / broadly held; foundational. Everything else in the category is downstream of it. |
|
||||
| **3** | High value, clearly worth doing, but not the keystone. |
|
||||
| **2** | Medium: real need, smaller delta over the status quo. |
|
||||
| **1** | Small / late-on-the-curve: refinement, niche, or largely satisfied by another item. |
|
||||
|
||||
Priority reflects **value, not build effort**. A cheap rank-1 outranks an
|
||||
expensive rank-6.
|
||||
|
||||
## Urgency / returns labels
|
||||
|
||||
- **`acute`** — the need is frequent, painful, and/or broadly held *right now*.
|
||||
Apply to the rank-1/keystone needs and anything externally forced (regulatory
|
||||
deadline, audit finding).
|
||||
- **`diminishing`** — the next investment in this area has tipped into
|
||||
diminishing returns, or the need is largely satisfied by another item already
|
||||
in the plan. Apply sparingly; it's a signal to deprioritize.
|
||||
|
||||
A task can have neither. Most have neither.
|
||||
|
||||
## Platform & deployment labels
|
||||
|
||||
- **Platform** = the product/platform the work belongs to (e.g. `Orchestrate`,
|
||||
`HBNG`). One label.
|
||||
- **Deployment / repo** = the specific service or repo (e.g. `Rosetta`,
|
||||
`Hendrix`, `Insight`, `Kong`). One per task when granularity is per-deployment.
|
||||
|
||||
Categories are consistent **across** platforms and deployments (that's the point
|
||||
— cross-deployment comparability). Platform/deployment are tags, never top-level
|
||||
projects.
|
||||
|
||||
## Done / in-progress
|
||||
|
||||
- If the doc says an item is already shipped for a deployment, set `done: true`.
|
||||
- Partial progress: leave `done: false` and set `percent` (e.g. 50) — note what
|
||||
remains in the description.
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/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]))
|
||||
@@ -1,204 +0,0 @@
|
||||
#!/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