A command line tool for Markdown and Confluence: works with agents, works with GitHub Actions, works with you.
- Manage Confluence content outside of Confluence. markfluence works with one file, or with a project of many files. It supports page trees, images, attachments, and labels.
- GitHub-Flavored Markdown with Confluence additions. markfluence supports base Markdown and more: tables, callouts, table cell background color, the table-of-contents macro, links, anchors, and Confluence user mentions. The files stay correct in GitHub and in Markdown preview programs.
- Content round trip.
exportdownloads a Confluence page, a page tree, or a whole space to your machine. Those files publish back to Confluence. You can also write new files on your machine and publish them. The result is semantically equivalent, but not byte-for-byte the same. The content survives the round trip. - Batch publish. Publish one page, or a whole tree at the same time. Under
a
markfluence.yaml, a page with no changes gets no new version: markfluence does not send its body again. - Confluence storage format. You can write Confluence-specific markup with no Markdown equivalent.
- Offline validation of Markdown files. markfluence finds dead links, broken images, and bad frontmatter with no network and no credentials.
- Confluence search.
findresolves an exact title to page ids and folder ids, and it sees archived pages and folders.searchdoes a full-text search and shows an excerpt for each hit. It also takes raw Confluence Query Language (CQL).user-findresolves a person's name to the account id that a mention needs. It also prints the Markdown line that mentions them. - Diff.
diffshows what is different between your file and the page in Confluence. The output works withpatch -R. The exit codes are the same as the exit codes ofdiff. - CI publish workflows. Keep your documents in a repository and publish them to Confluence when a change merges. Use markfluence-action. It is one step, and it publishes the files that changed and no other files.
- Care with the edits of other persons. Under a
markfluence.yaml, markfluence keeps a local event log. With that log, it refuses to overwrite a page that somebody else changed after you made your copy. It also skips a publish when the body did not change. It does not delete attachments. The--forceflag is the escape hatch. - Personal access tokens and scoped service account tokens. markfluence supports both token types correctly.
--dry-runon the publish and export commands. You can see what will happen before it happens.--json, correct exit codes, shell completions, terminal detection, and correct use of stdout and stderr. Every command can write JSON for scripts and for AI agents. markfluence generates its own shell completions. It colors its output, but not when the output is not a terminal. It sends each message to stdout or to stderr as a shell script needs.
This file tells you how to install markfluence, how to configure it, and how to use it. It also tells you where to find more documentation.
| README.md, this file | installation, configuration, and use |
| docs/commands/ | the --help text of every command, as Markdown. It is the same text that markfluence CMD --help prints, and it comes from the binary |
| docs/markdown-file.md | the page format: every frontmatter field, and what the converter does with each body construct |
| docs/credentials.md | where markfluence reads credentials from, how to set up your credentials file, and what each credentials error means |
| docs/root-model.md | the documentation root: how a tree of files maps to a tree of pages |
| CONTRIBUTING.md | how to contribute: the development setup, what to run before you open a pull request, the commit conventions, and how to file an issue |
| docs/confluence/ | what we found out about Confluence by experiment: the API, the storage format, the scopes, and the traps that give you a confident wrong answer |
| docs/design-principles.md | the design principles that guide markfluence, and the trade-offs they accept |
| docs/json-output.md | --json in detail: the status verbs, what counts as a result, and why the shapes are what they are |
| schema/json-output/v1.json | the --json schema. The markfluence schema command also prints it |
These are the ways to run Confluence, and the support that markfluence gives to each one:
| Confluence | markfluence support |
|---|---|
| Cloud: Standard, Premium, Enterprise | Supported. |
| Cloud with a custom site domain | Supported. |
| Atlassian Government Cloud, or isolated Cloud | Not tested. The Atlassian documents show the same APIs and the same identity model. We think it works, but we did not test it. |
| Data Center | Not supported. |
| Server | Not supported. Its end of life was in February 2024. |
This repository is its own tap, so the tap needs
an explicit URL. The repository is not named homebrew-markfluence, and that
is the name that brew tap looks for.
brew tap mozilla/markfluence https://github.com/mozilla/markfluence
brew trust mozilla/markfluence
brew install markfluenceTo upgrade, use brew update && brew upgrade markfluence. Homebrew installs
the shell completions where each shell looks for them, so you have no more
work to do.
markfluence has a build for Apple Silicon, and it has no build for Intel. macOS
26 Tahoe is the last release that Apple ships for Intel Macs, because macOS 27
needs Apple Silicon. Thus brew install finds no build on an Intel Mac. On an
Intel Mac, build markfluence from source. See below.
Get the archive for your architecture from the
latest release. Make
sure that its checksum is correct, then put the binary on your PATH. Change
these commands as necessary for your machine:
VERSION=0.1.0 # no leading "v"
ARCH=amd64 # or: arm64
BASE="https://github.com/mozilla/markfluence/releases/download/v${VERSION}"
ARCHIVE="markfluence_${VERSION}_linux_${ARCH}.tar.gz"
curl -fsSLO "${BASE}/${ARCHIVE}" && \
curl -fsSLO "${BASE}/checksums.txt" && \
sha256sum --check --ignore-missing checksums.txt && \
tar -xzf "${ARCHIVE}" markfluence && \
install -D -m 0755 markfluence ~/.local/bin/markfluenceThese commands tell tar to extract the markfluence file and no other file.
The archive is flat. It also holds a README.md file, a LICENSE file, and
a completions/ directory. A tar -xzf command with no file name writes over
your own copies of README.md and LICENSE. To get the completion scripts,
see Shell completions.
You need Go 1.25 or a later version.
go install github.com/mozilla/markfluence@latestA binary from this command shows its version as dev. The release build
applies the version stamp with linker flags, and go install does not use
those flags. If you think that you will report a bug for a specific version,
use a release archive instead.
You can also build from a clone. This is also the development setup:
git clone https://github.com/mozilla/markfluence
cd markfluence
make install # installs `markfluence` into your Go bin directory
# ...or build into ./bin and do not install:
make build # makes ./bin/markfluencemarkfluence generates its own completion scripts for bash, zsh, fish, and
PowerShell. The release archives hold the bash, zsh, and fish scripts in the
completions/ directory. A Homebrew install puts them where each shell looks
for them, so a brew install needs no more work. The archives do not hold the
PowerShell script. markfluence generates that script when you ask for it. See
below.
To load the completions into the current shell:
source <(markfluence completion bash) # bash
source <(markfluence completion zsh) # zsh
markfluence completion fish | source # fishTo install them permanently, run markfluence completion <shell> --help. It
prints the path that your shell reads on your platform. This is an example for
Linux with bash:
markfluence completion bash > /etc/bash_completion.d/markfluenceCompletion gives you Markdown file names for a FILE argument or a PAGE
argument. You type the page id form and the URL form of PAGE yourself.
Completion also gives you the values of flags such as --page-width and
--format, and directory names for --dest. Completion does not give you
attachment names. Attachment names are on the server, and completion never
makes a network request.
markfluence needs a Confluence site URL, a username, and an API token. Put them in your credentials file one time on each computer:
markfluence credentials-init
This command asks for the three settings, checks them with Confluence, and
writes ~/.config/markfluence/credentials with mode 0600. For a site at
atlassian.net, it also saves the cloud ID of the site. You can also write the
file yourself: docs/credentials.md shows how.
The environment variables of the same names override the file, and a file that
you name with --env-file PATH overrides both. There is no command-line flag
for any of these, so your API token cannot get into your shell history.
markfluence never reads credentials from the working directory or from a
project.
docs/credentials.md is the reference: the places that markfluence looks in, the two rules that stop it from sending a token to the wrong site, how to use a different site for one command, the permission warning, and what each credentials error means.
Optional: alias mf=markfluence
You must set CONFLUENCE_CLOUD_ID for a scoped API token, in the same place
as CONFLUENCE_URL. markfluence credentials-init does this for you. A normal
personal API token works with or without a cloud ID. markfluence reads the cloud ID only from the place that
gives the URL, and warns about a cloud ID that it ignores. An Atlassian
service account gets a scoped token, and you use it to publish from
CI. Atlassian refuses a scoped token with a 401 status against your site
domain. Thus markfluence must use the api.atlassian.com gateway of Atlassian,
and that gateway needs the cloud ID.
To find your cloud ID yourself, use this command. The cloud ID is not a secret:
$ curl -s https://your-org.atlassian.net/_edge/tenant_info
{"cloudId":"d8febd08-5555-5555-5555-db37c2369ce5"}markfluence needs these scopes. You can copy this list:
read:page:confluence
write:page:confluence
read:space:confluence
read:folder:confluence
search:confluence
read:confluence-user
write:confluence-file
readonly:content.attachment:confluence
read:confluence-content.summary
write:confluence-content
read:content-details:confluence
Note
Atlassian fixes the scopes when it issues a token. If a scope is absent, you must create a new token.
Note
The mixture of two name styles is correct. It is not a copy-paste error.
Atlassian has two scope vocabularies: the classic vocabulary, such as
read:confluence-user, and the granular vocabulary, such as
read:page:confluence. Atlassian grants them independently, so a token with
one vocabulary does not hold the other. markfluence uses both API
versions, and each version accepts one vocabulary only.
Use this table to diagnose a failure:
| Symptom | Meaning |
|---|---|
401 Unauthorized; scope does not match |
A scope is absent. Issue a new token |
| 403 | The token has the correct scope for the request, but the account does not have Confluence permission for that space or that page. Grant the access. A new token does not help |
docs/confluence/api.md is the reference. It gives these details:
- The scope that each API request needs, and how we found that out.
- Why the list mixes the two vocabularies.
- Which 3 requests Atlassian no longer documents.
- How to find the scopes that a token holds.
Atlassian has no introspection endpoint. But the scope gate runs before Confluence routes the request, so one request for each scope gives you the answer.
Each command explains itself. markfluence COMMAND --help is the reference
for what the command does, why it does it, and how you run it. This section
tells you which command to use.
To publish Markdown to Confluence:
create |
Make new pages from files that have no page_id yet. It does a check of every file first, and it creates no page if one file would fail |
update |
Publish files that already have a page_id again. Under a markfluence.yaml, it skips a file that did not change |
check |
Do a check of files with no network and no credentials: dead links, broken images, and bad frontmatter |
diff |
Show what is different between one file and its page. stdout is a patch. It exits with 1 when the two are different |
To get things out of Confluence:
read |
Print one page to stdout as Markdown, or as raw storage format |
export |
Write a page, a page tree, or a whole space to files, with the attachments |
page-info |
Show the metadata of one page: the space, the parent, the version, the width, the labels, and the authors |
space-info |
Show the metadata of one space: what you can do in it, the page statuses that it gives you, and exact page counts |
user-info |
Show who these credentials are and where they can publish. It also shows who an account id names |
To find pages:
find |
Resolve an exact title to page ids and folder ids. It sees archived pages and folders, and search cannot see them. A folder id is correct as a parent, and as the target of children and export |
search |
Do a full-text search, for when you do not know the title. It takes raw CQL with --cql |
children |
List what is below a page, a folder, or a space |
user-find |
Resolve the name of a person to the account id, and to the Markdown line that mentions them |
Attachments. The create and update commands do the work for the images
of a page. These commands are for all the other attachments:
attachment-list |
Show what is attached to a page |
attachment-upload |
Attach a file. It skips a file when the checksum already agrees |
attachment-download |
Get attachments back to the paths that they were published from |
The schema command prints the --json
schema. The
credentials-init command
writes your credentials file.
Every command except credentials-init takes --json. The create, update, export,
attachment-upload, and attachment-download commands take --dry-run.
Use markfluence-action.
This action publishes the files that changed in a GitHub repository to Confluence.
To edit a page that already exists:
# what is its page id?
markfluence find --space SRE "Deploy runbook"
# download it and all its attachments
markfluence export 1234567890
# ...edit the file that it wrote...
# is this Markdown correct?
markfluence check deploy-runbook.md
# publish the changes to Confluence
markfluence update deploy-runbook.mdTo create a new page:
vi deploy-runbook.md
# ...write the file...
# make sure that the Markdown is correct
markfluence check deploy-runbook.md
# create the page at the top level of the ENG space
markfluence create --space ENG deploy-runbook.md
# ...make some edits...
# make sure that the Markdown is correct
markfluence check deploy-runbook.md
# publish the edits
markfluence update deploy-runbook.mdTo export a whole tree of pages and edit them:
# export a whole tree of pages, with the attachments that they reference
markfluence export --depth all --dest docs 1234567890
# ...make edits...
# make sure that the Markdown is correct
markfluence check docs/*.md docs/**/*.md
# update each page that changed
markfluence update docs/*.md docs/**/*.mdTo get changes that somebody made in Confluence, do a read and then an edit.
Every command that writes goes one way, from your files to the page. The
page-info command shows the labels and the width of a page. The read
command prints the page as Markdown. The export command writes the whole page
to disk, with its frontmatter. No command writes the frontmatter of a file that
already exists from a page.
Look at the shape of these 3 commands before you run them. The children
command indents each row by its depth. It keeps the TYPE column and the ID
column in line, so you can use grep on the output:
TYPE ID TITLE
folder 2876047392 Articles
page 1675427879 MozCloud planning
page 1671692338 MozCloud observability focus and issues
The find command reports current pages, archived pages, and folders together.
All 3 types can hold the title that you asked for, but one type only is a page
that you can publish to:
TYPE ID SPACE STATUS TITLE URL
page 1675427879 ENG current Deploy runbook https://…
page 1293156436 CLOUDSERVICES archived Deploy runbook https://…
The search command prints a block for each hit, not a row. The excerpt tells
you why the page matched. markfluence shows the terms that Confluence marked
in reverse video:
Deployment runbook
page 2064154670 PXI
https://org.atlassian.net/wiki/spaces/PXI/pages/2064154670/Deployment+runbook
Keep an eye on the deploy-packages job...to be deployed)
Showing 2 matches; more exist (use --limit all).
The persistent --json flag makes a command write one machine-readable JSON
document to stdout. The command does not write its human output. Use this flag
for scripts and for CI. The output pipes to jq without trouble:
markfluence page-info 1234567890 --json | jq '.results[0].page_width'
markfluence update docs/*.md --json | jq '.summary'The output is a stable envelope with a version. The results field always
holds one object for each target. It holds one element for page-info and for
read. The summary field holds the counts for the batch:
{
"schema_version": 1,
"markfluence_version": "1.4.0",
"command": "update",
"roots": ["/repo/docs"],
"warnings": [],
"results": [
{
"ok": true,
"status": "published",
"dry_run": false,
"file": "docs/foo.md",
"page_id": "123",
"title": "Foo",
"space": "ENG",
"url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo",
"version": { "previous": 3, "new": 4 },
"page_width": { "value": "max", "default": false },
"attachments": [ { "action": "updated", "filename": "diagram.png" } ],
"warnings": [],
"broken": [],
"error": null,
"code": null
}
],
"summary": { "total": 1, "succeeded": 1, "failed": 0, "skipped": 0 }
}The full contract is a JSON Schema in draft 2020-12 at
schema/json-output/v1.json. The command field
selects the shape of each results item and the shape of summary. The error
object on stderr is #/$defs/errorObject. A test does a check of the real
output of markfluence against the schema, so the schema cannot drift from the
code.
The binary holds the same schema. Thus a consumer can get the contract and
know nothing about this repository. See schema.
docs/json-output.md gives these other details:
- The status verbs of each command.
- What counts as one result for each command.
- The
brokenstatus ofcheck. - The preflight abort of
create. - Why
findandsearchreport an operational failure on stderr, and not as a result.
These are the exit codes:
| Exit code | Meaning |
|---|---|
0 |
Success. This includes "no matches", because that is an answer that a caller acts on |
1 |
A failure for one file or one target. The envelope is still on stdout. Each failed result has ok: false and an error value and a code value |
2 |
A fatal preflight failure, such as a bad flag or a credential that does not resolve. There is no envelope. A typed error object goes to stderr instead |
The diff command is the one exception. It uses the exit codes of diff(1):
0 when the two are the same, 1 when they are different, and 2 for any
trouble. Thus if markfluence diff FILE >/dev/null 2>&1 means "the file and
the page agree". The operational failures of diff give a 2, and every other
command gives a 1 for them.
{ "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG", "warnings": [] }These are the error code values: CONFIG, AUTH, NOT_FOUND, VALIDATION,
CONVERT, IO, NETWORK, API, and CONFLICT. update gives CONFLICT
when it refuses to overwrite a page that somebody changed after you made your
copy. That check needs a markfluence.yaml.
Usage: markfluence schema
This command prints the JSON Schema for the --json
output to stdout. The binary holds the schema, so a script, a
CI job, or an agent can get the contract while it runs. It can do a check of
the output of markfluence, or it can generate types from the schema. It does
not read the schema from this repository:
$ markfluence schema | jq -r '.properties.command.enum | join(" ")'
page-info space-info user-info read update create check diff children find search user-find attachment-list attachment-upload attachment-download export
$ markfluence update docs/*.md --json > out.json
$ markfluence schema > schema.json
$ check-jsonschema --schemafile schema.json out.jsonThe document that it prints is byte-for-byte the same as
schema/json-output/v1.json at the revision that
the binary was built from. It describes the schema_version that the binary
writes. The tests do a check of the real output against the same embedded copy.
Thus the schema that you get from a binary is the schema that its output was
checked against.
This command does not talk to Confluence, so it needs no credentials. The
output is already JSON, and --json changes nothing.
Each Markdown file is one Confluence page. A file has an optional YAML frontmatter block, and then the Markdown body.
---
title: My Page Title
space: ENG
parent: null
page_id: 1234567890
page_status: Ready for review
page_width: max
---
# Body starts here
...
docs/markdown-file.md is the reference for the page format. It gives every frontmatter field and what each verb does with it. It also gives every body construct:
- Tables and the conventions for their cells.
- GitHub alerts.
- Images, and how their paths resolve.
- Links between pages.
- Mentions.
- Confluence storage markup that you paste in.
Do you need a markfluence.yaml file?
- If you export, edit, and publish files one at a time, you do not need
it to publish. The root of each file is its own directory, and that
directory is already the directory that you want. But without a
markfluence.yaml, markfluence keeps no event log. Thusupdatecannot find a page that somebody changed after your export, and it overwrites their change.updatealso sends the body again when nothing changed, and each publish makes a new page version. To get these protections, put an emptymarkfluence.yamlfile in the directory before you export. - If you work on a tree of directories, yes. An example is a set of pages
that link to each other, or that share an
assets/directory. Put amarkfluence.yamlfile at the root of that tree. Without one, the root of each file is still its own directory. Then a page cannot reach an image or another page that is above itself. A shared-assets layout as in docs/markdown-file.md does not work at all without a declared root.
# Marks the root of a markfluence project. Image and link paths are recorded
# relative to this directory. https://github.com/mozilla/markfluenceThe file can also hold defaults for the whole project. With these defaults,
you write space: ENG one time, and not one time in each of 100 files:
space: ENG
page_width: maxEach setting is a default, and a file overrides it. The sequence is the flag first, then the frontmatter, then the project file. Thus the answer that is nearest to the content wins. A key that markfluence does not recognise is an error, and markfluence does not ignore it. A typo in a default for the whole project is wrong for every file at the same time. Credentials are not settings here, and that is deliberate. See docs/root-model.md.
The file can also hold a pages: block. This block gives the page metadata
of each file, so the Markdown itself stays clean with no frontmatter at all:
pages:
docs/deploy-runbook.md:
title: Deploy Runbook
page_id: 12346This block is what makes markfluence update docs/**/*.md work from CI with no
input for each file. It is also why update has no --page-id flag and no
--title flag. Each of those flags can name one file only. Both locations are
legal, and markfluence says nothing when the two agree. Thus you can move the
metadata one file at a time. markfluence skips a file that neither location
mentions, and it does not fail. For the details, see
docs/root-model.md.
The other part of this section is the exact version of the same idea. Every
Markdown file has a documentation root. The root is the directory that holds
markfluence.yaml, and markfluence finds it when it goes up from the directory
of the file. If no markfluence.yaml file is above the file, the root is the
directory of the file itself. The root bounds which images and which parent:
references a file can read. The recorded source path of an image is relative to
the root. markfluence reports the root that it used one time for each different
value in a run. The --root PATH flag overrides this search for the whole
command.
For the reasons behind this model, what it corrects, and what it costs, see docs/root-model.md and _plans/025_file-organization.md. Those documents also give every setting for the whole project.
To move or rename a Markdown file. Move it. The links to it resolve by its real location, through the link index that is relative to the root. You edit nothing in the other files. You publish nothing again, except the file that you moved. Publish that file to get its own new links, if any link changed.
To move an image or another asset. Move it, with its page or without its page. The name of an attachment is the file name of the asset, and not its path. Thus a move keeps the same attachment. At the next publish, markfluence records the new path on the attachment it already has.
To rename an asset. A new file name is a new attachment. The next publish
uploads the asset with the new name. The attachment with the old name stays on
the page with no reference to it, because markfluence never deletes. Issue
#99 tracks a future
attachment-prune command.
Two assets with the same file name. One page cannot reference two assets
with the same file name, such as arch/diagram.png and deploy/diagram.png.
markfluence refuses to publish that page, because the two assets would get
the same attachment name. Rename one of them.
pchuri/confluence-cli for the command line interface. markfluence tries to match the subcommands and the arguments of confluence-cli. markfluence gives more attention to the publish of Markdown documents, and less attention to a CLI for the full Confluence v1 and v2 API.
kovetskiy/mark for Markdown support in Confluence, and for how it represents things. markfluence tries to match the key design decisions. It has defaults that I prefer, and it works better in different scenarios.