Stateful mirror from a git markdown tree to a Feishu/Lark wiki — incremental sync, transactional updates, cross-doc link patching, table column-width memory, mermaid preprocessing.
Project description
larkmd
Bidirectional mirror between a git markdown tree and a Feishu/Lark wiki.
larkmd keeps a directory of .md files in sync with a Feishu wiki space:
content-hash incremental updates, transactional in-place rewrites (no empty
docs on failure), cross-document link patching, table column-width memory,
mermaid round-trip, and a reverse pull that turns Feishu edits back into
markdown with explicit conflict detection.
Built on top of the official lark-cli so
you don't have to manage OAuth tokens yourself.
Quick start
# 1. Install
pip install larkmd
npm i -g @larksuiteoapi/lark-cli # external runtime dependency
lark-cli login --as user # one-time auth
# 2. Initialize config in your repo
cd path/to/your-md-repo
larkmd init # generates larkmd.yaml
# 3. Sanity check
larkmd doctor
# 4. Push: md → Feishu
larkmd plan # dry-run
larkmd apply # do it
# 5. Pull: Feishu → md (anything you edited in the UI)
larkmd pull-plan # dry-run, shows clean / remote-only / local-only / conflict
larkmd pull # write changed Feishu docs back to local md
State is kept in .feishu-sync-state.json (gitignored). Re-run larkmd apply
on every commit; only changed files are pushed. Run larkmd pull whenever
your team has been editing in Feishu.
Commands
| Command | What it does |
|---|---|
larkmd init |
Interactive wizard → generates larkmd.yaml |
larkmd doctor |
Check lark-cli / mmdc / Pillow / env vars |
larkmd plan |
Dry-run push: list create/update/skip per file |
larkmd apply |
Real push (incremental by default; --force for full rebuild) |
larkmd pull-plan |
Dry-run pull: list clean / remote-only / local-only / conflict |
larkmd pull |
Reverse sync: rewrite local md from Feishu (--force-remote / --force-local for conflicts) |
larkmd cleanup |
Delete wiki nodes whose source .md was deleted |
larkmd restore-widths |
Re-apply remembered table column widths |
larkmd state show |
Print state file in human-readable form |
Bidirectional sync model
┌────────── apply ──────────►┐
git md │ │ Feishu wiki
└◄───────── pull ───────────┘
(revision-aware)
Push (apply): walk md tree, hash-diff against state, import changed files
via lark-cli drive +import, in-place insert/delete blocks, upload mermaid
PNGs, restore callout block types, patch cross-doc links, move into wiki.
Pull (pull): for every file recorded in state, fetch the docx
revision_id and the block tree. Compare hashes both sides:
| local hash | remote revision | result |
|---|---|---|
| unchanged | unchanged | clean — skip |
| unchanged | changed | remote-only — overwrite local md from Feishu |
| changed | unchanged | local-only — skip; you should apply instead |
| changed | changed | conflict — abort (use --force-remote or --force-local) |
Conflict aborts default to safe — no local changes are touched until you choose a side.
Reverse-sync coverage
Round-trip fidelity per Feishu block type:
Lossless
| Feishu block | Markdown | Notes |
|---|---|---|
| Text paragraph | plain text | bold / italic / code / strike / link preserved |
| Heading 1–9 | # … ######### |
|
| Bullet / ordered list | - / 1. |
nested via 2-space indent |
| Todo | - [ ] / - [x] |
|
| Code | ```lang |
language id mapped per Feishu reference |
| Quote | > |
|
| Equation | $$…$$ (block) / $…$ (inline) |
|
| Divider | --- |
|
| Table | GFM | column widths preserved via state |
| Image (mermaid) | ```mermaid |
source preserved in state, restored on pull |
| Image (other) |  |
downloaded to <md_dir>/.assets/ |
Lossy with restoration
| Feishu block | Pull writes | Push restores? |
|---|---|---|
| Callout | <!-- larkmd:callout emoji=fire bg=1 -->\n> body |
✅ marker tracked, callout block reconstituted post-import |
Lossy degradation (no restoration)
| Feishu block | Pull writes | After push |
|---|---|---|
| Bookmark | <!-- larkmd:bookmark -->\n[url](url) |
becomes plain link |
| File attachment | <!-- larkmd:file token=… -->\n[file: name](token) |
becomes plain link |
| Iframe | <!-- larkmd:iframe -->\n[embed](url) |
becomes plain link |
| Sheet / bitable embed | <!-- larkmd:sheet token=… -->\n[sheet](url) |
becomes plain link |
| Sync block | <!-- larkmd:sync_block -->\n<children> |
sync wrapper lost; content stays |
| MindNote / Whiteboard / Diagram | <!-- larkmd:mindnote token=… --> placeholder |
content unrecoverable from md |
The <!-- larkmd:* --> markers are stripped before push so they don't appear
in the rendered Feishu doc. They exist as breadcrumbs for the human reader of
the markdown — "this used to be a callout in Feishu" — and to drive the
callout restore step. Markers you write yourself (<!-- TODO -->, etc.) are
left alone.
Out of scope (v0)
- New Feishu docs not in state —
pullwon't auto-create local md (path inference is ambiguous). Runapplyfrom the local side first. - Comments / reactions / version history.
- Parallel pull (Feishu has aggressive per-doc rate limits).
Mermaid round-trip
md: ```mermaid Feishu doc:
graph TD; A-->B [image: PNG]
``` ↑
↓ apply │
mmdc renders → PNG ───────────────────┘
upload returns image_token
→ state.mermaid_blocks[image_token] = "graph TD; A-->B"
↓ pull (later)
image block has token "T"
state has T → write ```mermaid graph TD; A-->B``` (no PNG download)
If you want a fully byte-equal apply → pull round-trip on a mermaid-only
file: state must already know the source. The very first pull after a push
performed by an older larkmd (no mermaid_blocks entry) will fall back to
the plain  form.
Callout restoration (deep dive)
Callouts are popular and have no native markdown form, so we go to extra lengths to keep them as callouts after a pull → push cycle:
-
Pull writes the marker + a quote:
<!-- larkmd:callout emoji=fire bg=1 --> > hot tip -
Push pre-process (in
_sync_one): scan for callout markers, replace each with a unique placeholder paragraph that survives Feishu's markdown importer as plain text:LARKMD_CALLOUT_PLACEHOLDER_0 > hot tip -
drive +importingests as normal — placeholder becomes a paragraph block, quote becomes a quote block. -
Post-import patch (
callout_restore.restore_callouts): walk the docx, find each placeholder paragraph, claim the next sibling (must be a quote), build a real callout block (block_type=19) carrying the quote's elements as a child paragraph + adopting any nested children, insert via the descendant API, then delete the placeholder + quote pair. -
If the placeholder can't be found or the next block isn't a quote, the marker is silently skipped — the placeholder paragraph is left in the doc as a visible breadcrumb. We never delete content we can't replace.
Other lossy block types (bookmark / iframe / sheet / etc.) are not restored this way; their markers exist purely as documentation.
Why use this over feishu-cli / feishu-docx?
| larkmd | feishu-cli / feishu-docx | |
|---|---|---|
| Stateful mirror (git tree → wiki tree) | ✅ | ❌ (one-shot import) |
| Bidirectional with conflict detection | ✅ | ❌ |
| Incremental sync (content hash + revision) | ✅ | ❌ |
| Transactional update (no empty docs) | ✅ | ❌ |
| Cross-document link patching | ✅ | ❌ |
| Table column-width memory across syncs | ✅ | ❌ |
| Mermaid round-trip (md ↔ docx PNG ↔ md) | ✅ | ❌ |
| Callout type restoration on push | ✅ | ❌ |
| Wiki node hierarchy from dir tree | ✅ | ❌ (CLI per node) |
larkmd is for maintaining a wiki from a markdown source of truth (with
occasional UI edits flowing back), not for one-off conversions.
Gotchas (you'll hit these on day one)
-
lark-cli silently keeps only the first link in a multi-link line. Put each
[a](x)[b](y)on its own line, or use one as inline and the other in a footnote.larkmd doctorwarns on offending lines. -
Mermaid emoji / Chinese render as boxes unless you install Noto fonts:
sudo apt install fonts-noto-cjk fonts-noto-color-emoji
-
applyis insert-then-delete. If anything betweenimport_mdanddescendant createfails, the doc may briefly contain old + new content. Re-runningapplyself-heals. -
Edit in Feishu freely — but pull first. Reverse sync is revision-aware, so the cycle is:
pullto grab UI edits → review/commit →applyto push your local changes back.pull-plantells you who's ahead. -
Table column widths require a Feishu UI nudge. Markdown has no width. Once you adjust a column in Feishu's UI,
larkmdrecords it in the state file and re-applies on every sync. Adding/removing a column resets that table's saved widths. -
lark-cliargv is capped at ~128 KB. larkmd auto-switches large payloads to--data @file(relative path required by lark-cli). -
Wiki delete needs
wiki:wikiscope. Iflarkmd cleanupreports131005, re-auth:lark-cli login --as user --scopes wiki:wiki .... -
First pull after upgrading from a no-revision state file treats every file as
remote-only. Runlarkmd apply --forceonce to record the currentlast_remote_revisionbaseline, then subsequent pulls behave normally.
Project status
0.x releases — schema may break between minor versions. State file carries
a schema_version and migrates automatically.
- v0.1: one-way push mirror, all gotchas above patched
- v0.2 (current): bidirectional sync, mermaid round-trip, callout restore, conflict detection
- v0.3: parallel sync respecting Feishu rate limits, new-doc discovery on pull
Development
git clone https://github.com/dzwalker/larkmd
cd larkmd
pip install -e ".[dev]"
pytest
E2E tests are opt-in (need a real Feishu test workspace) — see tests/e2e/README.md.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file larkmd-0.2.0.tar.gz.
File metadata
- Download URL: larkmd-0.2.0.tar.gz
- Upload date:
- Size: 143.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fcd126a7fd88b022c142389da4461fd17bf511366b19ddca38c732df28298993
|
|
| MD5 |
0d7defa3abd532cb7e303aa265444c2a
|
|
| BLAKE2b-256 |
5c9be99ec3c89a234411856635b9babcd0a15ffd109c00b93fe9be0c68a2bc4e
|
File details
Details for the file larkmd-0.2.0-py3-none-any.whl.
File metadata
- Download URL: larkmd-0.2.0-py3-none-any.whl
- Upload date:
- Size: 65.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
06dcd9b6559430a74e9064c72a920694358bb0a34b6212f70b68970c62876ea0
|
|
| MD5 |
8556e6506d92fa3e28eea2eaa1213d9f
|
|
| BLAKE2b-256 |
43ce7a9361d45634ee943a4a80fa4cb518d09b3c50f2e9c8963be87d9e615630
|