Skip to content

feat: add task-sync with CUFM and directory trees - #144

Closed
colinmollenhour wants to merge 539 commits into
krodak:mainfrom
colinmollenhour:feature/Task-Sync-CUFM
Closed

colinmollenhour wants to merge 539 commits into
krodak:mainfrom
colinmollenhour:feature/Task-Sync-CUFM

Conversation

@colinmollenhour

Copy link
Copy Markdown

Summary

Adds cup task-sync, a file-based workflow for keeping ClickUp task descriptions in source control as ClickUp Flavored Markdown (CUFM). CUFM compiles directly to ClickUp's native Quill editor ops, avoiding the extra paragraphs and broken nested blocks produced by ClickUp's generic Markdown importer.

The same compiler is used by cup create and cup update --description-file.

This PR also changes cup skill to install the complete bundled skill directory through npx skills add, ensuring the CUFM reference is available to every supported agent.

Highlights

  • Push and pull one task as a local Markdown file.
  • Sync a directory graph of parent tasks, subtasks, and dependency edges.
  • Compile GFM plus CUFM components to native ClickUp editor structures.
  • Upload and reuse local images by content hash.
  • Render Mermaid and tldraw diagrams as sharp high-resolution PNGs while retaining their logical display size.
  • Preserve Mermaid and tldraw source as native code blocks inside toggles.
  • Round-trip ClickUp Synced Content definitions and clones.
  • Detect local/remote conflicts using content and remote-description hashes.
  • Provide task-sync doctor for repeatable Quill and visual regression checks.

Task sync

cup task-sync init <taskId> notes.md
cup task-sync push notes.md
cup task-sync pull notes.md
cup task-sync status notes.md

cup task-sync init <taskId> ./tasks/
cup task-sync push ./tasks/
cup task-sync pull ./tasks/

cup task-sync doctor --list <listId>
cup task-sync doctor --task <taskId>

A directory represents one task graph:

  • Child parent: is canonical.
  • Parent subtasks: records display order and provides a consistency check.
  • depends_on: and blocks: represent waiting-on edges.
  • Missing tasks are created parents-first, descriptions are written next, and dependency edges are added last.
  • parent: null explicitly unparents; omitting the field preserves the existing parent.
  • Atomic writes record new task IDs immediately so a failed push cannot duplicate newly created tasks on retry.

ClickUp Flavored Markdown

CUFM supports standard headings, inline formatting, links, nested lists, task lists, fenced code, dividers, GFM tables, and images, plus native ClickUp components:

CUFM ClickUp result
::toc Native table of contents
::toggle{title="…"} Toggle with properly nested body blocks
::banner{color="blue"} Native banner, optionally with an emoji icon
GitHub alerts NOTE/TIP/IMPORTANT/WARNING/CAUTION mapped to supported banners
::quote{size="large"} Pull quote
::columns / :::column Native column layout
::table{widths="…"} Native table with explicit widths
fenced mermaid / ::mermaid Uploaded 2× PNG plus a native source-code toggle
fenced tldraw / ::tldraw Uploaded high-resolution PNG plus a native JSON source toggle
::sync-block{id="…"} Native Synced Content definition or clone
::button / ::frame Native button or embedded frame
:badge, :user, :task, :doc Native inline embeds and mentions

Supported base color tokens are grey, red, orange, yellow, green, blue, purple, and pink. Banners additionally support their -strong variants.

Unknown ::components are preserved as fenced cufm source instead of being silently discarded.

Full syntax: docs/cufm.md.

Synced Content

Lossless pull retrieves both the task Delta and its backing sync_blocks collection. The first occurrence carries the shared content; later empty occurrences clone the same ClickUp block ID:

::sync-block{id="aa751cde-1335-4328-8359-2774c6cc77d5"}
This is a synced content block
::

Below is a clone of the above block:

::sync-block{id="aa751cde-1335-4328-8359-2774c6cc77d5"}
::

Updating the component body updates the shared backing Delta and therefore every clone. That operation requires CU_SESSION_TOKEN or task-sync push --session-token <jwt>. The initial backing block is still created in ClickUp; cup then pulls and reuses its ID.

cup skill

cup skill now delegates installation to npx skills add using the skill directory bundled with the installed CLI version. This copies SKILL.md and references/cufm.md, supports Universal .agents/skills, and avoids maintaining an incomplete agent/path matrix in cup.

  • --print and --path remain local operations.
  • Non-TTY installs pass --yes and cannot hang on prompts.
  • The installed skill matches the installed cup version rather than GitHub main.

