No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-25 13:52:01 +02:00
src Make dry runs read the wiki and name every action 2026-08-25 13:52:01 +02:00
tests/fixtures/vault Select folder documents by index tag and export members opt-in 2026-08-12 19:07:06 +02:00
.gitignore Initial 2026-08-12 19:07:06 +02:00
Cargo.lock Translate several documents at once 2026-08-18 20:46:22 +02:00
Cargo.toml Translate several documents at once 2026-08-18 20:46:22 +02:00
LICENSE-APACHE License under MIT or Apache 2.0 and fill package metadata 2026-08-12 19:07:06 +02:00
LICENSE-MIT License under MIT or Apache 2.0 and fill package metadata 2026-08-12 19:07:06 +02:00
README.md Make dry runs read the wiki and name every action 2026-08-25 13:52:01 +02:00

peili

Peili (Finnish): a mirror. The vault is the original and the wiki is its reflection, updated one way and never edited back.

Peili walks an Obsidian vault, selects the notes and folders that opt in through their frontmatter, converts Obsidian markdown into Azure DevOps wiki markdown, and upserts the result as wiki pages through the Azure DevOps REST API. It creates and overwrites pages; it never deletes them.

Why

Notes accumulate in Obsidian; the team reads the project wiki. Copying pages over by hand produces two diverging documents and no way to tell which one is current. Peili makes the wiki a build artifact of the vault: a note opts in, a sync re-renders it, and editing happens in exactly one place. The wiki side is disposable by construction -- every exported page carries a marker naming the note it mirrors, and re-running the sync restores it.

What it does

vault -> scan (select) -> plan (paths, order) -> convert (markdown) -> upsert -> wiki

A sync resolves in four steps:

  1. Scan. Walk the vault and collect markdown files. A single note is selected when its frontmatter has peili_export: true. A folder is selected when it contains an index note, one whose frontmatter has peili_index: true and a peili_path; the folder's notes and nested subfolders belong to it, and each member note still opts in individually with peili_export: true -- a member without it is simply not exported. The index filename carries no meaning -- CATALOG.md is merely the convention, so Obsidian shows a sensible title. An index note nested inside another index note's folder does not open its own export; pathless, it becomes its directory's page, and with a path it is reported as a conflict, since only the export root may set one. Two index notes in one folder would double the subtree, so the first by filename wins.
  2. Plan. Map every selected note to a wiki path, sort parents before children, and synthesize an empty page for any directory level that has no note of its own. The plan also indexes every note by basename, which is what wikilinks resolve against.
  3. Convert. Rewrite wikilinks, image embeds, and callouts; leave code spans alone.
  4. Upsert. Create or update each page in place, uploading its images as wiki attachments first, and move it to its planned position -- vault sort order. Pages peili does not manage end up below the managed ones under a shared parent.

Folders and pages

DevOps wikis have no folders, only pages with children, so directories map onto pages:

  • A directory becomes the page at its path; its notes and subdirectories become child pages.
  • A folder note gives a directory page content, in one of three forms: a pathless note tagged peili_index: true under any name, the sibling name form (Sub.md next to Sub/), or the inner name form (Sub/Sub.md). Each becomes the page at .../Sub with the directory's contents as children. When several claim the same page, the tagged note wins, then the sibling over the inner; losers are reported and skipped.
  • The index note is the folder note of the export root: body text below its frontmatter fills the root page.
  • A directory with no folder note gets an empty placeholder page.

Renames and orphans

With a root configured, peili inventories the marked pages under it before writing. When the plan wants to create a page whose source note already has a page at another path -- its peili_path changed, the root moved, an index note was re-pathed -- peili moves the existing page instead of recreating it. A move keeps the page id, so comments, history, and followers survive, and it carries the whole subtree along.

A marked page whose path the plan no longer produces, and which no move consumed, is reported as an orphan (orphan <path>: from <note>) on stderr. Nothing is deleted, and orphans do not fail the run; cleaning them up stays a manual decision.

Renaming the note file itself changes the source path on both sides, so path-based matching cannot see it. A note can therefore carry a stable identity: peili_slug: <anything unique> in its frontmatter, stamped into the page marker. Rename detection matches slugs before source paths, so a slugged note keeps its wiki page -- and its comments -- across file renames and moves. Slugs must be unique per sync; notes sharing one are reported and skipped. A slug is opt-in per note and only needs to be stable: a document number like adr-002 or rfc-001 is a fine slug, since it never changes even when the title does.

