margins-cli
v0.21.0
Published
CLI for Margins — sync and review Markdown in Git
Readme
margins-cli
CLI for Margins — review layer for Markdown in Git.
Margins is a review platform where humans and AI agents are equal participants. It renders Markdown files from a Git repository in a clean UI where reviewers can open discussions, propose changes, and approve content. margins-cli exposes every Margins action as a shell command, making it usable in scripts, CI pipelines, and by AI agents.
Installation
Install globally (recommended):
npm install -g margins-cliRun without installing:
npx margins-cli <command>Or clone and build locally:
git clone https://github.com/alvistar/margins-cli.git
cd margins-cli
npm install && npm run build
npm link # makes 'margins' available globallyClaude Code plugin
The Claude Code plugin that wraps this CLI moved to the
margins-plugins marketplace repo.
Add that marketplace and install the margins plugin there; this repo is the
npm CLI only.
Quick Start
# Log in via browser (one-time)
margins auth login
# See your workspaces
margins workspace list
# Push local markdown to a brand-new workspace (no GitHub repo needed)
margins workspace push --project my-docs --dir ./docs
# Wire `git push` to auto-sync this repo to Margins (non-blocking)
margins install-hook
# Onboard a repo to credentialless CI sync (workspace + OIDC binding + workflow PR)
margins install
# List open discussions in the current repo
margins discuss list
# Create a discussion on a file
margins discuss create --path docs/intro.md --body "This section needs a concrete example."Authentication
Three methods are supported. The active credential is resolved in priority order:
| Priority | Source | Set by |
|---|---|---|
| 1 | GitHub Actions OIDC token | MARGINS_OIDC_TOKEN, or minted in CI from ACTIONS_ID_TOKEN_REQUEST_* |
| 2 | --api-key <key> flag | any command |
| 3 | MARGINS_API_KEY env var | shell / CI environment |
| 4 | Stored static API key | margins config set-key |
| 5 | Stored Keycloak access token | margins auth login |
Browser login (recommended for humans)
margins auth loginOpens your browser to complete OAuth 2 PKCE against Keycloak. On success, the access token and refresh token are stored locally. The token is refreshed automatically before it expires — no re-login needed.
Static API key (recommended for CI / agents)
Mint a key from the Margins web UI or the API (POST /api/keys), then store it:
margins config set-key mrgn_...
# or per-invocation:
margins --api-key mrgn_... workspace list
# or via environment:
MARGINS_API_KEY=mrgn_... margins workspace listStatic keys support two scopes: comment (read + create discussions) and edit (full write access).
GitHub Actions OIDC (credentialless CI)
In GitHub Actions, workspace push authenticates with a short-lived, GitHub-signed
OIDC token — no API key stored in the repo or anywhere else. Either set
MARGINS_OIDC_TOKEN to a pre-minted token, or grant the workflow
permissions: id-token: write and the CLI mints one itself from
ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN (re-minting
automatically on a mid-push 401, since large first syncs can outlive a token's
~5-minute life). The server verifies the token against GitHub's JWKS and authorizes
by a pre-registered trust binding written via margins install.
The easiest way to wire this up is the
margins-sync-action composite
action, which margins install stamps into the repo for you. Requires Margins
server v0.21.0+.
Commands
Global Flags
Available on every command.
| Flag | Description |
|---|---|
| -v, --version | Print version and exit |
| --json | Output as JSON — for scripting and agents |
| --verbose | Enable debug logging |
| --no-color | Disable ANSI colors |
| --server-url <url> | Override the server URL (default: https://margins.app) |
| --api-key <key> | Override the API key for this invocation |
config
Manage local CLI configuration.
config show
Display the active configuration.
margins config show
margins config show --jsonShows the active server URL, the masked API key or token, and whether auth came from auth login or config set-key.
config set-key <key>
Store a static Margins API key.
margins config set-key mrgn_abc123...Saves the key to the global config file. Clears any previously stored Keycloak session.
config set-url <url>
Override the server URL (useful for self-hosted Margins instances).
margins config set-url https://margins.example.comauth
Authentication commands.
auth login
Log in via browser using Keycloak OAuth 2 + PKCE.
margins auth loginOpens a browser window to complete the OAuth flow. On completion, stores the Keycloak access token and refresh token locally. Subsequent commands use the token automatically, refreshing it transparently when it expires.
Note: The Keycloak client must have
http://localhost:*registered as a valid redirect URI. See TODOS.md for the Keycloak admin configuration step.
auth whoami
Show the currently authenticated identity.
margins auth whoami
margins auth whoami --jsonCalls GET /api/auth/whoami and displays your user ID, email, and role.
auth logout
Revoke the stored session and clear local credentials.
margins auth logoutRevokes the Keycloak refresh token and clears the stored access/refresh tokens from the config file. The server URL is preserved.
workspace
Manage Margins workspaces. A workspace is the unit of review in Margins. There are two kinds:
- GitHub workspaces — connect a GitHub repository. Margins clones the repo and syncs markdown files on demand. Created with
workspace create <repo-url>. - Local workspaces — no repository. You push markdown files directly via
workspace push --project <name>. Useful for solo work, drafts, or content that does not live in a Git repo yet.
You can also push local markdown into a GitHub workspace via workspace push --workspace <id> — the content lands on a virtual @local branch alongside the real git branches, so you can review uncommitted edits before pushing them upstream.
workspace list
List all workspaces you have access to.
margins workspace list
margins workspace list --jsonDisplays workspace slug, name, sync status, and last synced time.
workspace create <repo-url>
Create a new workspace from a GitHub repository URL.
margins workspace create https://github.com/org/repoIf a workspace for that repository already exists and you are not a member, you will be auto-joined to it.
workspace open [slug]
Open a workspace in the browser.
margins workspace open # uses slug from .margins.json
margins workspace open my-repoIf no slug is provided, reads workspace_slug from .margins.json in the current directory (or any parent).
workspace sync [slug]
Trigger a git sync to pull the latest content from the repository.
margins workspace sync # uses .margins.json
margins workspace sync my-repo
margins workspace sync my-repo --branch main| Flag | Description |
|---|---|
| --branch <branch> | Branch to sync (defaults to the workspace's default branch) |
Local workspaces cannot be synced this way — they receive content via
workspace push. Callingsyncon a local workspace returnsLOCAL_SYNC_NOT_SUPPORTED(HTTP 422).
workspace push
Push local markdown files to a workspace for review. This is the only way to
get content into a local workspace, and the way to overlay uncommitted
edits onto a GitHub workspace via the virtual @local branch.
# Create a brand-new local workspace and push files in one step
margins workspace push --project my-docs --dir ./docs
# Push more files to the same workspace later (re-use the workspace ID)
margins workspace push --workspace 0cfbdc14-c023-4c84-bc4a-e027e13cefab --dir ./docs
# Overlay local edits onto an existing GitHub workspace (lands on @local branch)
margins workspace push --workspace <github-workspace-id> --dir ./docs| Flag | Required | Description |
|---|---|---|
| --project <name> | one of | Create a new local workspace with this name. Slug becomes local/<your-username>/<name>. The name must be alphanumeric (hyphens, dots, underscores allowed). |
| --workspace <id> | one of | Push to an existing workspace by UUID. Use this for re-pushes and for pushing into GitHub workspaces. |
| --dir <path> | no | Directory to recursively scan for .md files. Defaults to the current directory. Hidden files, node_modules/, and symlinks are skipped. |
| --branch <branch> | no | Branch to push to. Defaults to the current git branch (git rev-parse --abbrev-ref HEAD). Pass it explicitly in CI, where actions/checkout may leave a detached HEAD. |
| --content-mode <mode> | no | Assert the workspace's content mode (working-tree or committed) before pushing. The push is refused if the workspace disagrees, rather than sending content collected under the wrong assumption. See workspace content-mode. |
Behavior:
- Recursively scans
--dirfor.mdfiles (max 50 per push, max 1 MB per file, max 10 MB total) - For each file, computes a SHA-256 hash of the content. If the hash matches an existing artifact, the file is skipped. Otherwise it is added or changed.
- Output (with
--json):{ "added": 2, "changed": 0, "skipped": 0 } - For local workspaces, content lands on the
mainbranch. - For GitHub workspaces, content lands on the virtual
@localbranch — visible in the branch switcher alongside real git branches, but never pushed upstream.
Example: review your local edits before committing them
cd ~/my-project # has docs/spec.md, README.md
margins workspace push --workspace <gh-ws-id> # uploads to @local
margins workspace open # opens browser, switch to @local branch
# ...review, comment, refine...
git commit -am "Refine spec" # then commit for real
margins workspace sync # pull the committed version into mainworkspace content-mode
Shows or changes what a sync sends: the working tree as it sits on disk, or the tree of the last git commit.
margins workspace content-mode # show the current mode
margins workspace content-mode committed # switch, after previewing the cost
margins workspace content-mode working-tree --yes| Flag | Required | Description |
|---|---|---|
| --workspace <id> | no | Workspace ID. Defaults to workspace_id from .margins.json. |
| --dir <path> | no | Repository directory. Defaults to the current directory. |
| --branch <branch> | no | Branch to inspect when building the preview. Defaults to the current git branch. |
| --yes | no | Accept the preview without prompting. Required when not interactive (CI, scripts) — without it, a non-TTY run refuses rather than assuming yes. |
The two modes.
working-tree(default) sends the files as they are on disk. This is what every sync did before this setting existed, and it stays the default.committedsends the tree of the last git commit. Uncommitted edits stay on your machine until you commit them — which matters most when a git hook is firing syncs for you, because a half-finished paragraph would otherwise reach reviewers the moment anything triggered a push.
Switching shows the cost first. Changing mode changes what reviewers see, so the command previews the difference between the two views — how many files would be added, removed, or differ — and asks before writing. The write itself is a compare-and-swap against the stored value, so if someone else changes the mode while you are looking at the preview, your change is refused rather than silently overwriting theirs.
The server decides on every push. The mode lives on the workspace, not in local config, and travels on the sync preflight. It is never cached locally, so a change takes effect on the next push rather than whenever a client happens to refresh.
Committed mode refuses rather than mislead. It declines to run where the result would be quietly wrong: a repository with no commits, files that exist only in the working tree, and a checkout whose line endings genuinely differ from the index.
Requires a Margins server on 0.52.0 or later. Against an older server the preflight carries no content mode and the CLI behaves as it always did.
workspace archive-branch
Archive a workspace branch — hide it from the active branch list. The branch and
its review discussions are retained and are automatically revived on the
next push to that branch. Used by the Margins sync GitHub Action's delete-event
path to archive a workspace branch when its git branch is deleted.
margins workspace archive-branch --workspace <id> --branch feat/my-feature| Flag | Required | Description |
|---|---|---|
| --workspace <id> | yes | Workspace UUID. |
| --branch <branch> | yes | Branch name to archive. |
Behavior:
- Idempotent — archiving an unknown or already-archived branch is a no-op success.
- The workspace's default branch is never archived (a no-op).
- Soft and reversible: the branch row and its discussions are never deleted; pushing to the same branch name un-archives it.
- GitHub OIDC only. The server's archive endpoint accepts only a GitHub Actions
OIDC principal — this command is meant for the sync Action's
delete-event path, not manual/local use. A stored API key is rejected with a 403.
sync
Sets a folder up for continuous sync with Margins, in one step: creates the
workspace (a GitHub overlay, or a local one if the folder has no remote), pushes
its markdown, and writes .margins.json plus a registry entry so the tray app
picks the folder up within a few seconds.
margins sync # the current directory
margins sync ./docs
margins sync ./docs --content-mode committed # non-interactive: state the mode| Flag | Required | Description |
|---|---|---|
| --content-mode <mode> | conditionally | What a sync of this workspace should send: working-tree or committed. See workspace content-mode. |
| --confirm-full-delete | no | Allow a push that would delete every file on the branch. Without it, a push that would empty the branch is refused. |
Not the same command as
margins workspace sync. This one sets a folder up;workspace synctriggers a server-side git sync on a workspace that already exists.
When --content-mode is required. The first sync of a git repository has to
settle what a sync sends, and the answer is stored on the server rather than locally —
so no local copy can go stale after someone migrates the workspace. Interactively the
command asks. With no TTY or under --json there is nobody to ask, so it refuses rather
than guessing, and the flag is how you state the choice. A non-git folder has no working
tree/commit distinction to settle, and never needs it.
stash
Publish a single markdown document to a Margins stash — a one-off, single-doc workspace for review. No repo, no workspace to pick, no folder binding. Returns a review URL.
margins stash notes.md # publish a file
cat notes.md | margins stash # or pipe markdown via stdin
margins stash notes.md --title "Q3 plan" # set the title explicitly
margins stash notes.md --json # machine-readable: { id, slug, url, format, path }
margins stash site/index.html # publish an HTML design| Flag | Description |
|---|---|
| --title <title> | Title for the stash doc. Defaults to the document's first # heading (<title> or <h1> for HTML), then the file name, then "Untitled stash doc". |
| --format <markdown\|html> | Override the format inferred from the extension. .html and .htm are designs; everything else, and stdin, is Markdown. |
| --force | Update without checking the stash has not moved since. Overwrites whatever it holds now. |
The document comes from the [file] argument, or from piped stdin when no file
is given (or the argument is -). The stash is reviewed like any Margins doc —
open discussions, address them, resolve.
HTML designs
A stash can hold an HTML document — a landing page, a mockup, an export from a design tool — instead of Markdown. Margins renders it in an isolated frame and reviewers comment on elements rather than on lines of text.
A design has to be one self-contained file, so the CLI folds local assets in
before uploading: a <link rel="stylesheet"> becomes a <style> block, and an
<img src="…"> becomes a data: URI. It prints what it inlined, because those
files leave your machine. Only stylesheets and known image types are ever read —
a reference to anything else (.env, .json) is left alone, as is anything
outside the document's own folder, any symlink, and every remote URL.
The server stores it sanitized: no scripts, no external files. Anything your page draws with JavaScript will not be there, and web fonts fall back to the next family in their stack. A document that is too complex, nests too deeply, carries too much CSS, or has nothing left after sanitization is refused, with the measurement and the limit named — nothing is stored, so fix it and re-run.
Re-running updates the same stash, and the link you sent stays valid. A design is
replaced, never merged: if the stash changed since your last push the update
is refused and nothing is written. --force overwrites deliberately, --new
publishes a separate stash. Comments survive — one on an element you did not
touch keeps its pin, one whose element you replaced is shown as not in this
version.
For images inside a Markdown document, use workspace push or the Margins
desktop app.
discuss
Manage discussions on Markdown artifacts.
discuss list [slug]
List discussions in a workspace.
margins discuss list # uses .margins.json, shows open discussions
margins discuss list my-repo
margins discuss list my-repo --status resolved
margins discuss list my-repo --path docs/intro.md
margins discuss list --json| Flag | Description | Default |
|---|---|---|
| --path <path> | Filter by artifact path | — |
| --status <status> | Filter by status: open or resolved | open |
discuss create [slug]
Create a new discussion on an artifact.
margins discuss create \
--path docs/intro.md \
--body "This section needs a concrete example."
margins discuss create my-repo \
--path docs/api.md \
--body "Consider adding a rate limit note here." \
--anchor-heading "Authentication"
margins discuss create my-repo \
--path docs/api.md \
--body "Typo: 'recieve' should be 'receive'." \
--anchor-text "recieve the response"| Flag | Required | Description |
|---|---|---|
| --path <path> | yes | Artifact path within the workspace |
| --body <body> | yes | Discussion body text |
| --anchor-heading <heading> | no | Anchor the discussion to a heading |
| --anchor-text <text> | no | Anchor the discussion to a text selection |
discuss reply <discussion-id>
Post a reply to an existing discussion.
margins discuss reply d_abc123 --body "Fixed in the latest commit."
margins discuss reply d_abc123 --body "Agreed." --workspace my-repo| Flag | Required | Description |
|---|---|---|
| --body <body> | yes | Reply body text |
| --workspace <slug> | no | Workspace slug (alternative to .margins.json) |
discuss resolve <discussion-id>
Mark a discussion as resolved.
margins discuss resolve d_abc123
margins discuss resolve d_abc123 --summary "Updated the docs to include this example."
margins discuss resolve d_abc123 --workspace my-repo| Flag | Required | Description |
|---|---|---|
| --summary <summary> | no | Short description of how the issue was resolved |
| --workspace <slug> | no | Workspace slug (alternative to .margins.json) |
stop
Stop the Margins Light daemon that margins open starts for a local folder. The
daemon outlives the browser tab, so this is how you end it and release the store
lock it holds.
margins stop
margins stop --json # → { "stopped": true, "pid": 1234, "reason": "stopped" }The command does not look for the daemon itself — it asks the cached runtime's own launcher to stop it, so it stays correct as the runtime's discovery format changes. It reports one of five outcomes:
| reason | Meaning |
|---|---|
| stopped | The daemon is gone — signalled, and confirmed exited. |
| not-running | There was nothing to stop. |
| refused | A daemon is running and the runtime would not stop it: it may be wedged mid-boot, or holding a lock it cannot identify as its own. Run the runtime launcher with stop --force to signal it anyway. |
| timed-out | The daemon was signalled but is still alive. Give it a moment and try again, or signal it directly. |
| failed | The runtime launcher could not answer; detail says why. A daemon may still be running. |
Three of those five mean a daemon is still running, so treat anything other than
stopped or not-running as "still up".
completions
Generate shell completion scripts.
margins completions -s zsh # zsh
margins completions -s bash # bash
margins completions -s fish # fish| Flag | Required | Description |
|---|---|---|
| -s, --shell <shell> | yes | Target shell: bash, zsh, or fish |
Install
Zsh — add to ~/.zshrc:
eval "$(margins completions -s zsh)"Bash — add to ~/.bashrc or ~/.bash_profile:
eval "$(margins completions -s bash)"Fish — add to ~/.config/fish/config.fish:
margins completions -s fish | sourceAfter reloading your shell, press Tab after margins workspace sync to get live workspace slug completion from the API.
install-hook
Installs a git hook that syncs to Margins on every push (or commit).
The hook is non-blocking: sync runs in the background and git push always succeeds
regardless of sync outcome. CLI logs a warning on failure.
margins install-hook # pre-push hook (default)
margins install-hook --on commit # post-commit hook
margins install-hook --force # overwrite an existing hook without prompting| Flag | Required | Description |
|---|---|---|
| --on <trigger> | no | push (default) or commit. push runs the sync when you git push; commit runs it on every commit. |
| --force | no | Overwrite an existing hook file without prompting. |
The two triggers do different things.
--on pushsyncs what you pushed. Git tells the hook, on stdin, exactly which commits are going to which remote branches; the hook syncs those. Push a branch you are not standing on, or pushlocal-name:remote-name, and the sync follows git — it lands onremote-name, not on your current checkout. A multi-branch push syncs each branch independently: one branch failing does not stop the rest. Tag pushes and branch deletions sync nothing.--on commitsyncs the commit you just made, resolved to an immutable object id before the background sync starts — so committing again straight away cannot make the first sync send the second commit.
Whether a sync sends the commit's tree or the working tree is the workspace's content mode, settled at each push. The hook does not decide it and does not carry it.
A pre-push hook runs before the remote accepts the push. If the remote rejects it (non-fast-forward, a hook on the server, a protected branch), Margins is left holding content the remote never took. The hook mirrors what you intended locally; it cannot speak for the remote.
When a background sync fails, it is not silent. The hook exits 0 so git push
is never blocked, which means a refusal — an expired key, an unreachable server,
a workspace someone switched to committed mode — has no exit code and no output
anyone sees. So the failure is recorded in the CLI's own data directory (beside
repos.json, honouring MARGINS_DATA_DIR; never inside your repository, where
it would show up in git status), and the next margins command you run reports
it once and clears it:
margins: an earlier background sync did not reach Margins.
/Users/you/project — main — <why it failed> (2026-07-19T12:00:00.000Z)
Git was not blocked at the time, so nothing said so. Fix the cause and push again.Only the latest failure per project is kept — it is replaced, not appended.
In CI there is no next command and no surviving filesystem, so no record is
written: margins workspace hook-sync exits non-zero and names the cause in
the job log instead. A CI runner is detected from CI / GITHUB_ACTIONS /
GITLAB_CI / BUILDKITE / CIRCLECI / TF_BUILD (CI=false counts as not CI).
Hooks are installed via git rev-parse --git-path hooks, so linked worktrees
(git worktree add) and submodules — where .git is a file, not a directory —
install into the shared hooks directory rather than failing.
Prerequisite — workspace identification. The hook reads workspace_id from
.margins.json in the repo root. Before installing the hook, register the workspace once:
margins workspace push --workspace <workspace-id>
# or, for a brand-new local workspace:
margins workspace push --project my-docsAfter the first push, .margins.json is written and the hook works on subsequent git push.
If .margins.json is missing when you run install-hook, the CLI warns you — the hook will
fail silently on every push until you create it.
Generated hook (pre-push):
#!/bin/sh
# Margins CAS sync — non-blocking pre-push hook
# Installed by: margins install-hook
refs=$(cat)
margins workspace hook-sync --event pre-push --refs "$refs" &
exit 0refs=$(cat) runs before the & deliberately: git closes the hook's stdin the
moment the hook exits, so a reader started in the background would find an empty pipe.
The post-commit hook resolves git rev-parse HEAD in the same position and for the
same reason.
margins workspace hook-sync is an internal command invoked by the installed hooks.
It is not part of the CLI's user-facing surface — use margins workspace push by hand.
Removing the hook: delete .git/hooks/pre-push (or post-commit) by hand. There is
no uninstall-hook command yet.
install
Onboards a repository to credentialless CI sync: creates (or reuses) the Margins workspace, writes the OIDC trust binding that authorizes this repo's GitHub Actions to push, and opens a PR adding the sync workflow. Once it lands, CI pushes markdown on every change with no stored credentials (see Authentication).
margins install # onboard the current repo
margins install owner/repo # onboard a specific repo
margins install --org my-org # onboard every repo in an org (or user account)
margins install --org my-org --include 'docs-*' --exclude 'archived-*'
margins install --yes # accept the origin-detected repo without confirming (CI)
margins install --dry-run # print intended actions without writing anythingWith no argument, install resolves the target from the current repo's origin
remote and asks you to confirm before opening a PR. Pass --yes to skip the prompt —
required in a non-interactive context (no TTY, or --json), where it will otherwise
error rather than open a PR against a guessed repo. A non-GitHub or missing origin
errors instead of guessing.
| Flag | Required | Description |
|---|---|---|
| --org <org> | no | Install across all repos in a GitHub org or user account. |
| --include <glob...> | no | With --org: only repos matching these globs. |
| --exclude <glob...> | no | With --org: skip repos matching these globs. |
| --dry-run | no | Print the planned workspace / binding / PR actions without writing anything. |
| --yes | no | Accept the origin-detected repo without the confirmation prompt. Required when no repo is given in a non-interactive context (no TTY or --json). |
The stamped workflow uses the margins-sync-action and a pinned CLI version. Requires Margins server v0.21.0+.
A repo that cannot be onboarded is skipped, not a failure. Over the cap, on the
wrong sync mode, or refused by the server because a workspace already exists for it and
you are not a member — each is reported per-repo and the run continues to the next one.
So install can exit 0 having onboarded nothing. Read the per-repo results (--json
gives you them structured); the exit code only tells you the run itself did not break.
A refused repo needs an invite link from an editor of the existing workspace, which is
an action for a person, not a retry.
audit
Reports sync coverage across one or many repositories: which are missing the sync workflow, which carry a stale action pin, which have binding drift (the recorded workspace binding no longer matches the repo), and which exceed the server's file cap.
margins audit # audit the current repo
margins audit --org my-org # audit every repo in an org
margins audit --org my-org --csv # CSV output for spreadsheetsWith no argument, audit resolves the target from the current repo's origin remote
and prints which repo it is auditing. It writes nothing, so there is no prompt; a
non-GitHub or missing origin errors instead of guessing.
| Flag | Required | Description |
|---|---|---|
| --org <org> | no | Audit all repos in a GitHub org or user account. |
| --include <glob...> | no | With --org: only repos matching these globs. |
| --exclude <glob...> | no | With --org: skip repos matching these globs. |
| --csv | no | Emit CSV instead of a table. |
audit runs in gh-only mode without Margins credentials — it still reports missing
workflows, stale pins, and over-cap repos from the GitHub API alone (binding-drift checks
are skipped when unauthenticated).
Agent / Scripting Mode
All commands support --json for structured output:
margins workspace list --json
# → [{ "slug": "my-repo", "name": "My Repo", "syncStatus": "synced", ... }]
margins discuss list my-repo --json
# → [{ "id": "d_...", "path": "docs/intro.md", "body": "...", "status": "open", ... }]For non-interactive use (CI, agents), use environment variables instead of stored credentials:
MARGINS_API_KEY=mrgn_... MARGINS_SERVER_URL=https://margins.example.com margins workspace list --jsonExit codes: 0 on success, 1 on any error (auth failure, network error, not found, etc.). Error details are written to stderr.
Error envelope
Under --json, a failure writes a single JSON object to stderr:
{ "error": "human-readable message", "code": "ConflictError", "serverCode": "SLUG_CONFLICT" }| Field | Always present | What it is |
|---|---|---|
| error | yes | The message meant for a person. Wording is not a contract — do not match on it. |
| code | yes | The CLI's error class. Coarse: several unrelated failures share one (a refused workspace and a cancelled prompt are both ValidationError). |
| serverCode | no | The server's structural code for the failure, when the response carried one. This is the field to branch on. |
serverCode is absent whenever the server said nothing structural — a network
error, a client-side validation failure, or an older server that answers without
an error code. Treat its absence as "cause unknown", not as a specific cause.
The case this exists for: margins sync against a repository whose workspace you
are not a member of fails with serverCode: "SLUG_CONFLICT", which a CI job can
distinguish from every other conflict without reading prose the server owns.
Local Workspace Config (.margins.json)
When a slug argument is omitted, the CLI walks up from the current directory looking for a .margins.json file. This allows running commands from anywhere inside a repository without repeating the workspace slug.
Example .margins.json:
{
"workspace_slug": "local/avigano/my-docs",
"workspace_id": "0cfbdc14-c023-4c84-bc4a-e027e13cefab",
"default_branch": "main",
"syncMode": "client",
"server_url": "https://margins.example.com"
}| Field | Description |
|---|---|
| workspace_slug | Default workspace slug for discuss and workspace commands |
| workspace_id | Default workspace UUID. Used by workspace push --workspace for re-pushes — more reliable than slug because it doesn't depend on slug resolution. |
| default_branch | Default branch for workspace sync. For local workspaces this is main; for GitHub-overlay mode (local edits pushed to a GitHub workspace's @local branch) this is @local. |
| syncMode | "client" — the CLI pushes content via workspace push (CAS). "server" — Margins syncs the workspace from a GitHub webhook; the CLI refuses workspace push and directs you to workspace sync. Replaces the legacy mode field ("local" / "overlay"), which is still read and upgraded to syncMode in place. |
| server_url | Server URL override (lower priority than --server-url and MARGINS_SERVER_URL) |
Project-scoped credentials:
.margins.jsonis intended to be committed to the repository so teammates share the same workspace identity. It does NOT contain credentials. For project-scoped API keys + server URL, use a.margins/directory at the project root containingconfig.jsonand setMARGINS_CONFIG_DIRto point at it. Add.margins/to.gitignoresince it contains credentials.
Global Config File
The CLI resolves the config directory in this order:
| Priority | Condition | Path used |
|---|---|---|
| 1 | MARGINS_CONFIG_DIR env var is set | $MARGINS_CONFIG_DIR/config.json |
| 2 | ~/.config/margins/config.json already exists | ~/.config/margins/config.json |
| 3 | Platform default (fallback) | macOS: ~/Library/Preferences/margins/config.json · Linux/XDG: ~/.config/margins/config.json · Windows: %APPDATA%/margins/Config/config.json |
Preferred location on all platforms: ~/.config/margins/config.json
If that file exists (e.g. you created it manually, or you're on Linux), it takes precedence over the macOS ~/Library/Preferences/ default. To migrate on macOS:
mkdir -p ~/.config/margins
cp ~/Library/Preferences/margins/config.json ~/.config/margins/config.jsonThe MARGINS_CONFIG_DIR override is intended for tests and CI — it fully isolates the config from your user profile.
| Field | Set by | Description |
|---|---|---|
| apiKey | config set-key | Static Margins API key (mrgn_...) |
| serverUrl | config set-url | Server URL override |
| accessToken | auth login | Keycloak JWT access token |
| refreshToken | auth login | Keycloak refresh token (auto-refresh) |
| accessTokenExpiresAt | auth login | Access token expiry (epoch ms) |
| keycloakIssuer | auth login | Keycloak realm URL |
| keycloakClientId | auth login | Keycloak client ID |
Running
margins auth loginclears any previously storedapiKey. Runningmargins config set-keyclears any stored Keycloak session.
Development
git clone https://github.com/alvistar/margins-cli.git
cd margins-cli
npm install
# Build
npm run build # compiles to dist/index.mjs via tsdown
# Run from source (no build required)
npm run dev -- workspace list
# Tests
npm test # vitest run (114 tests)
npm run test:watch # watch modeThe CLI is built as ESM. The bin/margins.js shebang entry imports ../dist/index.mjs.
How
npx github:alvistar/margins-cliworks: npm clones the repo, runsnpm install, then runs thepreparescript (npm run build) automatically. This compilessrc/todist/before the binary is executed — no pre-built files need to be committed.