Visual verification

The doctor document was exercised against task 86bbhk0rk and inspected through the public ClickUp UI:

https://sharing.clickup.com/2304761/t/h/86bbhk0rk/KAQ14DPGC3O0IIA

Full task-sync doctor document rendered in ClickUp

Confirmed in the stored Quill and rendered DOM:

  • Toggle JSON remains one native code block inside the collapsed toggle.
  • Mermaid source remains one native four-line code container inside its toggle.
  • Mermaid is stored at 1196×234 and displayed at approximately 596×117.
  • tldraw renders as an uploaded high-resolution image with its JSON source preserved in a toggle.
  • Formatted table cells preserve marks including literal strikethrough.
  • Both Synced Content embeds reference the same UUID and retain one shared backing Delta.

Public viewers see ClickUp's permission placeholder for Synced Content, but the public task payload exposes and confirms the shared references.

Validation

  • npm run typecheck
  • npm run lint
  • npm run format:check
  • npm test — 111 files, 1,434 tests
  • npm run build

The full gate was run against clean commit 2ed3211.

krodak and others added 30 commits March 28, 2026 14:33
…ipe escaping, dedup PRIORITY_MAP, createGoal NaN, loadConfig double-parse
Show how long a task has been in each status via the ClickUp
time_in_status API endpoint. Supports TTY table, markdown, and
JSON output modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Catch the "No data for TIS" API error and display actionable guidance
instead of a raw 401 error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 20, 2026 18:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@colinmollenhour

colinmollenhour commented Aug 20, 2026 •

Copy link
Copy Markdown
Author

@krodak Hey Krzysztof, I know this is a huge PR, but I promise it's worth it. :)

All code was written by AI with multiple models and been through multiple rounds of review, refinement, testing, etc. This feature makes possible what was previously impossible in a single command so that syncing ClickUp tasks back and forth is dead simple for humans and AIs and you don't waste the precious context window space when in the middle of planning just to put your plans online. It allows the AI to write rich descriptions without losing them over and over and allows humans to make edits without those being stripped out next time you sync. I think this is really going to take my task planning to the next level.

Note, this uses an undocumented API for fetching the task descriptions from ClickUp because their documented APIs don't return the Quill format which is necessary to preserve all of the data types. I've asked them months ago about making the most minor changes with no luck so don't think that "wait for it to be official" is worthwhile, we'll have AGI before then.. 🤣 If it breaks in the future, we'll fix it, that is what the "doctor" command is there for.
Also, just to avoid any confusion CUFM is something I made up with this PR, it doesn't exist otherwise. It does use MDC which is a well-designed standalone markup extension so I didn't invent the markup syntax or parser, just the components and how they render to Quill.

Oh yeah, I meant to say, thank you so much for creating and maintaining this awesome CLI, I use and love it daily! ClickUp should buy it and/or hire you. You're the man!

`::mermaid`/`::tldraw` bodies were rebuilt from markdown-it tokens by
`tokensToSource`, which kept only inline/fence/text tokens. A blank line
inside a pretty-printed .tldr body ends the paragraph and turns the next
indented line into a `code_block` token, which was dropped — so the
compiler handed the renderer mangled JSON, the cache key missed, and
`task-sync push` reported "tldraw render failed" even though the render
and upload had succeeded.

Bodies now come from the original source lines via the container token's
`map`, byte-for-byte, with the container's own indentation or `>` prefix
stripped for nested blocks.

Also:
- Add exported `collectDiagramSources()` and use it from publish.ts in
  place of its own regexes, so the render and compile sides cannot drift
  apart again.
- Surface render failures as "<lang> render failed: <reason>" in compile
  warnings instead of swallowing the reason.
- Key the mermaid cache by theme + source; a fenced block's `{theme=...}`
  was previously ignored and same-source blocks with different themes
  collided.
Lossless `task-sync pull`, Synced Content updates and doctor's stored-colour
audit all need ClickUp's web session JWT; a personal pk_ API token is rejected
by those endpoints (JWT_008 / JWT_062). Nothing documented how to obtain the
JWT, and a pull without one silently replaced authored CUFM with ClickUp's
flattened markdown export.

- Add `cup auth session [token]` to store the JWT on the profile (0600, beside
  apiToken), with --status and --clear. Accepts a positional token, piped
  stdin, or a masked prompt; rejects pk_ tokens and already-expired JWTs with
  a pointer to where the real one lives.