Translations

A note can ask for translated variants of itself, rendered as additional pages in the same sync. The note names the target languages; a [translate.<lang>] config section customizes a language when the defaults are not enough:

peili_translate: de,es,pt        # or a YAML list
[llm]
model = "claude-sonnet-5"      # default model; key from ANTHROPIC_API_KEY
# provider = "openrouter"      # key from OPENROUTER_API_KEY; models are
                               # vendor-prefixed (anthropic/claude-sonnet-5)
# url = "http://localhost:4141/v1/chat/completions"
                               # any endpoint speaking the provider's dialect
                               # (a local proxy, ollama); the key becomes
                               # optional

[translate]
placement = "subfolder"        # global mode: subfolder (default), sibling, suffix

[translate.de]                 # optional, per language
prompt = "Translate into German; keep Anglicisms common in German IT usage."
# placement = "sibling"        # mode override, keeps the default name
# subfolder = "DE"             # explicit name overrides mode and name
# footer = "..."               # major languages have built-in texts
# model = "..."                # per-language override

Every translated page opens with a notice in its own language, linking back to the original document; a configured header sits above it. Everything has a default, so peili_translate: de works with no config at all: the prompt derives from the language ("Translate the document into German."), and pages land by the global placement mode under the language's own name for itself -- Deutsch/guide (subfolder, the default), a Deutsch folder alongside (sibling), or guide - Deutsch (suffix). Unknown language codes fall back to the uppercased code.

The engine is Anthropic's API, or OpenRouter's with provider = "openrouter"; url points the chosen dialect at any other compatible endpoint (a local proxy, ollama), and then the API key may be absent. The prompt serves as system prompt, and the existing translation is passed along, so unchanged parts keep their wording. Translations are streamed, so a long one stays alive: a large document can take ten minutes or more, and peili gives up only when nothing arrives for two minutes. An endpoint that ignores streaming still works.

Documents are translated several at a time, four by default -- concurrency under [llm], or --concurrency N. Translating is one long serial write per document, so overlapping them is most of what can be done about the wall clock: a page waits only for its own translation, not for the ones before it. Raise it if your endpoint has room, lower it to 1 if you are hitting rate limits. The work runs before any page is written, so an interrupted sync loses the translations it had not written yet; a re-run redoes only those. Translated pages that carry no peili_slug keep to the old sequential path, since they are found by a path that is only known once their parent has landed.

On an index note, peili_translate mirrors the whole folder: the subtree reappears at the translated base (sibling renames the folder itself, so /EN becomes /DE), placeholders included. Members can also opt in individually relative to their own page.

Translated pages get translated titles, except the base page of a translated folder -- its name belongs to the placement (Deutsch, or a configured subfolder). Translated titles make variant paths dynamic, so translated notes must carry a peili_slug: it is the variant's identity. A re-translation that changes the title moves the page, keeping its id and comments; a note without a slug has its translations refused with a reason. Wikilinks inside translated pages prefer the linked note's variant in the same language and fall back to the original, and links to pages that moved are patched at the end of the run (relinked lines).

A variant whose source note has not changed since its last translation is fresh: no API call, no write -- only changed notes cost tokens. Freshness follows the source text, so changing the prompt, the model, or the language settings leaves every existing page fresh; --retranslate asks for the work anyway. Bare, it takes every translation; --retranslate=TEXT takes the notes whose source path or slug contains TEXT, which is how to redo one document without paying for the rest. A forced translation is not given the previous one to follow, since that is what would carry the old wording back in. Variants whose note dropped the language are reported as orphans. Generic, non-translation transformations are out of scope; they belong to a future external-tool hook.

Frontmatter

peili_export: true                       # opt-in (required, truthy)
peili_org:     https://dev.azure.com/org # org URL
peili_project: MyProject                 # project name
peili_wiki:    MyProject.wiki            # wiki name
peili_path:    /Engineering/Runbooks     # required, target wiki path

The peili_ key prefix is configurable (--prefix flag or prefix in the config, flag wins); prefix = "devops_" keeps notes written for the earlier key names working.

