npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@caracode/fyi-cli

v0.27.0

Published

Publish Markdown, HTML and PDF docs to riar.fyi from any directory, pull in-app edits back, read docs and comments, work lists and draw sketches (agent-friendly --json).

Readme

fyi CLI

Publish docs to riar.fyi from any directory: a repo, a scratch folder, an Obsidian vault. One command:

fyi publish report.html    # an HTML doc
fyi publish deck.pdf       # a PDF doc
fyi publish notes.md       # a Markdown doc
fyi publish docs/          # a whole directory of Markdown
fyi link notes.md          # print the doc's URL

The first time, that creates the doc. Every time after, it updates the same doc at the same URL.

It reads, too, which is what an agent working in your repo mostly needs:

fyi ls                     # the linked space's folders and docs
fyi read "Onboarding"      # a doc's body, named by title, ref, URL or file
fyi comments "Onboarding"  # its open comment threads

See Reading docs. And it draws: an agent can read a sketch as an outline and draw on it, or create one, with a batch of operations. See Drawing a sketch.

The file types behave differently, and the difference is worth knowing up front. An HTML or PDF doc is a file you replace: the app renders whatever bytes were uploaded last. A Markdown doc is editable in the app, so two writers can touch it. Either way fyi publish looks before it writes, and refuses to bury a change it hasn't seen: someone else's upload, or their edits in the app. The conflict model explains what it does instead, and HTML and PDF covers the file side.

Install

Node 20+.

npm install -g @caracode/fyi-cli
# or
pnpm add -g @caracode/fyi-cli

From a clone of this repo instead:

pnpm --filter @caracode/fyi-cli build
node packages/fyi-cli/dist/cli.js --help

Releasing (maintainers)

dist/ is gitignored and files ships only dist + this README, so the package must be built before publishing. That's what prepublishOnly is for — and it is deliberately not prepare, because pnpm runs prepare on every install, which would drag the CLI into the Cloudflare Workers Build (see notes/2026-07-29-fyi-cli.md, Phase 3).

Publish from this directory, not the repo root: npm publish --workspace … reads npm's own workspaces field, which this repo doesn't have (it's a pnpm workspace, configured in pnpm-workspace.yaml).

cd packages/fyi-cli
npm publish --otp=<code-from-your-authenticator>

The npmjs account has 2FA enforced for publishing, so the OTP is required. The alternative is a granular access token with "bypass 2FA" enabled, set as //registry.npmjs.org/:_authToken in ~/.npmrc — note the token already there is a GitHub Packages token for @caracodedev and is unrelated.

Authenticate

Mint a personal API token in the app: Account settings → API tokens → New token. The secret is shown exactly once.

fyi auth            # masked prompt, verifies the token, then saves it

fyi auth verifies the token against the API before writing anything, then stores it in ~/.config/fyi/config.json with mode 600 (the directory is 700).

Signed in at a terminal, it then offers (default yes) to add a short fyi section to your own ~/.claude/CLAUDE.md, so agents in any repo know fyi exists — see The global section. It says nothing if the section is already there and current, and never asks under --stdin or --json.

Non-interactive (CI, agents) — there is deliberately no --token flag, because argv is visible in ps and lands in shell history:

echo "$TOKEN" | fyi auth --stdin      # explicit
FYI_TOKEN="$TOKEN" fyi auth           # non-TTY fallback

Token resolution order

Everywhere in the CLI, in this order:

  1. FYI_TOKEN in the environment
  2. ~/.config/fyi/config.json

Env wins, so CI and agents can override a stored credential without touching your home directory. Any command run with neither exits non-zero telling you to run fyi auth — never a raw HTTP 401.

Environment

| Variable | Meaning | | --- | --- | | FYI_TOKEN | API token; beats the config file. | | FYI_API_URL | API base URL override. Defaults to the prod deployment. | | XDG_CONFIG_HOME | Moves the config directory (default ~/.config). |

The whole loop

cd ~/work/some-report            # a repo, a vault, anything — git not required
fyi publish cookie-audit-2026-07-29.html
#   → which space?          (picker; skipped if you own exactly one)
#   → which folder?         (defaults to a folder named "Some Report", created if needed)
#   → Title [Cookie Audit]: (edit or press Return)
#   → Created Cookie Audit
#     https://fyi.riar.app/caracode/acme/cookie-audit
#   → writes .fyi.json, and offers a CLAUDE.md note for agents

# edit the file, then:
fyi publish cookie-audit-2026-07-29.html
#   → Published Cookie Audit      (no prompts, same doc, new version)

A Markdown file runs the same loop, plus one extra move: because the doc can be edited in the app, fyi pull brings those edits back into the file. See fyi publish <file.md>.

fyi init

Sets a directory up without publishing anything: pick the space, pick or create the folder, write .fyi.json, and add the CLAUDE.md block. First publish in a directory runs this inline, so you often don't need it by hand; run it deliberately to check or change where new docs go, or to (re)write the CLAUDE.md block. At a terminal the space and folder pickers are arrow-key menus.

Re-running it

In a directory that's already set up, fyi init says so and shows where it publishes before it changes anything:

This directory is already set up for fyi (.fyi.json).
  Space:  Tools / riar-fyi  (tools/riar-fyi)
  Folder: tina-astro-template
  3 files published from here.

What would you like to do?
  1) Keep these settings
  2) Change the space
  3) Change the folder
  4) Rename the folder "tina-astro-template"
Choose 1-4 [1]:
  • Keep these settings (the default) changes nothing and refreshes the CLAUDE.md block.
  • Change the space runs the space picker, then the folder picker for that space. Change the folder runs the folder picker for the current space.
  • Rename the folder renames it in the app, in place. It offers the readable name (tina-astro-template → "Tina Astro Template") as the answer a bare Return accepts. The folder keeps its id, so .fyi.json doesn't change and every doc in the folder stays in it; only the CLAUDE.md block is refreshed, since it names the folder. Only offered when there is a folder.

If the setup has gone stale, it says that too: a space you can no longer publish to (deleted, or you're no longer an Editor there), a folder that was deleted or moved to another space, or an archived space (still fine — it's only hidden from the space list). Changing the space or folder fixes the first two.

Re-running never touches your mappings. orgId/folderId are defaults for NEW docs, so re-pointing changes where the next new doc is created and nothing else — every file already in docs keeps updating the same doc, wherever that doc actually lives. fyi init tells you how many files that covers before it changes anything.

Without a terminal (an agent, --yes, or no TTY) a re-run never prompts. With no other flags it prints the current settings, refreshes the CLAUDE.md block and changes nothing. (Older versions of fyi init --yes silently re-pointed the directory at a folder named after it.) To change something, say what:

fyi init --yes --space acme                   # new space (and its default folder)
fyi init --yes --folder Reports               # new folder, same space
fyi init --yes --no-folder                    # new docs at the space root
fyi init --yes --rename-folder "Q3 Reports"   # rename the current folder in place
fyi init --json                               # the current settings, as JSON

--json prints one line and changes nothing:

{"space":{"name":"riar-fyi","slug":"riar-fyi","teamSlug":"tools","archived":false},"folder":{"id":"…","name":"tina-astro-template"},"mappedFiles":3}

space is null when it can't be reached any more, folder is null for no folder, and a folder whose name is null no longer exists. Add --space, --folder, --no-folder or --rename-folder and it makes that change first and prints the settings that result; progress lines go to stderr so stdout stays JSON.

Picking a space and folder

  • The folder default is named after the directory, made readable — tina-astro-template is offered as "Tina Astro Template" — and created server-side if it doesn't exist. An existing folder named either way (the raw directory name, or the readable one) is reused instead, so a directory set up by an older CLI doesn't gain a twin.

  • Type a new folder name… in the picker asks for any name ("A&M Astro Template") and creates it, or uses the existing folder if one already has that name.

  • Subfolders are listed under their parent as Parent / Child. To pick one without the picker, pass --folder "Parent/Child"; if Parent exists but Child doesn't, the subfolder is created. A bare --folder Name always means a top-level folder, as it always has. Folders nest two deep at most.

  • Archived spaces aren't offered in the picker — for owners and Editors alike — and don't count when deciding whether there's only one space to pick. --space <slug> still reaches an archived space (it still accepts docs, and the CLI notes that it's archived).

  • Nothing assumes git. A plain folder and an Obsidian vault behave exactly like a repo.

  • Spaces you own, plus any where you're an Editor or Admin, are listed — those are the rungs that can create a doc. Spaces where you're only a Member (including via the verified-email domain allowlist) are not, because Member can't create. Non-owned spaces are labelled in the picker.

  • No folder at all: --no-folder puts new docs at the space root. It can't be combined with --folder.

fyi init                                      # set up, or check / change, this directory
fyi init --yes --space acme --folder Reports  # zero prompts
fyi init --yes --space acme --no-folder       # zero prompts, docs at the space root

Agent init

An agent sets a repo up without a single prompt, but never picks the space on its own: the global section tells it to run fyi ls --spaces --json, keep the spaces where its role is owner, admin or editor and that aren't archived, and ask you one question — which space, with a folder named after the repo proposed (another name, or no folder, is fine). Then:

fyi init --space tools/riar-fyi --folder "Riar Fyi" --json   # or --no-folder
fyi publish notes.md

fyi init --json on a first run sets up without prompting, writes .fyi.json and the CLAUDE.md block, and prints the resulting settings.

fyi init --claude-md

Writes the CLAUDE.md block on its own, using the space and folder this directory is already pointed at — no space picker, nothing else touched:

fyi init --claude-md

Use it when you declined the block the first time, when the repo gained a CLAUDE.md later, or when the block has gone stale. It's a no-op (CLAUDE.md is already up to date.) when nothing needs changing, and it errors if the directory has no .fyi.json yet.

The global section: fyi init --global