- Add src/session-token.ts as the single resolver: --session-token, then
  CU_SESSION_TOKEN, then the stored value. It decodes `exp`, so an expired
  token now reports "expired 20m ago" instead of an opaque 401.
- `task-sync pull` refuses to overwrite a file containing CUFM with flattened
  markdown unless --lossy is passed, and every pull line is now labelled
  (lossless) or (lossy) instead of only marking the lossless case.
- Keep sessionToken through the profile normaliser, which previously dropped
  unknown fields on any config write.
- Document how to get the token, its lifetime, and the --lossy escape hatch.
A pull rebuilt CUFM line by line, so anything spanning several Quill lines
came back mangled. Round-tripping a real task description through
compile/decompile corrupted 93 lines with no network involved.

Group lines into the block they belong to before rendering:

- join consecutive code-block lines into one fence, instead of emitting a
  complete fence per line (this alone made diagram sources unrecoverable)
- keep task/user/doc mentions and images inline rather than breaking the
  sentence onto new lines
- wrap every line of a banner, pull quote or column layout in one component;
  `::columns` was previously dropped without warning
- close toggles around their indented children; the `::` that appeared to
  close them was the following table's
- collapse ClickUp's rendered-image + "<lang> source" toggle pair back to the
  plain fence that produced it, since push re-renders the image
- keep inline formatting in table cells and fold multi-line cells to `<br>`
- hoist each emphasis over the longest run of spans sharing it, so adjacent
  spans emit one delimiter pair instead of colliding into `**`x`**** y**`
- number ordered lists and indent children past the parent marker
- separate blocks with a blank line, as a soft break would otherwise collapse
  to a space on the next push

Two of these drifted without bound: whitespace before `{align="..."}` grew by
one space per cycle and ordered numbering climbed on every pull.

Also fix a leaf inline component (`:doc{...}`) swallowing the rest of its line
and losing its props: it has no closing token, so searching for one ran to the
end of the line.

The doctor document could not have caught any of this — every code block in it
was one line long, and its banners and quotes were single paragraphs. Give it
multi-line bodies, multi-paragraph banners and quotes, mentions mid-sentence,
adjacent emphasis, deeper ordered lists and formatted table cells, and audit
the decompiled result both locally and as ClickUp stored it. Verified against
the pre-fix decompiler: 11 of the 12 new assertions fail on it.

Document the traps an author cannot see from the rendered output: soft breaks
collapse to spaces, ordered items nest by three spaces, and tables, dividers,
`::button` and `::frame` fall out of a toggle because the block embeds have no
line to carry the indent.
`tldraw export --print` streams base64 to stdout and exits without
waiting for the pipe to drain. Captured through execFile that yields a
prefix of the image: a real diagram produced 146,176 of 2,163,597 bytes,
decoding to a 109,632-byte PNG with no IEND chunk. Shell redirection to a
file gets the whole stream, which is why this never showed up manually.

pngDimensions() validated only the signature and IHDR, so the truncated
image passed, was uploaded, and rendered as a corrupt attachment on the
task. Re-pushing reproduced the identical bytes, so nothing downstream
could recover.

Export now writes to the temp directory that already holds the .tldr
source (--output <dir> --name diagram) and reads the file back, and
pngDimensions() additionally requires a well-formed IEND trailer so a
short read fails loudly as a compile warning instead of silently
uploading a broken image.

exportTldrawPng is exported and takes an injectable runner, matching the
exporter seam renderTldrawPng already had, so the argv contract can be
asserted without the external CLI. All six new tests fail against the
previous implementation.

Note: tests/unit/index.test.ts and tests/unit/commands/profile.test.ts
have six pre-existing failures under the full suite (they pass in
isolation); verified identical on a clean tree before and after.
@krodak krodak assigned krodak and unassigned krodak Aug 29, 2026
@krodak
krodak self-requested a review August 29, 2026 11:49
@krodak krodak added the blocked-on-upstream-api Needs a public ClickUp API capability that does not exist yet label Aug 29, 2026
@krodak

krodak commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Hi Colin — first, thank you for this. It's one of the most substantial and carefully built contributions this project has received: the CUFM round-trip fixture testing, atomic ID recording on push, the injection-safe tldraw/git invocations, and the visual verification against a live workspace all show real care. I've spent a good amount of time reviewing it, and I want to be upfront about where I've landed and why, because the reason isn't code quality.