org, project, and wiki resolve for each selected note or folder from three layers: the note's frontmatter wins, then the --org / --project / --wiki flags, then the same-named config keys. path always comes from the note, so a minimal note needs only peili_export and peili_path. A note still missing org, project, wiki, or path after all layers is reported and skipped; the rest continue.

A root (--root flag or root in the config, flag wins) confines the export: every peili_path is placed underneath it, and a path with . or .. segments is refused instead of resolved, so no page above the root is ever written. Within the root the subtree is peili's: any missing ancestor page -- a deep peili_path, a translation's sibling folder -- is synthesized as an empty placeholder; only the root chain itself must pre-exist. The root is a wiki path, or the permanent id of the root page -- root = 174030 in the config, --root '#174030' on the CLI. The id survives renames of the root page on the wiki side; it is resolved to the current path once per sync (the page id sits in the page's URL). An id root needs org, project, and wiki from flags or config; one read-only call resolves it per sync. Attachments sit outside the confinement guarantee -- Azure DevOps stores all wiki attachments in the wiki-global /.attachments area.

Configuration

TOML, read from .peili.toml in the vault root, so the vault carries its own export settings. Obsidian never shows dotfiles, so the file stays invisible in the app and travels with the vault repo. --config FILE reads a different file instead; unlike the in-vault one, it must exist. A malformed config aborts the run before anything is scanned, so a typo cannot silently retarget the sync.

prefix  = "peili_"                       # frontmatter key prefix
org     = "https://dev.azure.com/org"    # default organization URL
project = "MyProject"                    # default project
wiki    = "MyProject.wiki"               # default wiki
root    = "/Docs"                        # confine all exports under this path (or a page id: root = 174030)

All keys are optional; every one can also be given as a flag, and flags win.

The vault itself is not a config key -- the config is found through the vault. It resolves from --vault, then the PEILI_VAULT environment variable, then the current directory.

The cache

With a root configured, every sync needs to know the marked pages under it -- their paths, identities, and freshness. That knowledge lives in the wiki itself, and peili sweeps it when it has nothing better. After each real sync it writes what it learned to .peili.toml's sibling .peili.cache: plain TOML, one entry per page. When the file is present, the sweep is skipped entirely.

The cache is a convenience copy, never the authority. Every sync that uses it first asks the wiki which pages still exist and forgets the entries for the ones that do not, so a page deleted by hand stops being reported as an orphan and comes back on the next sync if a note still asks for it. Delete the file and the next sync sweeps; --refresh-cache does the same on demand. Whether to commit it is taste: committed, the whole team shares one view and nobody re-sweeps; ignored, it is a per-machine warm-up. Obsidian hides it either way.

Auth

One of:

  • the az CLI, logged in (az login) with access to the target wiki -- peili reuses the cached session through the Azure CLI credential;
  • a personal access token with wiki read/write scope in ADO_TOKEN.

Peili does no auth of its own and stores no credentials.

Quick start

Build (Rust, stable toolchain):

cargo build --release

Preview what a sync would do, then run it:

peili sync --dry-run
peili sync

Per-page result lines (created / updated / moved / fresh / relinked) go to stdout; diagnostics and the run summary go to stderr.

A dry run reads the wiki and says what a real run would do to every note, one line each: create, update, move OLD -> NEW, fresh for a translation it would leave alone, relink for a page whose links it would patch, and translate for one it would send to the model, with the reason and both hashes. It writes nothing at all -- no pages, no moves, no attachments, no cache -- and calls no model, so it costs nothing but a few reads. The one thing it cannot tell you is the final name of a page it would translate, because the model chooses the title; the line shows where the page is now instead. Being a read, it needs the same credentials a real run does.

Upload

Besides the mirror there is a one-shot: peili upload [FILE] --to /Path pushes a single markdown file (stdin when FILE is omitted or -) as one wiki page. Org, project, and wiki come from flags or config only, and the file ships as is: its frontmatter neither steers the target nor gets stripped or rewritten, and conversion touches only the body below it. The root confinement applies to --to, and images are handled as in sync -- but the page gets no marker and no sibling ordering: it is not part of the mirror, so later syncs neither move nor orphan-report it.

--translate de,pt additionally lands one translated page per language, with the translation notice, localized banners, and a placeholder parent when the placement introduces one. A single page's translation belongs beside it, so the one-shot default placement is suffix (/Docs/guide gets a /Docs/guide - Deutsch); a configured [translate] placement wins, and then the sync rules apply relative to --to. Paths stay as placed -- no translated titles -- and without a marker there is no freshness: every upload translates again.

CLI

peili sync [--dry-run]
peili upload [FILE] --to /Path [--translate de,pt]

Global flags, valid before or after the subcommand:

Flag Meaning
--vault PATH Vault to scan; falls back to PEILI_VAULT, then the current directory.
--prefix P Frontmatter key prefix; default peili_.
--org URL Default organization for notes whose frontmatter has none.
--project P Default project for notes whose frontmatter has none.
--wiki W Default wiki for notes whose frontmatter has none.
--root PATH Wiki path all exports live under, or #id of that page; escaping paths are refused.
--no-preserve-frontmatter Strip the note's frontmatter from the page instead of keeping it.
--footer[=TEXT] Append a footer to every page; bare flag uses the default text.
--header[=TEXT] Prepend a header to every page; bare flag uses the default text.
--refresh-cache Rebuild .peili.cache from the wiki instead of trusting it.
--concurrency N How many documents to translate at once; falls back to [llm] concurrency, then 4.
--retranslate[=TEXT] Translate again though the note has not changed; with a value, only notes whose source path or slug contains it.
-v / -vv Explain decisions on stderr: -v says why each page updates or re-translates, -vv adds raw detail.
--config FILE Config file to use instead of .peili.toml in the vault.
--dry-run Say what a sync would do to every note, reading the wiki but changing nothing (on sync).

Failures and exit codes

Errors are handled at three levels:

  • The run aborts before anything is written when the setup is broken: a config file that does not parse, an explicitly named config file that is missing, or an unreadable vault.
  • A note or folder is skipped when it cannot be resolved: missing org, project, wiki, or path; a peili_path escaping the root; two notes claiming the same page; a duplicated slug. The skip is reported on stderr and the rest export normally.
  • A page fails when its API call fails, or when its translation fails. The error is reported and the remaining pages continue. A missing ANTHROPIC_API_KEY aborts the run as soon as a translation is due.

The process exits non-zero whenever anything was skipped or failed, so a partial mirror never passes for a complete one. Orphan reports are informational only and do not affect the exit code.

Conversion

  • Frontmatter stays on the page, minus the peili keys and with tags flattened to a sorted comma-separated string, so ADO renders it as the page's metadata table. --no-preserve-frontmatter (or preserve_frontmatter = false in the config) strips it instead.
  • [[Note]] / [[Note|alias]] become links to the matching exported page, or plain text when the target is not exported.
  • Callouts > [!type] Title become a bold-titled blockquote.
  • Image embeds ![[img.png]] are uploaded as wiki attachments under a content-hashed name (img-a1b2c3d4.png), so re-syncing an unchanged image is a no-op and a changed image is a fresh upload; superseded attachments stay behind unreferenced.
  • Code blocks and inline code pass through untouched.
  • --footer appends a footer line to every page peili writes: bare --footer (or footer = true in the config) uses the default do-not-edit text with a link to peili, a value (--footer="..." or footer = "..." in the config) replaces it. --header and header are its mirror image at the top of the page, below any frontmatter.
  • Every exported page ends with an invisible HTML comment naming its vault-relative source note (<!-- peili: mirrored from team/guide.md -->). It identifies peili-owned pages and is the basis for a future prune of orphaned pages.

Status

The parsing, conversion, and planning are covered by tests. The page upsert (create, update, move) and the attachment upload are verified against a live Azure DevOps wiki, including a byte-identical round-trip of an uploaded image.

Implementation

Rust on Tokio: azure_devops_rust_api and azure_identity for the wiki API and auth, gray_matter for splitting frontmatter, serde_yaml_ng for parsing it, regex for the markdown rewrites with pulldown-cmark marking the code spans they must skip, walkdir for the vault scan, clap for the CLI, toml and serde for config, anyhow for error context.

A Note on Agentic Engineering

LLM agents were heavily involved in writing this code. Every change has nonetheless been reviewed by a human, who made a best effort to understand, test, and validate it before it landed. Where the agents and the human disagreed, the human had the final say -- and bears responsibility for the result, bugs included. Read it with the same scrutiny you'd apply to any code: trust, but verify.

License

Licensed under either of the MIT license or the Apache License, Version 2.0, at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.