Writes, or refreshes, a short fyi section in your own ~/.claude/CLAUDE.md ($CLAUDE_CONFIG_DIR/CLAUDE.md when that's set), creating the file if it doesn't exist. It's what tells an agent in a repo with no .fyi.json that fyi exists: what it's for, that a linked repo's own block has the details, that reading works anywhere by URL or team/space/doc, the agent init routine, and that exit 3 means asking you to run fyi auth.

fyi init --global          # Created / Added … / Refreshed … / already up to date
fyi init --global --json   # {"global":"created","path":"/Users/you/.claude/CLAUDE.md"}

global is created, appended, refreshed or unchanged. No sign-in is needed and nothing is asked: running the command is the yes. It takes no other option but --json and --verbose (anything else is exit 2). The section is fenced by <!-- fyi-cli-global --> … <!-- /fyi-cli-global -->, distinct from the repo block's markers, so the two never mistake each other — even in one file — and your own content around it is never touched.

The manifest: .fyi.json

The CLI remembers exactly one thing per file: which doc that file feeds. That mapping lives in a .fyi.json next to your files (fyi publish walks up from the file to find it). It holds no secrets, so commit it.

{
  "orgId": "k5712abc...",
  "folderId": "k9987def...",
  "docs": {
    "report.html": {
      "docId": "kg2xyz...",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015",
      "versionId": "html/5f0c…"
    },
    "reports/q3.html": "kg2uvw...",
    "notes.md": {
      "docId": "kg2rst...",
      "sha256": "b1946ac92492d2347c6235b4d2611184",
      "remoteEditedAt": 1756732800000
    }
  }
}
  • docs — the mapping. Keys are POSIX-style paths relative to the directory the .fyi.json sits in.
  • An HTML or PDF entry is an object once it has a baseline: sha256 is the hash of the file you last published or pulled, and versionId is the doc's file version at that moment. An entry written by an older CLI is the docId on its own; it still works, publishes without a check once, and becomes an object.
  • A Markdown entry is an object, because publish has to know what it last saw. sha256 is the hash of the file's bytes at that moment and remoteEditedAt is the doc's server timestamp at that same moment. Those two numbers are the whole of the conflict model. Both entry shapes are read forever, and the CLI writes each entry back in the shape it found it in.
  • orgId / folderId — defaults for NEW docs only. They are not consulted when updating an existing doc, and the CLI never rewrites them to match where a doc actually lives.

Why the mapping is by docId

The app is the source of truth for everything except this mapping. Title, space, folder and version history all live in the app and you should change them there freely. Because the mapping is by docId, moving a doc to another folder or renaming it in the app is invisible to the CLI — fyi publish keeps updating the same doc. The CLI never fights the app for ownership of those fields, and never caches them. Publish status is the one both sides can change: the app's Publish button, or --live / --draft on fyi publish (Publish status). Without either flag, a publish leaves it as it is.

fyi publish <file.html>

  1. check the file exists, is .html, and that the page, with its local images built in, is under the 5 MB cap — locally, before any network call;
  2. look up the docId in .fyi.json (running init first if this directory has none);
  3. mapped → confirm the doc is still live, then look before writing; unmapped → settle a title, create the doc (published), record the mapping;
  4. mint a signed upload URL, PUT the bytes, record the upload;
  5. print the doc's URL in the app.

HTML docs keep every version, and restoring an earlier one is a click in the app's version history.

Images

An HTML file can point at images next to it: <img src="shots/a.png">, srcset, <link rel="icon"> and the like, and CSS url() in a <style> block or a style="" attribute. fyi publish shrinks each local image and builds it into the uploaded copy as a data: URI, so the page on fyi needs nothing else. Your local files never change.

  • How images are shrunk. PNG, JPEG, WebP and AVIF become WebP at quality 90, at most 2000 px wide (--image-quality <1-100> changes the quality; 85 fits about 14 photos in a page where 90 fits about 10). SVG, GIF and ICO are kept as they are, and so is any image under 50 KB. An image whose WebP comes out no smaller keeps its original bytes, and an animated PNG, WebP or AVIF goes in as it is, so it keeps moving.
  • The 5 MB limit is for the whole published page with every image built in, not for each image. If the page is still over 5 MB after shrinking, publish stops, uploads nothing, and names the largest images.
  • Left alone: remote (http://, https://), data: and root-absolute (/x.png) references.
  • A missing image gets a warning that names it, and publish goes ahead.
  • Local files that aren't images (a font in a url(), say) aren't built in; a note names them.
  • Changing only an image counts as a change. Publish uploads the page again even when the HTML itself is the same.
  • Under --json, the outcome gains images: { built, missing } when the HTML points at local images.

Looking before writing (HTML and PDF)

Someone else can change an HTML or PDF doc too: upload a newer file in the app, publish from another checkout, or restore an old version. So a replace asks the same two questions Markdown does. Did your file change since your last publish or pull (its sha256 against the one in .fyi.json), and is the doc's current file still the one you last saw (its version id against the recorded one)?

| Local file changed | Doc's file changed | fyi publish | fyi pull | | --- | --- | --- | --- | | no | no | up to date, nothing uploaded | up to date, nothing written | | yes | no | uploads | refuses, --force overwrites your file | | no | yes | skips, tells you to fyi pull | downloads | | yes | yes | conflict, nothing uploaded | conflict, nothing written |

report.html — SKIPPED: changed in fyi by Sarah on Oct 2 (fyi pull to fetch, --force to overwrite)
report.html — CONFLICT: your local file changed AND it was changed in fyi by Sarah on Oct 2 (fyi pull to fetch, --force to overwrite)

The server checks it too: an upload says which version it expected to replace, and if someone else's landed first the upload is thrown away and the doc is left as it was (CONFLICT … Nothing was replaced., exit 6).

  • --force uploads yours anyway. Nothing is lost by it: the file you replaced stays in the doc's version history.
  • An entry with no baseline uploads without a check, with no warning, then records one. That's a legacy bare-docId entry, or an entry an older CLI rewrote. Every upload is a version, so nothing is lost.
  • Skips and conflicts change nothing: the baseline in .fyi.json stays where it was, so the next run sees the same picture.

Any failure exits non-zero with the server's own message.

Titles

Only ever set when the doc is created — after that the app owns the title and the CLI won't touch it.

The suggestion comes from the filename: -/_ become spaces, a trailing ISO-date or timestamp is dropped, and words are capitalized.

| File | Suggested title | | --- | --- | | website-cookie-audit-2026-07-29.html | Website Cookie Audit | | my_snake_case_notes.html | My Snake Case Notes | | API-audit-20260729.html | API Audit | | quarterly_report_v2.html | Quarterly Report V2 |

--title "..." replaces it outright; --yes accepts it without asking.

A Markdown file is titled from its first # H1 instead, when it has one, and falls back to the same filename rule when it doesn't.

Publish status

A new doc is published when it's created, so guests can see it. After that, fyi publish leaves the doc's status alone unless you pass a flag:

  • --live publishes the doc (makes it visible to guests).
  • --draft creates a new doc unpublished, and unpublishes an existing one. Before 0.15 it did nothing on an existing doc; now it does what it says, so take it out of any script that still passes it by habit.
  • Both at once is a usage error (exit 2), before anything is sent.

Content first, then status. The status only changes once your file has gone up, or there was nothing to send ("already up to date", or skipped because it was edited in fyi). If the content step fails or conflicts, the status is left alone: a doc never goes live with content you couldn't push. --force --live sends your file and then publishes. When the doc already has the status you asked for, nothing extra is sent. The line says when it changed:

Published Q3 report → published
notes.md — already up to date → unpublished

Folder mode asks first. fyi publish docs/ --draft counts the mapped docs that are published right now and, if there are any, asks once before writing anything: "3 published docs in this run will be unpublished. Continue?" --yes (or --json) answers yes; with no terminal and no --yes it stops with nothing written. --live never asks. The summary line adds "2 made live" or "1 unpublished" when a run changed any.

Dead mappings

If the mapped docId no longer resolves — the doc was deleted in the app, or your account lost access — fyi publish stops. It never silently creates a replacement, because a duplicate doc is worse than an error: the stale one keeps serving whatever link you already shared.

Interactively it offers to create a new doc and repoint .fyi.json at it (the old doc, if it still exists somewhere, is untouched). Under --yes or on a non-TTY it just exits non-zero with the hint.

fyi publish <file.pdf>

fyi publish deck.pdf

Exactly the HTML path above, for a PDF doc: the same create-or-replace, the same signed upload, the same look-before-writing check and dead-mapping refusal. The cap is 25 MB (checked locally first), and every upload is kept in the doc's version history in the app.

A file has to stay the kind of doc it was created as. A manifest entry that points a .pdf at an HTML doc (or the other way round) is refused by the server — Not a PDF document / Not an HTML document — and nothing is written.

fyi publish <file.md>

fyi publish notes.md

The shape is the HTML path's: local checks first (a .md file has to be under 1 MB, and that is checked before any network call), then the docId in .fyi.json, then create or update. Three things are different after that.

The file is converted to the app's block format in the CLI, by the same BlockNote parser the app's own Markdown import runs, so what you publish and what you'd get by pasting the file into the app are the same document.

An unmapped file is created, filled and published in one server transaction. There is no moment where the doc exists and is empty, and nothing to clean up if it fails.

A mapped file goes through the conflict check below before a byte is written.

$ fyi publish notes.md
Created Release Notes
https://fyi.riar.app/caracode/acme/release-notes

$ fyi publish notes.md          # after editing the file
Published Release Notes
https://fyi.riar.app/caracode/acme/release-notes

$ fyi publish notes.md          # nothing changed on either side
notes.md — already up to date

The conflict model

A Markdown doc has two writers: this CLI, and anyone editing it in the app. So every publish asks two questions first. Did the local file change since the last time we synced (its sha256 against the one in .fyi.json), and did the doc change in the app (the server's lastEditedAt against the one in .fyi.json)?

| Local file changed | Doc changed in fyi | fyi publish | fyi pull | | --- | --- | --- | --- | | no | no | up to date, nothing written | up to date, nothing written | | yes | no | publishes | refuses, --force overwrites your file | | no | yes | skips, tells you to fyi pull | pulls | | yes | yes | conflict, nothing written | conflict, nothing written |

Both conflict rows are what --force is for.

Those two questions produce the messages. The actual enforcement is on the server: a content write carries the timestamp the CLI expected to find, and the server refuses it if the doc has moved on since. A publish that loses a race with a live editing session therefore fails closed even when the CLI's picture was already stale by the time it asked.

notes.md — SKIPPED: edited in fyi by Sarah on Aug 28 (fyi pull to fetch, --force to overwrite)
notes.md — CONFLICT: your local file changed AND it was edited in fyi by Sarah on Aug 28 (fyi pull to fetch, --force to overwrite)

A skip is a normal, successful outcome and exits 0: the doc simply moved on and fyi pull is the answer. A conflict exits non-zero, because it is the one outcome a script has to be able to notice.

Four more things worth knowing:

  • --force skips the check, but not the person. It publishes without an expected timestamp, so it wins against in-app edits. It does not get past someone with the doc open in the editor right now: that refusal stands, and says --force cannot write past someone who is editing. Try again when they're done.

  • A file with no baseline publishes blind, and says so. That's an entry written before this CLI could publish Markdown, or one added by hand: with no recorded sha256 and timestamp neither question can be answered, so the publish goes ahead without a check and warns that in-app edits (if there were any) are gone. Every publish after that one is guarded.

  • A publish stamps you as the doc's last editor, so the app will show "edited by you just now" whether or not a human touched it.

  • Comments on text you didn't change keep their place. The server keeps each unchanged block as it was, so a highlight on an untouched paragraph survives a publish. A comment on text you reworded, moved or deleted comes loose: its card in the app says the original text changed, and fyi comments shows the thread as orphaned. When that happens, publish says so on stderr:

    fyi: 2 open comments came loose — the text they were on changed. `fyi comments notes.md` lists them.

    Under --json the published outcome carries "looseComments": 2. A folder run prints the hint once, with the total and the files, and each file's entry carries its own count. An older server doesn't report it, and then there's no hint and no field (never a guessed 0).

fyi publish <dir>

Point publish at a directory and it walks the tree, mirrors it into fyi, and runs the single-file pipeline once per file.

$ fyi publish docs/
Publishing 5 markdown files from docs/
  created    docs/faq.md → Frequently Asked Questions
  created    docs/getting-started.md → Getting Started
  folder     guides (created)
  created    docs/guides/imports.md → Importing Data
  created    docs/guides/sharing.md → Sharing a Doc
  folder     reference (created)
  created    docs/reference/api.md → API Reference

5 markdown files: 5 created, 0 published, 0 up to date, 0 skipped (edited in fyi), 0 conflicts, 0 errors. 1 non-.md file ignored.
Created folders: guides, reference.

The rules the walk holds to:

  • The directory you pass is the root, not a folder. fyi publish docs/ puts docs/guides/*.md in a folder called "guides"; fyi publish docs/guides/ puts the same files in the manifest's default location, because there is no subdirectory in sight. To publish into a folder, publish the parent.
  • One level deep, and it refuses to guess about more. Top-level .md files become docs in the manifest's default location. A subdirectory becomes an fyi folder of the same name, matched case-insensitively against the folders you already have and created when nothing matches. The name is used as is: folder mode doesn't make it readable the way fyi init does, because trees already published matched their folders by the exact directory name. A .md file nested deeper than that is a hard error naming the path, raised before anything from that run is published, because fyi has one level of folders and there is no honest way to flatten a second one.
  • Markdown only. Files that aren't .md are counted in the summary and never published. A stray .html or .pdf in a docs tree must not become a surprise doc — publish those one file at a time. Dotfiles and dot-directories are skipped without being counted.
  • --title is refused here. One title can't name five docs. Each new doc is titled from its first # H1, else its filename, with no prompt.
  • Folders are decided when a doc is created, and never again. A re-run of a fully mapped tree makes no folder API call at all, which is what makes it safe to run on a schedule: a doc you moved or renamed in the app keeps taking updates from the same file, and folder mode will not drag it back.
  • One file's failure doesn't end the run. A dead mapping or an over-cap file becomes a FAILED line and the rest of the tree still runs. Conflicts and failures are repeated in full at the bottom, where they can't scroll away, and the exit code is non-zero if there were any.

Files publish one at a time, in order. The manifest is a single file rewritten after every successful write, so running them concurrently would race .fyi.json against itself.

Links between files

Relative links work. [setup](./guides/setup.md) in docs/index.md opens the doc that docs/guides/setup.md became, for whoever is reading: the owner, a member, or a guest with the space link. Nothing in your file changes — the link stays ./guides/setup.md in the stored content and fyi pull gives it back verbatim. The app resolves it when the doc is shown.

Since 0.7.0 every publish also records the file's path relative to .fyi.json (guides/setup.md) on the doc, so a link keeps working after the target is renamed or moved in the app. Docs published with an older CLI still resolve by convention — folder name plus a slug made from the filename — and pick up the exact path on their next publish. A link to a file that isn't published yet shows as dangling in the app and says so when clicked; publish the file and it resolves. Only .md, .markdown and .html targets resolve, and links inside an HTML doc are left to the browser.

The first run in an unconfigured directory does the usual fyi init, with one difference: it suggests no default folder, because the tree is providing the structure and top-level files belong at the space root.

fyi pull <file|dir>

The other direction. Someone fixed a typo in the app; fyi pull brings it back into the file as an ordinary change git can diff.

$ fyi pull docs/
Pulling 5 markdown files into docs/
  up to date docs/faq.md
  pulled     docs/getting-started.md
  up to date docs/guides/imports.md
  up to date docs/guides/sharing.md
  not mapped docs/reference/api.md — skipped

5 markdown files: 1 pulled, 3 up to date, 1 not mapped (ignored), 0 conflicts, 0 errors. 0 non-.md files ignored.
fyi: pulled files are Markdown re-rendered from fyi, so formatting can normalize and anything Markdown can't express is dropped — review the git diff.

Pull runs the same two questions, and the same table above, with the answers reversed. It writes a file only when the doc moved and your copy didn't, or under --force. Local changes fyi has never seen are never silently replaced.

  • Mapped files only. A .md file with no entry in .fyi.json is skipped and counted, never created from the app. Pull also can't run fyi init: with no manifest, nothing is mapped, so it stops and tells you to publish first.
  • The baseline it records is the bytes it wrote, so a pull is immediately "up to date" for the next publish rather than bouncing back as a change.
  • No mtime churn. If the app's rendering is byte-identical to the file you already have, nothing is written to disk.
  • Round-trips normalize. Markdown is the interchange format, not the storage format: the app stores blocks, and what comes back is those blocks re-rendered. Wrapping and list markers can shift, and anything Markdown can't express is dropped. The first pull over a hand-written file may show a bigger diff than the edit deserves. After that it settles down.

Pulling an HTML or PDF file

fyi pull report.html
fyi pull deck.pdf

A single .html, .htm or .pdf file gets the doc's current file, byte for byte: whatever someone uploaded or restored in the app. It runs the pull side of the HTML and PDF table: it downloads only when the doc's file changed and yours didn't, or under --force, and the baseline it records is the file it wrote and the version it came from. Then fyi publish on the untouched file says "already up to date".

  • Pulling needs Editor, like publishing, and is checked before anything is downloaded.
  • The file has to match the doc's type (.pdf for a PDF doc, .html/.htm for an HTML doc), and the doc has to have a file; otherwise it exits 1 and writes nothing.
  • Pull won't overwrite an HTML file that points at local images. The copy on fyi is the built page, with every image inside it, not your source file, so writing it would replace your file and its image links. Pull refuses only when it would write over the file (the doc changed in fyi); "already up to date" and conflicts read as usual. --force takes the built copy anyway.
  • A directory pull (fyi pull docs/) still pulls only its .md files.

fyi link <file>

Prints the web URL of the doc a file publishes to — the same link fyi publish prints, without publishing anything.

$ fyi link notes.md
https://fyi.riar.app/caracode/acme/release-notes

$ fyi link notes.md --json
{"url":"https://fyi.riar.app/caracode/acme/release-notes","ref":"caracode/acme/release-notes","title":"Release notes","type":"md"}

It reads the mapping from .fyi.json and asks the server for the doc's current address, so it follows renames and moves made in the app. A file that isn't mapped yet is an error telling you to publish it first; a dead mapping is reported the same way fyi publish reports it.

fyi share <doc> --name "<who>"

Makes a guest pass for one doc and prints its /d/… link, for when someone outside the space needs to read that doc and nothing else. fyi link is still the everyday command (the doc's own address, for people in the space); this one is for when you're asked to share a doc with someone.

$ fyi share "Q3 report" --name "Client marketing team"
https://fyi.riar.app/d/k3x9-q2
Made a guest pass "Client marketing team" for "Q3 report".
The link is read-only, and it works even while the doc is a draft.
  • --name is required: it says who the pass is for, so you can tell the doc's passes apart in the app later.
  • The same name reuses the pass. If the doc already has a pass with that name (any capitalisation), you get its link back ("Reused the guest pass…") rather than a second pass. A different name makes a new one.
  • Read-only, and it opens even while the doc is a draft. It reaches only that one doc.
  • A members-only space refuses it: a guest link there wouldn't open, so the server says so and nothing is made. The same goes for any other reason the server gives; its sentence is printed as is.
  • If the pass has a password (set in the app), the output says it needs it too.
  • Needs Editor, like publishing; a Member is told so before anything is made.
  • The doc is named the usual ways (see Naming a doc): its URL, a mapped file, its team/space/doc ref, or its title.
  • --json prints {"url", "slug", "name", "reused", "needsPassword", "docId", "title"}.

Passwords, expiry and revoking a pass stay in the app. fyi share isn't in the CLAUDE.md block, so an agent uses it only when you ask it to.

Reading docs

Four read commands, all at Member level: anyone who can see a space in the app can read it from the CLI. None of them changes a doc. fyi reply is the one write here, and it only adds a comment. (Lists have their own commands: see Working a list.)

Naming a doc

fyi read and fyi comments take a doc four ways, tried in this order:

  1. Its URL, as copied from the browser: https://fyi.riar.app/<team>/<space>/<doc>. Any origin works (a dev link too); a query, fragment or trailing slash is ignored. An ID link, https://fyi.riar.app/go/<id> (what a doc link in a log entry stores), works too and follows the doc wherever it has moved. Share links (/d/…) aren't doc addresses and are refused.
  2. A file mapped in .fyi.json: fyi read notes.md reads the doc that file publishes to.
  3. Its ref, team/space/doc, which fyi ls prints for every doc. A renamed or moved doc still resolves, as its old URL does in the app.
  4. Its title, in the linked space, or the one --space team/slug names (--space slug alone works when only one of your spaces has that slug). An exact title wins (case and spacing don't matter); otherwise a title that contains the text. If several docs match, they're listed with their refs, nothing is picked, and the exit code is 5.

fyi ls [team/space]

$ fyi ls
Acme  (caracode/acme)  owner

Guides/
  md    Setup guide       published  caracode/acme/setup-guide
  html  Q3 report         draft      caracode/acme/q3-report
list    Launch checklist  published  caracode/acme/launch-checklist

Folders first, then docs, in the sidebar's order. With no argument it lists the linked space; in a directory that isn't linked, or with --spaces, it lists every space you're in, with your role and whether it's archived. fyi ls team/space lists that space. (A space you reach only through your email domain doesn't appear in --spaces, but fyi ls team/space still finds it.)

fyi read <doc>

Prints the doc's body in its natural form:

| Type | Output | | --- | --- | | md | Markdown, the same conversion fyi pull writes | | html | The current file's HTML, with the data of each built-in image left out: a long base64 data: URI keeps its type and becomes data:image/webp;base64,[42 KB left out], and a note on stderr says how much was left out. --raw: every byte as stored. --text: headings (as # lines) and readable text only, links kept as links; script and style content never appears | | list | The list as pretty JSON: an intro, then sections of tasks (status, subtasks), notes and images | | sitemap | The sitemap as pretty JSON | | pdf | A few key: value lines (size, upload time, versions) and the doc's link | | sketch | An outline: a header (title, counts, revision) and the link, then one line per shape and free text, top to bottom then left to right (id, kind, colour, box x,y w×h, text as a JSON string, then size, dashed, heavy, in <id> for the shape it sits inside, link "<title>"), then one per connector (id source → target, label, sides when they aren't the facing ones, ends, route, style). See Drawing a sketch | | stack | Refused (exit 1): stacks aren't readable from the CLI |

An HTML doc with no file uploaded yet is an error (exit 1). --text or --raw on anything but an HTML doc is a usage error (exit 2), and so are the two together.

fyi comments <doc>

$ fyi comments "Setup guide"
Setup guide  (caracode/acme/setup-guide): 2 open threads

thread k57f…  ·  open  ·  on "run the migration first"
  Sarah (member) · 2026-10-02 09:14 UTC
    Does this need the old schema?
  Rich (owner) · 2026-10-02 09:30 UTC
    Yes, see step 2.

thread k58a…  ·  open  ·  general
  Ana (guest) · 2026-10-01 16:02 UTC
    Typo in the title.

Open threads by default; --all adds resolved ones. Each thread says what it's attached to: general, on "quoted text", on task "…" (lists), pin (HTML and sketches) or on page … (sitemaps). Stacks don't take comments.

fyi reply <thread-id> "<text>"

Replies to a thread as you, exactly as if you'd typed it in the app, and prints the new comment's id. Pass - instead of the text to read it from stdin:

fyi reply k57f… "Fixed in the latest publish."
cat answer.md | fyi reply k57f… -

A reply never changes the thread's status: a resolved thread stays resolved. Empty replies and replies over 10,000 characters are refused (exit 1); a thread you can't see is not found (exit 4).

Working a list

Five commands for a list doc's tasks, all at Member level, as in the app. fyi tasks and fyi task read; fyi status, fyi tick and fyi comment work the tasks, as you. None of them changes the list's structure (titles, sections, order), so they work even while someone has the list open in the editor. Changing the structure is below.

fyi tasks "Launch checklist" --not-done
fyi task "Launch checklist" "Railway project"
fyi status "Launch checklist" "Railway project" blocked --reason "DNS access"
fyi tick "Launch checklist" "Railway project" "Set env"
fyi comment "Launch checklist" "Railway project" "Waiting on the DNS change."

The <list> is named the way any doc is. A <task> is its id (fyi tasks prints one per task) or its title: an exact title wins (case and spacing don't matter), otherwise a title that contains the text. If it matches several tasks, they're listed with their sections and ids, nothing is picked, and the exit code is 5. A <subtask> is matched the same way, within its task.

The statuses are open, in_progress, blocked, in_review and done (any case; in-progress and "in progress" work too). Plain output uses the app's labels; --json always carries these names.

fyi tasks <list>

$ fyi tasks "Launch checklist" --not-done
Launch checklist  (caracode/acme/launch-checklist)  ·  4 tasks: 1 Open, 1 In progress, 1 Blocked, 1 Done

Railway
  Blocked      Deploy  (waiting on: DNS access)  ·  assignee: Sarah  ·  1/2 subtasks  ·  id t_d1
  In progress  Railway project  ·  id t_rp

Docs
  Open         Write changelog  ·  id t_open

A task's assignee (one per task, shown by display name) reads assignee: Sarah, or assignee: Sarah (left the space) when they've since left; unassigned tasks show nothing. The header's counts are always over the whole list. --not-done keeps Open, In progress, Blocked and In review: "what's open" means not done. --status s1,s2 keeps exactly those statuses (--status open is only the Open ones), and can't be combined with --not-done. --section <name> keeps one section's tasks. --sort list (the default) groups tasks under their sections in list order; status sorts in workflow order; updated puts the newest status change first.

fyi task <list> <task>

One task in full: its section, status (with the reason or review note), its assignee when it has one, who changed it and when, description, attachments, subtasks with their ids, and its open comment threads with their ids, so fyi reply works on them too.

Attachments list each file and image by name, kind and size, and each linked doc by title, type and team/space/doc ref, so fyi read <ref> opens it. Only doc links you can open are shown. Older screenshots (added before attachments) are counted on the last line, when there are any:

attachments:
  - invoice.pdf (file, 1.2 MB)
  - bug.png (image, 340.0 KB)
  - Launch brief (md doc: riar/acme/launch-brief)
  - 2 screenshots (open the list in the app to see them)

fyi status <list> <task> <status>

Sets the task's status, under the app's rules, and says what happened in the app's words:

$ fyi status "Launch checklist" Deploy blocked --reason "DNS access"
Deploy: Open → Blocked (waiting on: DNS access)
$ fyi status "Launch checklist" Deploy blocked --reason "the registrar"
Deploy: changed the reason to "the registrar"
$ fyi status "Launch checklist" Deploy blocked
Deploy: cleared the reason
$ fyi status "Launch checklist" Deploy blocked
Deploy is already Blocked.

--reason "<text>" goes only with blocked, and --note "<text>" (the review note) only with in_review; anything else is a usage error (exit 2). Setting a task blocked again with no --reason clears its reason, as in the app.

fyi tick <list> <task> <subtask> [--undo]

Ticks a subtask (--undo unticks it): Ticked "Set env" on Deploy, or "Set env" on Deploy is already ticked. when there was nothing to do.

fyi comment <list> <task> "<text>"

Comments on the task as you. The comment joins the task's conversation (the thread the app's task panel replies to), or starts it if the task has none: Commented on "Deploy" (new thread k57f…) or (reply in thread k57f…). The text works exactly as in fyi reply: - reads it from stdin, and text that starts with - goes after --. It never changes a thread's status.

A task that was typed in the app less than a second ago may not be saved yet; the server then says there's no such task (exit 1), and a retry works. If the server says not found (exit 4) on a list you just read, either the list is gone or the fyi server is older than this CLI.

Editing a list's structure

Six commands change what a list holds and where, as you. They need Editor in the list's space (a Member works tasks but can't restructure, as in the app; the CLI says so before sending anything).

fyi add-task "Launch checklist" "Wire Turnstile" --section Cloudflare \
  --description "Keys live in the vault." --subtask "Get keys" --subtask "Test the widget"
fyi add-subtask "Launch checklist" "Wire Turnstile" "Ship it" "Tell the team"
fyi add-section "Launch checklist" "Cloudflare" --note "DNS and bot protection" --before Docs
fyi edit "Launch checklist" "Wire Turnstile" --title "Wire up Turnstile" --description ""
fyi edit "Launch checklist" "Wire Turnstile" --subtask "Get keys" --title "Get the site keys"
fyi edit "Launch checklist" --section Cloudflare --note "DNS, WAF and Turnstile"
fyi move "Launch checklist" "Wire Turnstile" --after "Point DNS at Railway"
fyi move-section "Launch checklist" Cloudflare --before Docs

| Command | What it does | | --- | --- | | fyi add-task <list> "<title>" | Adds a task. --section <s>: at the end of that section. --before <task> / --after <task>: next to that task, in its section. --intro, or no placement at all: at the end of the area above the first section (the whole list, if it has no sections). --description "<text>" and any number of --subtask "<title>" go in the same change. | | fyi add-subtask <list> <task> "<title>" … | Adds subtasks to a task, in the order given. A task holds at most 20. | | fyi add-section <list> "<title>" | Adds a section at the end of the list, or --before/--after another section. --note "<text>" gives it a note. | | fyi edit <list> <task> | --title and/or --description of a task. | | fyi edit <list> <task> --subtask <subtask> --title "<text>" | Renames one of the task's subtasks. | | fyi edit <list> --section <section> | --title and/or --note of a section. | | fyi move <list> <task> | Exactly one of --section <s> (end of it), --intro (above the sections), --before <task>, --after <task>. | | fyi move-section <list> <section> | Exactly one of --before <section>, --after <section>. The section's tasks go with it. |

Tasks, subtasks and sections are named as in the task commands: an id, an exact title, or part of one title, and several matches are listed, never guessed (exit 5). --description "" or --note "" on fyi edit clears the field; on the add commands an empty one is a usage error (leave it off). --description - and --note - read the text from stdin. Titles are trimmed, and at most 500 characters; descriptions and notes at most 10,000.

Each command is one change, applied to the list as it is now. The CLI reads the list once to turn names into ids, then sends the whole command as one all-or-nothing batch: add-task with a description and two subtasks lands complete or not at all. Nothing is ever sent back as a whole list, so nothing someone just did in the app is undone.

It never takes over from a person. While someone is typing in the list (their hold lasts until about 30 seconds after their last keystroke, or up to two minutes if their tab crashed or went offline), the server refuses structure edits. The CLI then waits, printing one line on stderr (Rich is editing this list — waiting for them to finish…), tries again every 3 seconds, and goes through as soon as they stop. After a minute it gives up with exit 6, naming who's editing. --no-wait gives up at once. An open tab that nobody is typing in doesn't hold the list, and it shows the change live.

Plain output is one line saying what happened and where, ending in the id:

Added "Wire Turnstile" to Cloudflare (t_k57f…), with 2 subtasks and a description
Added subtask "Ship it" to "Wire Turnstile" (st_9x2…)
Added section "Cloudflare" before "Docs" (s_3jq…), with a note
Updated task "Wire up Turnstile" (t_k57f…): title was "Wire Turnstile", description cleared
Renamed subtask "Get keys" to "Get the site keys" on "Wire Turnstile" (st_1ab…)
Moved "Wire Turnstile" after "Point DNS at Railway" in Railway (t_k57f…)
"Wire Turnstile" is already there — nothing changed.

Deleting tasks, subtasks or sections isn't possible from the CLI: do it in the app.

Drawing a sketch

A Sketch doc is a boxes-and-arrows diagram. fyi read prints one as an outline, and fyi draw changes it, or creates one, with a batch of operations: add, change, move, connect, reorder and delete shapes, free text and connectors.

fyi read "Signup flow"                          # the outline, with every id
fyi draw "Signup flow" ops.json                 # apply a batch (- reads it from stdin)
fyi draw --new "Signup flow" ops.json           # create a sketch and draw it
fyi draw --new "Signup flow" --space riar/acme --folder Diagrams --draft
fyi draw --help                                 # every op, field and placement rule

A batch is a JSON file, {"layout"?: "right" | "down", "ops": [ … ]} (a bare [ … ] of ops works too). A lane with three steps in it:

{"ops": [
  {"op": "addShape", "ref": "lane", "kind": "rect", "dashed": true, "w": 640, "h": 200, "at": {"x": 0, "y": 0}},
  {"op": "addText", "text": "Admin", "size": "l", "above": "@lane"},
  {"op": "addShape", "ref": "invite", "text": "Invites a user", "inside": "@lane"},
  {"op": "addShape", "ref": "check", "kind": "diamond", "text": "Has an account?", "rightOf": "@invite"},
  {"op": "addShape", "ref": "join", "text": "Joins the space", "rightOf": "@check"},
  {"op": "connect", "from": "@invite", "to": "@check"},
  {"op": "connect", "from": "@check", "to": "@join", "label": "Yes"}
]}

| Op | Fields | | --- | --- | | addShape | ref, kind (rounded default, rect, ellipse, diamond), text, color, size, dashed, heavy, w, h, link, a placement | | addText | text (required), ref, color, size, a placement. Free text, sized to its words | | connect | from, to (required), ref, fromSide, toSide, route, ends, color, dashed, heavy, label | | update | id (required) and only what changes. Shapes: text, kind, color, size, dashed, heavy, w, h, link. Free text: text, color, size. Connectors: label, route, ends, color, dashed, heavy, and from/fromSide/to/toSide to re-attach. link: null / label: null remove them | | move | id (required), then one placement, or by: {"dx", "dy"} | | reverse | id: a connector, turned end for end | | reorder | ids, to: front or back | | delete | ids: a shape's connectors go with it |

  • Refs. An op that creates something can carry "ref": "<name>"; later ops in the batch point at it as "@<name>" ("@<k>" is whatever op k made). Anything else is an id from fyi read. Ids never change, so comment pins stay on their shapes.
  • Placement: at: {x, y}; below / above / rightOf / leftOf a shape (centred on that side, 40px away, pushed on until nothing overlaps; next to a step in a lane, it stays in the lane); inside a shape (its first free spot); or nothing, for an automatic layout of the batch's new shapes along its connectors, left to right ("layout": "down" for top to bottom). Relative and inside placements go first, then the automatic block, so they can't name an automatically placed shape: give the first shape on an empty sketch an at. For steps in a lane, put the first inside and the rest rightOf the one before.
  • Text fit. New shapes grow, 20px at a time, until their text fits.
  • Links name a doc the way every command does (URL, mapped file, ref, or a title in the sketch's space); fyi draw turns each into the doc's id before sending. A name that matches nothing (exit 4) or several docs (exit 5) stops the command before anything is sent. A doc id isn't a name: one copied from fyi read --json is refused (exit 4) with a pointer to the doc's ref.
  • All or nothing. A refused op prints the server's reason, ops[k]: <reason>, and nothing is written (exit 1).
  • Deleting a shape leaves any comment pin on it where it was, marked as on a deleted shape.
  • Who. Editors and up; a Member when the sketch lets Members edit (the CLI says so in one line before sending anything otherwise). Creating needs Editor. --new goes in the linked space and folder, or --space (its top level) and --folder, and is published unless --draft.
  • Waiting. While someone is editing the sketch, fyi draw waits for them (Rich is editing this sketch — waiting for them to finish…), as the list commands do: up to a minute, then exit 6. --no-wait gives up at once.

Plain output is one summary line, each new thing's id (by its ref, else by @k), and the link:

Added 5 shapes and 2 connectors
  @lane   → n-ayyjke4b
  @1      → t-8i24s6s4
  @invite → n-457znefm
  @check  → n-q7cnf3ip
  @join   → n-zfzlj2e6
  @5      → e-nqfwvn4z
  @6      → e-naqrk3hj
https://fyi.riar.app/riar/acme/signup-flow

--new adds a first line, Created sketch "Signup flow". A batch that changed nothing prints No changes: the sketch already matched.

Posting to a log

A Log doc is a running stream of dated entries — a dev log, sales updates, meeting notes, a product changelog. Adding an entry is not editing the doc: it takes no edit lock, so posting never waits for anyone, and any Member can post.

fyi post "Dev log" -                               # a new entry, text from stdin
fyi post "Dev log" notes.md                        # …or from a file
fyi post "Product changelog" --version 0.20.0 -    # a changelog entry; the version is its key
fyi post "Product changelog" --type fixed -        # a changelog entry with a badge
fyi post "Meeting log" --title "Acme kickoff" --date 2026-10-05 --attendees "Sam, Jo" notes.md
fyi post "Dev log" --key spec/log-docs -           # replaces the entry with that key, if any
fyi post --new "Sales updates" --preset updates -  # create the log, then post its first entry
fyi post --changelog --version 0.20.0 -            # this repo's changelog log (fyi init sets it)
fyi entries "Dev log" --since 2w --limit 20        # newest first, each with its id
fyi entries "Meeting log" --search pricing         # search one log (--all-logs: the whole space)
fyi entry "Dev log" <id>                           # one entry with its comments
fyi edit-entry "Dev log" <id> --title "…" -        # change fields and/or replace the text
  • The text. - reads stdin; anything else is a file path. With neither, fyi post reads stdin when something is piped in, and otherwise exits 2 ("pass - or a file"). It's Markdown; hard-wrapped lines are joined, as on fyi publish.
  • The date is the day it happened, YYYY-MM-DD: your machine's today unless --date says an earlier day (never a later one). The stream sorts by it.
  • Fields. --title on any entry. --attendees "Sam, Jo" (comma-separated) for a meeting, --version for a changelog line. An entry needs some text, a title or a version — a changelog line can be just its version.
  • Badges (Changelog only). --type new|improved|tweaked|fixed|removed|planned marks a changelog entry with a badge (planned is for something announced but not out yet: when it ships, fyi edit-entry it to new); --type "" clears it. Any other word is exit 2, and so is --type on an Updates or Meetings log. It travels as change in --json. A key replace keeps the entry's badge unless you pass --type (the one field a replace doesn't clear when you leave it out).
  • Keys make posting idempotent. --key (or, in a Changelog log, the --version when there's no --key) names the entry: posting the same key again replaces that entry's text and fields — same id, same comments, the original author kept, marked edited. If it's someone else's entry and you can't edit it (you're a Member, they posted it), nothing is posted and the exit code is 6, naming who did.
  • Limits are refused, never cut (exit 2): text 100,000 characters, title 200, version 40, key 120, 30 attendees of 80 characters each.
  • --new "<title>" creates the log first (Editor and up) in the linked space or --space, the linked folder or --folder, published unless --draft. --preset is updates (the default), meetings or changelog. Without --new, a log that doesn't exist is exit 4.
  • fyi entries prints the newest 20 as Markdown, each headed by its date (a meeting by its title, a changelog line by its version, led by its badge: [Fixed] v0.20.0) with its author and id. --since takes 7d, 2w or a date; --limit up to 50; --search finds words in titles, text, attendees and versions and shows the best matches, newest first. --all-logs reads every log in the space: the log name is optional then, and the space is the named log's, else --space, else the linked one. A rollup log reads as its sources, merged.
  • fyi edit-entry changes only what you pass: --title "" (or --version, --key, --attendees, --type) clears a field, and - or a file replaces the text. Yours always; anyone's as an Editor.
  • fyi read <log> prints the same newest 20 as fyi entries.
  • Images and attachments. Entries posted in the app can hold images and attached files. The CLI prints image Markdown as it is, and lists an entry's attachments by name under its text (Attachments: options.pdf (2.4 MB)). It doesn't upload or download them.

The repo's changelog

fyi init asks once, interactively: Post release notes to a log? Pick a log in the space, create a Changelog log, or skip. The answer is saved as "changelog": "<docId>" in .fyi.json, and the CLAUDE.md block names it ("Release notes for this repo go to Changelog: fyi post --changelog --version <v> -"). Without a prompt: fyi init --changelog "<log>" (any way of naming a doc), fyi init --changelog new, or fyi init --no-changelog to clear it. fyi post --changelog with none set exits 2: "No changelog log set for this repo — run fyi init or name the log."

With --json: fyi post prints {"doc", "entryId", "replaced", "entry"} (plus "created": true with --new); fyi edit-entry {"doc", "entryId", "entry"}; fyi entries {"doc", "preset", "scope", "search", "entries", "more", "capped"}; fyi entry {"doc", "entry", "comments"}. An entry is {"id", "log", "preset", "title", "body", "happenedOn", "postedAt", "authorName", "editedAt", "editedByName", "attendees", "version", "key", "change", "commentCount", "canEdit", "url", "attachments"} (change is a changelog badge or null; attachments is [{"name", "kind": "image"|"file", "size"}]).

--json and exit codes

Every command takes --json. It prints one JSON document on stdout, for success and failure alike, and never prompts (it implies --yes). stderr only ever carries --verbose logs and hints.

| Command | --json success | | --- | --- | | fyi ls | {"space", "folders", "docs"}, or {"spaces"} | | fyi read | {"doc", "body"}, plus "file" (size, upload time, uploader, version id) for HTML and PDF. body.kind is md (markdown), html (html, plus imagesLeftOut: {count, bytes} when image data was left out; or text with --text), list (list), sitemap (sitemap), pdf, or sketch (revision, sketch: the parsed sketch, linkedDocs: each linked doc id you can see → {"title", "ref"}) | | fyi comments | {"doc", "threads"} | | fyi reply | {"threadId", "commentId", "docId"} | | fyi tasks | {"doc", "filters", "counts", "tasks"}; each task is {"id", "title", "section", "status", "blockedReason"?, "reviewNote"?, "assignee"?, "description"?, "subtasks", "imageCount", "attachments"?, "updatedAt"?, "updatedByName"?}; assignee is {"name", "inSpace"} and only present on an assigned task ("inSpace": false: they've since left the space); each attachment is {"kind": "file"\|"image", "name", "size", "addedByName", "addedAt"} or {"kind": "doc", "docId", "title", "type", "ref", "addedByName", "addedAt"} | | fyi task | {"doc", "task", "threads"} | | fyi status | {"doc", "task": {"id", "title"}, "from", "status", "blockedReason"?, "reviewNote"?, "changed"} | | fyi tick | {"doc", "task": {"id", "title"}, "subtask": {"id", "title"}, "done", "changed"} | | fyi comment | {"doc", "task": {"id", "title"}, "threadId", "commentId", "docId", "taskId", "newThread"} | | fyi add-task | {"doc", "changed", "revision", "added", "task", "to"} | | fyi add-subtask | {"doc", "changed", "revision", "added", "task"} | | fyi add-section | {"doc", "changed", "revision", "added", "section", "to"} | | fyi edit | {"doc", "changed", "revision", "added": [], "task", "set"}; a subtask: "task", "subtask", "set"; a section: "section", "set" | | fyi move | {"doc", "changed", "revision", "added": [], "task", "from", "to"} | | fyi move-section | {"doc", "changed", "revision", "added": [], "section", "to"} | | fyi draw <sketch> | {"doc", "revision", "changed", "created", "counts"} | | fyi draw --new | {"doc", "docId", "url", "lastEditedAt", "status", "revision", "created", "counts"} | | fyi link | {"url", "ref", "title", "type"} | | fyi publish <file> | {"file", "outcome", "docId", "title", "url", "status"}. status is the doc's status once the run is done, on every outcome but a conflict; "statusChange" ("published" or "unpublished") is there when --live / --draft changed it. A skip carries lastEditedByName/lastEditedAt instead of title and url. A Markdown published outcome adds "looseComments" when the server reported it | | fyi share | {"url", "slug", "name", "reused", "needsPassword", "docId", "title"} | | fyi publish <dir>, fyi pull | {"files", "counts"}, whatever the exit code. A publish run's counts include madeLive and unpublished |

A failure is {"error": {"code", "message", …}}, where message is the sentence a human would see. An ambiguous name carries matches, the docs (or tasks, subtasks, sections) it matched. A structure edit or fyi draw that gave up waiting carries lastEditedByName and lastEditedAt (epoch ms).

In the structure commands' documents: changed is false when the list already was that way; revision is the list's revision after the change; added is what the command created, in order, each {"kind": "task" | "subtask" | "section", "id", "title"} (empty for edit and the moves). task, subtask and section are {"id", "title"}, titles as they are after the change. to is where a task went, {"section": {"id", "title"} | null, "before"?, "after"?} (null = above the sections; before/after name the neighbour task), or for a section {"before"?, "after"?} ({} = the end of the list). from (move) is {"section"} before the move. set (edit) is exactly what was sent, "title"?, "description"? or "note"?; "" means cleared.

| Exit | code | Meaning | | --- | --- | --- | | 0 | | Success (an md file skipped because it was edited in fyi is a success) | | 1 | failed | Anything else: network, server error, an unsupported doc type, a Member publishing, pulling, sharing or changing a list's structure, a Member drawing on a sketch that doesn't let Members edit, an operation the server refused (ops[k]: <reason>) | | 2 | usage | A bad flag or a missing argument | | 3 | unauthenticated | Not signed in, or the token was refused | | 4 | not_found | No such doc, no access, a name that matched nothing (a doc, task, subtask or section, or a sketch link), a dead mapping | | 5 | ambiguous | A name matched more than one doc (or task, subtask, section) | | 6 | conflict | The doc changed in fyi and your file changed too, or someone kept editing a list or sketch through a structure edit's or fyi draw's wait (or --no-wait). In a folder run with failures as well, 1 wins |

No command ever prints a signed download link, --verbose included.

CLAU