diff --git a/README.md b/README.md index 60f34a1..d67988e 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,9 @@ Or hand the whole thing to your agent — paste this into Claude Code: *"Set me - **"drafty it."** Tell Claude to drafty the thing it just wrote → you get a `drafty.im/canvas/` link. Anyone you share it with hovers an element, clicks, and leaves a threaded comment — live cursors, no sign-up needed to comment. - **Claude closes the loop.** "Address the canvas" → Claude reads each thread, edits the source file, pushes a new version on the same link, replies on the canvas, and marks threads done. Set a canvas `live` and Claude works comments as they arrive. - **Agent eyes.** `drafty shot` renders a canvas, a local HTML file, or any URL to an image so Claude can *see* what it built — including a commenter's exact view (their viewport width, their revision, the anchored element highlighted) instead of guessing at "looks squished on my phone" from text alone. -- **Site boards.** `drafty present ` maps a site (robots → sitemap → homepage links), curates the main screens, captures each at desktop + phone width with local Chrome, and publishes an annotatable board. `--refresh` re-shoots the same screens as a tick — competitor tracking, staging watch. +- **Site boards.** `drafty present ` maps a site (robots → sitemap → homepage links), curates the main screens, captures each at desktop + phone width with local Chrome, and publishes an annotatable board for review. - **Versioned, with a real undo.** Every push snapshots a revision. `revert` rolls the canvas back AND resyncs the local file atomically; `status` reports in-sync / local-ahead / canvas-ahead / diverged; a push that would clobber an edit made elsewhere (browser, another agent) is refused with instructions, never silently applied. - **Organized, and self-tidying.** Projects, tags, pin, archive — and `drafty tidy --sweep` cross-references your canvases against the repo's git log to flag the ones whose work already shipped, so finished specs get receipted and archived instead of rotting on the list. -- **Self-refreshing dashboards.** The bundled `drafty-cron` skill wires a plain OS cron — no model, no credits at runtime — that re-renders and pushes a data-backed canvas on a timer. ## How it works @@ -37,7 +36,6 @@ You talk; Claude runs the commands. You never touch the CLI yourself. - **The `drafty` skill** — teaches Claude the whole loop: publish, read comments, reply, mark threads done, push revisions, render-and-look before claiming a visual fix, sweep shipped canvases, roll back. Claude loads it on its own when you say "drafty it" / "share this for feedback" / "what did they comment". - **The `drafty` CLI** — a single-file, thin HTTP client. Every command is a call to drafty.im's public API; ownership and visibility are enforced server-side. No keys ship in the plugin — auth is a browser sign-in (`drafty login`), one sign-in covering web + CLI. -- **The `drafty-cron` skill** — the control plane for self-refreshing canvases (author a deterministic query → render → push script once; launchd runs it forever). ## Modes & visibility @@ -88,7 +86,7 @@ Claude drives these; the reference is here so you can audit what it's doing. | Command | What it does | |---|---| | `drafty shot [--width N] [--annotation A] [--full]` | Render to an image, print the path. `--annotation` reproduces a commenter's exact view. Local files/URLs and private canvases render with your own headless Chrome; public canvases use the server's cached render. | -| `drafty present [--screens N] [--urls …] [--slug S --refresh] [--dry-run]` | Site board: map → curate (≤20 screens) → shoot at 1280+390px → publish annotatable board. | +| `drafty present [--screens N] [--urls …] [--slug S] [--dry-run]` | Site board: map → curate (≤20 screens) → shoot at 1280+390px → publish an annotatable snapshot. Reuse `--slug` to publish a new version on the same canvas. | **Session** diff --git a/plugins/drafty/cli/canvas.ts b/plugins/drafty/cli/canvas.ts index bc3624e..ea8e1d3 100755 --- a/plugins/drafty/cli/canvas.ts +++ b/plugins/drafty/cli/canvas.ts @@ -585,7 +585,7 @@ function writeManifestEntry(file: string, entry: ManifestEntry): void { // ── commands ──────────────────────────────────────────────────────────────── async function canvasPush(args: string[]) { const file = args[0]; - if (!file) return die("usage: drafty canvas push [--title T] [--description D] [--slug S] [--mode M] [--format html|markdown] [--project P] [--tag T …] [--refresh]"); + if (!file) return die("usage: drafty canvas push [--title T] [--description D] [--slug S] [--mode M] [--format html|markdown] [--project P] [--tag T …]"); const content = readFileSync(file, "utf8"); if (!content.trim()) return die(`file is empty: ${file}`); // --format html|markdown is an explicit override; otherwise sniff content+extension. @@ -611,22 +611,17 @@ async function canvasPush(args: string[]) { // Organize flags, parsed up front so a bad value fails before anything publishes. const project = flag(args, "project"); const tags = multiFlag(args, "tag"); - // --refresh marks this push as coming from a scheduled job (drafty-cron). The - // server stamps the canvas as self-refreshing (arming a new one may be - // plan-gated server-side); re-pushes to an armed canvas always pass. - const refresh = has(args, "refresh"); // Divergence guard (agent-eyes S3): send the rev counter we last synced so the // server refuses to clobber a canvas that moved (browser edit, restore, - // another agent). --force skips it; refreshes and manifest-less pushes never - // send one, so their behavior is unchanged. + // another agent). --force skips it; manifest-less pushes never send one. const force = has(args, "force"); - const baseRev = !force && !refresh && mf.entry && mf.entry.slug === slug && mf.entry.lastRev != null ? mf.entry.lastRev : undefined; + const baseRev = !force && mf.entry && mf.entry.slug === slug && mf.entry.lastRev != null ? mf.entry.lastRev : undefined; // Upload local images → served URLs in the published copy; the file on disk is // left as-is (small + editable). Titles are inferred from the original content. const published = await uploadLocalAssets(content, file); // targetSlug = update intent (exact); newSlug = pre-hashed slug if we create. const r = await api("canvas.push", { - body: { content: published, format, title, targetSlug: slug, newSlug: slugify(slug || title), ...(description ? { description } : {}), ...(mode ? { mode } : {}), ...(visibility ? { visibility } : {}), ...(refresh ? { refresh: true } : {}), ...(baseRev != null ? { baseRev } : {}) }, + body: { content: published, format, title, targetSlug: slug, newSlug: slugify(slug || title), ...(description ? { description } : {}), ...(mode ? { mode } : {}), ...(visibility ? { visibility } : {}), ...(baseRev != null ? { baseRev } : {}) }, }); if (r.diverged) { const who = r.headAuthorKind === "human" ? `${r.headAuthor} (in the browser)` : r.headAuthor || "someone"; @@ -655,8 +650,7 @@ async function canvasPush(args: string[]) { if (mode) console.log(` ${modeLine(mode, r.slug)}`); } if (visibility) console.log(` visibility: ${visibilityLabel[visibility]}`); - // Server-sent aside (e.g. the first self-refreshing canvas on the free plan). - // Relay it verbatim — it's written for the human, not the log. + // Relay any server-sent aside verbatim — it is written for the human, not the log. if (r.notice) console.log(` ${r.notice}`); // ?ref=cli marks the link as CLI-published. console.log(` ${url(r.slug)}?ref=cli`); @@ -749,8 +743,8 @@ async function commentsStatus(args: string[], status: "open" | "completed") { // match never lands silently on the wrong element); `--at ,` adds a point // inside that element (0..1 — for pinning on a screenshot in a present board); // `--canvas` makes it a canvas-level note with no anchor. Lets `present` stay a -// pure capture tool while analysis layers on as anchored comments that survive a -// --refresh (annotations are separate data, re-anchored on every re-push). +// pure capture tool while analysis layers on as anchored comments. Annotations +// are separate data and re-anchor on every re-push. async function commentsCreate(args: string[]) { const VALUE_FLAGS = new Set(["anchor", "at"]); const positionals: string[] = []; @@ -1217,9 +1211,8 @@ async function shot(args: string[]) { // discovery reads what sites already publish (robots.txt → sitemap.xml → // homepage links), curation is heuristic (URL-template collapse, nav order, // cap), shots are local headless Chrome, and the board pushes through the -// normal asset pipeline. Deterministic for a given site state — which is what -// makes the refresh recipe a one-liner (`--slug --refresh` re-shoots -// the same screens as a tick). +// normal asset pipeline. Reusing `--slug ` publishes a new version of +// the same board so its earlier snapshot remains available in history. const PRESENT_UA = "Mozilla/5.0 (compatible; drafty-present; +https://drafty.im)"; // Paths that are never "main screens": auth/account/commerce plumbing, API-ish, legal boilerplate. @@ -1446,9 +1439,8 @@ ${sections} async function present(args: string[]) { const usage = - "usage: drafty present [--screens N] [--widths 1280,390] [--urls a,b,c] [--slug S] [--title T] [--visibility public|authed|invite] [--refresh] [--project P] [--tag T …] [--dry-run]"; + "usage: drafty present [--screens N] [--widths 1280,390] [--urls a,b,c] [--slug S] [--title T] [--visibility public|authed|invite] [--project P] [--tag T …] [--dry-run]"; const slugFlag = flag(args, "slug"); - const refresh = has(args, "refresh"); const dry = has(args, "dry-run"); const cap = Math.max(1, Math.min(40, Number(flag(args, "screens") ?? 20))); let widths = (flag(args, "widths") ?? "1280,390").split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0); @@ -1457,7 +1449,7 @@ async function present(args: string[]) { let rootStr = args[0] && !args[0].startsWith("--") ? args[0] : undefined; let screens: PresentScreen[] | null = null; - // Refresh / re-run against an existing board: read the screen list back from + // Re-run against an existing board: read the screen list back from // the board's own meta block, so the run is byte-deterministic with the // original (same URLs, same widths) — no re-discovery drift. An explicit // --urls beats the meta: that's how a board's screens get re-curated. @@ -1579,7 +1571,6 @@ async function present(args: string[]) { targetSlug: slugFlag, newSlug: slugify(slugFlag || title), ...(visibility ? { visibility } : {}), - ...(refresh ? { refresh: true } : {}), }, }); // File it: explicit flags win; otherwise every board gets the site-board tag. @@ -1590,7 +1581,7 @@ async function present(args: string[]) { } catch { /* organizing is best-effort */ } rmSync(work, { recursive: true, force: true }); - console.log(`✓ ${r.created ? "published" : r.tick ? "refreshed" : "updated"} "${r.title}" — ${screens.length} screens × ${widths.join("/")}px`); + console.log(`✓ ${r.created ? "published" : "updated"} "${r.title}" — ${screens.length} screens × ${widths.join("/")}px`); if (r.notice) console.log(` ${r.notice}`); console.log(` ${url(r.slug)}?ref=cli`); // Boards exist to be shared (clients, teammates) — surface the gate that the @@ -1598,9 +1589,9 @@ async function present(args: string[]) { // a surprise. --visibility public skips it at creation. if (r.created && !visibility) console.log(` visibility: private to you — run \`drafty canvas visibility ${r.slug} public\` to share it`); - if (r.created && !refresh) - console.log(` keep it fresh: drafty present --slug ${r.slug} --refresh (re-shoots the same screens)`); - await track("canvas.presented", { slug: r.slug, screens: screens.length, widths: widths.length, refresh, created: !!r.created }); + if (r.created) + console.log(` re-run later: drafty present ${root.href} --slug ${r.slug} (re-shoots the same screens as a new version)`); + await track("canvas.presented", { slug: r.slug, screens: screens.length, widths: widths.length, created: !!r.created }); } // Download the artifact body. Content goes to stdout (newline-terminated) so it @@ -1831,7 +1822,7 @@ async function commentsInbox(args: string[]) { } } -// Shared SSE doorbell loop for `comments watch` and `inbox watch`: --for +// Shared SSE doorbell loop for `comments watch`: --for // self-bounding, SIGINT, and reconnect-with-backoff around the single // /get/api/comments.watch stream; the caller picks which events it prints. // @@ -1920,125 +1911,6 @@ async function commentsWatch(args: string[]) { }); } -// ── the capture inbox — screenshot → agent fixes → proof ───────────────────── -// The account's single inbox canvas: slug-less captures from the iOS/Mac apps -// land there as `todo` entries (the board renders Todo/Doing/Review/Done). The -// agent loop: `inbox watch` (doorbell) → `inbox ls --status todo` → `claim` → -// `classify` (on pickup, with the screenshot in view) → fix → `review --pr … -// --proof …` (the agent's terminal action). `done` is the human's approval; -// items stay on the board as the ledger, receipts attached. -async function inboxLs(args: string[]) { - const query: Record = {}; - const status = flag(args, "status"); - if (status) query.status = status; - const project = flag(args, "project"); - if (project) query.project = project; - const r = await api("inbox.ls", { method: "GET", query }); - if (has(args, "json")) { - console.log(JSON.stringify(r, null, 2)); - return; - } - if (!r.slug) { - console.log("no inbox yet — share a capture from the Drafty iOS or Mac app and it appears here"); - return; - } - const items = (r.items as any[]) || []; - console.log(`# Inbox — ${r.url}`); - if (!items.length) { - console.log(status || project ? "no matching items" : "inbox is empty"); - return; - } - for (const it of items) { - const tagStr = Array.isArray(it.tags) && it.tags.length ? ` #${it.tags.join(" #")}` : ""; - console.log(`• [${it.status}] ${it.summary || it.text || "(capture, unclassified)"}${it.project ? ` (${it.project})` : ""}${tagStr}`); - if (it.mediaUrl) console.log(` media: ${it.mediaUrl}`); - if (it.status === "doing" && it.claimedBy) console.log(` claimed by ${it.claimedBy}`); - if (it.status === "done" || it.status === "review") { - const receipts = [it.proofSlug ? `proof: ${BASE_URL}/canvas/${it.proofSlug}` : "", it.fixRef ? `pr: ${it.fixRef}` : ""].filter(Boolean).join(" "); - if (receipts) console.log(` ${receipts}`); - } - console.log(` id: ${it.entryId}\n`); - } -} - -async function inboxClaim(args: string[]) { - const entryId = args.find((a) => !a.startsWith("--")); - if (!entryId) return die("usage: drafty inbox claim [--agent name]"); - const body: Record = { entryId }; - const agent = flag(args, "agent"); - if (agent) body.agent = agent; - const r = await api("inbox.claim", { body }); - console.log(`✓ claimed → doing (${r.entryId})`); - console.log(` classify it while the capture is in view: drafty inbox classify ${r.entryId} --project P --tag T --summary "…"`); -} - -async function inboxClassify(args: string[]) { - const entryId = args.find((a) => !a.startsWith("--")); - if (!entryId) return die('usage: drafty inbox classify [--project P] [--tag T …] [--summary "…"]'); - const body: Record = { entryId }; - const project = flag(args, "project"); - if (project) body.project = project; - const tags = multiFlag(args, "tag"); - if (tags.length) body.tags = tags; - const summary = flag(args, "summary"); - if (summary) body.summary = summary; - if (!(project || tags.length || summary)) return die("classify needs at least one of --project / --tag / --summary"); - const r = await api("inbox.classify", { body }); - console.log(`✓ classified (${r.entryId})`); -} - -async function inboxDone(args: string[]) { - const entryId = args.find((a) => !a.startsWith("--")); - if (!entryId) return die("usage: drafty inbox done [--pr URL] [--proof slug]"); - const body: Record = { entryId }; - const pr = flag(args, "pr"); - if (pr) body.fixRef = pr; - const proof = flag(args, "proof"); - if (proof) body.proofSlug = proof; - const r = await api("inbox.done", { body }); - console.log(`✓ done (${r.entryId})${pr || proof ? " — receipts attached" : ""}`); -} - -async function inboxReview(args: string[]) { - const entryId = args.find((a) => !a.startsWith("--")); - if (!entryId) return die("usage: drafty inbox review [--pr URL] [--proof slug]"); - const body: Record = { entryId }; - const pr = flag(args, "pr"); - if (pr) body.fixRef = pr; - const proof = flag(args, "proof"); - if (proof) body.proofSlug = proof; - const r = await api("inbox.review", { body }); - console.log(`✓ ready for review (${r.entryId})${pr || proof ? " — receipts attached" : ""}`); -} - -async function inboxReopen(args: string[]) { - const entryId = args.find((a) => !a.startsWith("--")); - if (!entryId) return die("usage: drafty inbox reopen "); - const r = await api("inbox.reopen", { body: { entryId } }); - console.log(`✓ reopened → todo (${r.entryId})`); -} - -async function inboxWatch(args: string[]) { - // The inbox doorbell: same SSE stream as comments watch, filtered to `entry` - // events (new captures). Resolve the inbox slug first; no inbox yet = nothing - // to watch (it's created by the first capture). - const r = await api("inbox.ls", { method: "GET", query: {} }); - const slug = r.slug as string | null; - if (!slug) return die("no inbox yet — share a capture from the Drafty iOS or Mac app first"); - const asJson = has(args, "json"); - if (!asJson) console.error(`👀 watching your inbox (${url(slug)}) — new captures will appear here\n`); - await watchLoop(slug, args, asJson, (ev) => { - if (ev.ev !== "entry") return; - if (asJson) { - console.log(JSON.stringify({ entryId: ev.entryId, kind: ev.kind, text: ev.text ?? null, mediaUrl: ev.mediaUrl ?? null, mediaType: ev.mediaType ?? null, status: ev.status ?? null, project: ev.project ?? null, createdAt: ev.createdAt })); - } else { - console.log(`[${shortTime(ev.createdAt)}] new capture${ev.text ? `: ${ev.text}` : ""}`); - if (ev.mediaUrl) console.log(` media: ${ev.mediaUrl}`); - console.log(` ↳ claim it: drafty inbox claim ${ev.entryId}\n`); - } - }); -} - // ── canvas management (owner-scoped via perms) ─────────────────────────────── function requireYes(args: string[], what: string) { if (!has(args, "yes")) die(`${what} is destructive — re-run with --yes to confirm.`); @@ -2262,7 +2134,7 @@ function gitLogEntries(root: string | null): { sha: string; ts: number; subject: } catch { return []; } } -// Pinned canvases are deliberately long-lived (dashboards, living docs) — never +// Pinned canvases are deliberately long-lived (specs, handbooks, living docs) — never // sweep candidates. Slugs are collision-proof (`launch-plan-9fk2q`), so a plain // substring match against commit messages is exact enough. function sweepEvidence(items: any[], log: ReturnType): SweepRow[] { @@ -2818,15 +2690,6 @@ COMMENTS — threads pinned to a canvas, and their replies drafty comments rm-reply delete a single reply drafty comments clear --yes delete all threads on a canvas -INBOX — your capture inbox: screenshots from the iOS/Mac apps, worked by agents - drafty inbox ls [--status todo|doing|review|done] [--project P] [--json] the board as a task queue - drafty inbox watch [--json] [--backlog] [--for DUR] stream new captures live (the doorbell) - drafty inbox claim [--agent name] take a todo item (todo → doing) - drafty inbox classify [--project P] [--tag T …] [--summary "…"] file it on pickup - drafty inbox review [--pr URL] [--proof slug] hand it back for approval with receipts (→ review) - drafty inbox done [--pr URL] [--proof slug] human approval — accept the work (→ done) - drafty inbox reopen send it back to todo (receipts kept) - LINKS — short tracked links (drafty.im/l/) with attribution baked in drafty link create [--code C] [--source S] [--medium M] [--campaign C] [--content C] mint (or reuse) a shortlink drafty link ls [--json] your shortlinks, newest first @@ -2834,7 +2697,7 @@ LINKS — short tracked links (drafty.im/l/) with attribution baked in drafty resolve [--json] print the canvas behind any drafty link (slug, /canvas/ URL, or /l/ short link) drafty shot [--width N] [--revision R] [--annotation A] [--full] [-o out] render to an image and print its path (the agent's eyes) - drafty present [--screens N] [--widths 1280,390] [--urls a,b…] [--slug S] [--refresh] [--dry-run] site board: map → curate → shoot → annotatable canvas + drafty present [--screens N] [--widths 1280,390] [--urls a,b…] [--slug S] [--dry-run] site board: map → curate → shoot → annotatable canvas drafty context [--limit N] [--archived] [--json] one-shot orientation: identity, git, projects, tags + recent canvases drafty tidy [--project P] [--sweep] [--json] one audit pass: unfiled canvases, junk titles, tag drift + which look shipped/stale (commit evidence); --sweep = just the shipped/stale section drafty changelog [--json] what shipped, by week @@ -2868,9 +2731,6 @@ const COMMENTS: Record = { resolve: (a) => commentsStatus(a, "completed"), reopen: (a) => commentsStatus(a, "open"), rm: commentsRm, "rm-reply": commentsRmReply, clear: commentsClear, }; -const INBOX: Record = { - ls: inboxLs, claim: inboxClaim, classify: inboxClassify, review: inboxReview, done: inboxDone, reopen: inboxReopen, watch: inboxWatch, -}; const LINK: Record = { create: linkCreate, ls: linkLs, rm: linkRm, resolve: resolveCmd }; // Top-level: session / meta — not scoped to a canvas or a comment. // `sweep` (released ≤0.25.0) folded into `tidy --sweep`; the alias keeps old @@ -2894,7 +2754,6 @@ async function main() { // so existing muscle memory and older docs keep working. if (["canvas", "canvases", "documents", "document", "doc"].includes(head)) return runGroup("canvas", CANVAS, rest); if (head === "comments" || head === "comment") return runGroup("comments", COMMENTS, rest); - if (head === "inbox") return runGroup("inbox", INBOX, rest); if (head === "link" || head === "links") return runGroup("link", LINK, rest); if (head && TOP[head]) return TOP[head](rest); console.log(HELP); diff --git a/plugins/drafty/skills/drafty-cron/SKILL.md b/plugins/drafty/skills/drafty-cron/SKILL.md deleted file mode 100644 index b5a5636..0000000 --- a/plugins/drafty/skills/drafty-cron/SKILL.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: drafty-cron -description: Keep a Drafty canvas auto-updated from live data on a schedule, using a LOCAL macOS launchd cron — with no Claude in the loop at runtime (no claude -p, no /loop, no credits). Claude authors the refresh script once and installs/manages the cron; launchd runs the deterministic "query → render → push" job forever. Use when the user wants a canvas (a metrics/growth dashboard, a status board, anything data-backed) to refresh itself periodically, or says "keep this canvas updated", "schedule this canvas", "refresh every N minutes", "make it live", "auto-update the dashboard", "pause/stop the schedule", or "what's scheduled". ---- - -# drafty-cron — self-refreshing Drafty canvases - -A canvas refresh is **mechanical**: run a script (pull data → render HTML → push). No reasoning, no model needed at run time. So it should NOT run through Claude — it runs as a plain OS cron. - -- **Control plane (occasional, you):** author the refresh script, install the cron, manage it. That's this skill. -- **Data plane (continuous, no Claude):** a `launchctl` job runs the script on a timer. Free, runs whether or not Claude is around. - -Do **not** reach for `/loop`, `claude -p`, or `CronCreate` here — those re-invoke the model every run (credits + a live session). Use a real OS cron (`launchctl`). - -> Platform note: this skill uses **macOS launchd**. The pattern (a plain OS cron running a render→push script) works anywhere; only the install helper is macOS-specific. - -## Helper - -`drafty-cron.sh` (bundled next to this SKILL.md) manages the launchd jobs. On first use, copy it to a stable path so launchd references survive plugin updates: - -```sh -mkdir -p ~/.drafty && cp "$(dirname "$0")/drafty-cron.sh" ~/.drafty/cron.sh 2>/dev/null || true -CRON=~/.drafty/cron.sh # or run the bundled copy directly -"$CRON" add # install + start (RunAtLoad fires immediately) -"$CRON" ls # list drafty crons (PID/status) -"$CRON" log # tail the run log -"$CRON" rm # stop + remove -``` - -## Setting up a refresh for a canvas - -1. **Author the refresh script** (the smart, one-time part) — start from `refresh.template.sh` bundled here. It must, deterministically: - - pull the data (a `bq query`, a SQL connector, an API call, …), - - render a self-contained HTML file, - - **push only if the data changed** — hash the HTML *excluding* any "Generated " line, compare to a `.last-hash` sidecar, and skip the push when unchanged. This is what makes a tight cadence safe (no no-op revisions). - - **push with `--refresh`** — marks the canvas as self-refreshing on the server. The free plan includes one; arming a second prints an upgrade link (Drafty Pro runs unlimited). Re-pushes to an already-armed canvas always go through, so a running schedule never breaks. -2. **launchd has a bare PATH** — the #1 gotcha. Export an explicit PATH in the script pointing at the real tools (`~/.bun/bin`, the gcloud SDK bin, `/opt/homebrew/bin`). Use the **version-stable** drafty source CLI for the push (`bun ~/Projects/drafty/plugins/drafty/cli/canvas.ts canvas push …`), not the version-pinned plugin-cache binary. -3. **Install the cron**, e.g. every 5 minutes: - ```sh - "$CRON" add my-dashboard 300 "/abs/path/to/refresh.sh" - ``` -4. **Verify** with `"$CRON" log my-dashboard` — the first run fires on load; expect "pushed" or "no change, skipped". - -## Cadence - -Local crons are free per run, so aggressive cadences (every 5 min = `300`) are fine. The only real costs are (a) data-source query bytes — keep queries cheap/capped — and (b) version history, which the push-only-if-changed guard already protects. Match the interval to how fast the data actually moves; 5–15 min suits most dashboards. - -## Gotchas learned in the field - -- **Never swallow the push's exit code, and only write the `.last-hash` sidecar - on success.** A push can be *refused* — the owner is editing the canvas on - drafty.im (the edit lease holds pushes off), a plan gate fires, the network - blips. If the script records the hash anyway, the canvas silently stays stale - until the data changes a second time. The template handles this; keep it. -- **One job may refresh several canvases.** It's fine (and cheaper) to render + - push multiple canvases from one script on one timer — but then the launchd - job name no longer says what it covers. When asked "is canvas X on a cron?", - read the refresh *script* the job points at, not just `"$CRON" ls`. Prefer a - job name describing the script's scope (`…cron.analytics`), not its first - canvas. - -## Honesty / caveats - -- **Local only.** launchd jobs run while the Mac is awake (they survive reboot/logout, not power-off; on sleep, missed runs coalesce to one run on wake). For always-on (laptop-closed) refresh you'd need a cloud runner — out of scope here. -- Needs the runtime creds present locally (e.g. gcloud auth for BigQuery, the `~/.drafty` token for push). diff --git a/plugins/drafty/skills/drafty-cron/drafty-cron.sh b/plugins/drafty/skills/drafty-cron/drafty-cron.sh deleted file mode 100755 index 7644007..0000000 --- a/plugins/drafty/skills/drafty-cron/drafty-cron.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/zsh -# drafty-cron — manage LOCAL launchd jobs that mechanically refresh Drafty -# canvases. The jobs run a plain command on a schedule with NO Claude in the -# loop (no claude -p, no /loop session) — so they're free and run whether or not -# Claude is around. Claude only uses this to install/list/remove them. -# -# drafty-cron.sh add -# drafty-cron.sh ls -# drafty-cron.sh rm -# drafty-cron.sh log -set -e -AGENTS="$HOME/Library/LaunchAgents" -LOGS="$HOME/Library/Logs" -PREFIX="im.drafty.cron" - -cmd="${1:-}"; shift 2>/dev/null || true -case "$cmd" in - add) - name="$1"; interval="$2"; shift 2 - work="$*" - label="$PREFIX.$name" - plist="$AGENTS/$label.plist" - log="$LOGS/drafty-cron-$name.log" - mkdir -p "$AGENTS" "$LOGS" - cat > "$plist" < - - - - Label$label - ProgramArguments - - /bin/zsh - -lc - $work - - StartInterval$interval - RunAtLoad - StandardOutPath$log - StandardErrorPath$log - - -PLIST - launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$plist" - echo "✓ scheduled $label every ${interval}s" - echo " log: $log" - ;; - ls) - launchctl list 2>/dev/null | grep "$PREFIX" || echo "(no drafty crons)" - ;; - rm) - name="$1"; label="$PREFIX.$name" - launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true - rm -f "$AGENTS/$label.plist" - echo "✓ removed $label" - ;; - log) - name="$1"; tail -n 30 "$LOGS/drafty-cron-$name.log" 2>/dev/null || echo "(no log yet for $name)" - ;; - *) - echo "usage: drafty-cron.sh add | ls | rm | log " - ;; -esac diff --git a/plugins/drafty/skills/drafty-cron/refresh.template.sh b/plugins/drafty/skills/drafty-cron/refresh.template.sh deleted file mode 100755 index 78319a3..0000000 --- a/plugins/drafty/skills/drafty-cron/refresh.template.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/zsh -# Refresh template for a self-updating Drafty canvas. Copy this next to your -# render script, fill in the three MARKED spots, and schedule it with the -# drafty-cron helper. It renders the artifact and pushes ONLY when the data -# changed, so a tight schedule (e.g. every 5 min) doesn't spam version history. -set -e - -# launchd runs with a bare PATH — point at the real tools explicitly. -# Add whatever your render step needs (here: bun + the gcloud SDK for BigQuery). -export PATH="$HOME/.bun/bin:/opt/homebrew/share/google-cloud-sdk/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - -cd "${0:A:h}" # this script's directory - -SLUG="REPLACE-with-your-canvas-slug" -OUT="REPLACE-with-your-rendered-file.html" -DRAFTY="$HOME/Projects/drafty/plugins/drafty/cli/canvas.ts" # version-stable source CLI - -# 1) RENDER — replace with your own deterministic build step (query → HTML). -REPLACE_render_command # e.g. bun render.ts - -# 2) PUSH ONLY IF CHANGED — hash the output excluding any "Generated