The decision: I can't merge the parts that depend on ClickUp's private API surface, and I'd like to invite a revised PR that keeps everything else.

The reasoning. This CLI is a published npm package, which changes the risk calculus for unofficial endpoints in two ways:

  1. Breakage lands on every user at once. The frontdoor host (frontdoor-prod-us-east-2-2.clickup.com) is a deployment-specific internal hostname, and the description: { ops } acceptance on the public v2 endpoints is web-app behavior rather than documented contract. Either can change in a routine deploy with no notice. Since the PR routes all cup create -d / cup update -d calls through the { ops } form with no markdown_content fallback, that would break the CLI's most basic commands for everyone, not just task-sync users.
  2. The session-token pattern is something I don't want to distribute. A web session JWT is full-account-scope (unlike a pk_ token), and a published CLI teaching users to copy it out of DevTools and replay it with spoofed origin/x-csrf headers is the kind of pattern that gets tools rate-limited or worse — and normalizes rough security hygiene under this package's name.

What I'd love to merge. The undocumented surface looks severable to me, and you've already built the graceful-degradation path (pull falls back to markdown export without a token). A revision that:

  • keeps the CUFM compiler/decompiler, task-sync files and directory trees, conflict detection, mermaid/tldraw rendering, and doctor;
  • pushes via markdown_content and pulls via the markdown export (lossy for native blocks — acceptable, and honestly labeled);
  • drops frontdoor.ts, session-token.ts, the Synced Content writes, and the { ops } path on core create/update

…would be most of the value with none of the liability, and I'd be glad to take it.

Two bugs worth fixing in any revision (verified locally):

  • src/task-sync/pull.ts:43-58 — the dirty-local-file guard sits inside a try whose catch { /* new file */ } swallows the refusal confirmClobber signals by throwing, so pull overwrites unsynced local edits even when the user declines the prompt (the test at pull.test.ts:73-81 inadvertently locks this in). Narrowing the catch to ENOENT fixes it.
  • src/cufm/decompile.ts:180 renders a mention-only line as :task[86x], but compile.ts has no case for the mdc_block_shorthand token that shape produces, so one pull→push cycle silently deletes any mention that sits on its own line.

Upstream. I've labeled this blocked-on-upstream-api rather than closing it, and I'm filing a feature request with ClickUp for public API support for native block content, citing this PR as concrete demand. If that lands, the lossless path here becomes a small adapter swap onto a supported endpoint — and this work is exactly the foundation it would slot into.

Thanks again for the effort you put into this. I hope the split doesn't feel like a rejection of the work — it's a scoping call about what a published CLI can responsibly carry.

@krodak

krodak commented Aug 29, 2026

Copy link
Copy Markdown
Owner

@colinmollenhour

Copy link
Copy Markdown
Author

@krodak Thanks for your thoughtful review.

I understand your reasons for not accepting, although I'd push back a little.. To me this is such a major lack of functionality and after having used it, I just can't possibly go back. Without the Quill pull/push the entire thing is kinda pointless for my workflow. What if the lossy fallback was by default for task-sync and maybe just issue a warning when it is lossy? I had it that way initially but my thinking is that if you're using task-sync you most likely want to ONLY use the session token so an error is better.

I do agree the frontdoor host needs to be fixed - it probably could be with more hackery; but I have no solution for securing the session token pattern. I suppose this means I will just have to use my fork indefinitely.. I'll continue to improve but am not really interested in submitting a second PR and maintaining my private fork plus the feature for upstream that is so nerfed it's hardly worth using. You're welcome to modify and merge it as you see fit.

Thanks for filing the feedback issue, I have voted on it but didn't bother to write a request there in the first place because based on other feedback I've seen it appears to be nearly pointless. Even issues with hundreds of votes that are years old and don't even require UI don't get completed.. it should be possible to do this in 2 days but I have little faith it will get done any time soon. /rant

A mention sitting on its own line tokenizes as mdc_block_shorthand, which
compile skipped, so a pull/push cycle deleted it. The dirty-file guard
lived in a catch that treated every throw as "new file", including
confirmClobber refusals.
The web app looks up the regional frontdoor via an unauthenticated
workspace handshake rather than a hardcoded shard hostname. Create and
update still send native editor ops first, then retry with
markdown_content if the public API rejects that shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked-on-upstream-api Needs a public ClickUp API capability that does not exist yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants