agentleFS
wwwyowwwyo/syokan/AGENTS.md

syokan

AGENTS.md0 starsChanged 18 days agoMITView on GitHub

syokan (召喚, "summon") is a verb. An LLM speaks a JSON incantation, and a rich, living interface appears — no JSX written, no build step. Typing syokan dashboard.json reads literally as "syokan dashboard.json": the command itself is the incantation. Views are ephemeral — summoned when needed, they fade; nothing is hoarded. Under the hood this is a schema-driven renderer: data pulled from multiple repositories, external APIs, and the filesystem is rendered for humans through predefined React components. LLMs (Claude Code…

  • Pipes a download into a shell
  • Reads credentials
  • Installs packages
  • Commits and pushes

The file

We split this AGENTS.md into the rules it gives an agent

4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    If all of these flow into the same JSON envelope at POST /api/snapshots, the renderer stays unchanged no matter how many input paths are …

    line 66
  2. Distinctive:

    Instead of having the LLM "write JSX", have it output a JSON tree that satisfies a schema.

    line 83
  3. Distinctive:

    No flattery. Point out problems and risks bluntly

    line 198
  4. Distinctive:

    Write comments only to explain "why".

    line 199

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

syokan

syokan (召喚, "summon") is a verb. An LLM speaks a JSON incantation, and a rich, living interface appears — no JSX written, no build step. Typing syokan dashboard.json reads literally as "syokan dashboard.json": the command itself is the incantation. Views are ephemeral — summoned when needed, they fade; nothing is hoarded. Under the hood this is a schema-driven renderer: data pulled from multiple repositories, external APIs, and the filesystem is rendered for humans through predefined React components. LLMs (Claude Code / scheduled agents / CLI) post a JSON tree only — no per-view JSX is ever generated.

Setup and usage live in README.md; the catalog type list and the POST /api/snapshots envelope schema are sourced live from GET /api/catalog (syokan catalog); CLI commands live in syokan --help (machine-readable via --help --json) — those are the SSOT. This AGENTS.md covers design judgments (why) and development conventions (how to change things).

Repo learning skills

  • Brand/logo/OGP changes should load .agents/skills/brand-assets/ for repo-specific asset pitfalls maintained by session-retro.
  • Code, build, dev-server, and filesystem-watcher changes should load .agents/skills/coding/ for repo-specific pitfalls maintained by session-retro.
  • Snapshot store / envelope / API compatibility changes should load .agents/skills/coding/ for repo-specific migration pitfalls maintained by session-retro.

Why build this

The problem to solve

The data I want to look at is scattered around me:

  • Today's RSS feed (a daily input markdown)
  • An in-progress code review (diff fetched via gh + my own comments)
  • Meeting-notes markdown shared at work (an LLM structures it into a catalog tree on the spot)
  • Today's Calendar / TODO
  • Re-reading an article I saved somewhere

I want to see these at a single URL, in structured UI, only when needed. Opening markdown files one after another doesn't fit the requirement, and neither does hoarding everything in Notion.

The limits of having Claude Code write JSX every time

Building views with Claude Code is possible, but generating JSX each time means:

  • Token cost balloons (hundreds of lines of JSX × display frequency)
  • Generation speed is slow (seconds to tens of seconds of lag)
  • Accuracy wobbles (wrong props, type mismatches, missing imports)
  • Design consistency erodes (the same Card looks subtly different from day to day)
  • Un-refactorable — each page becomes a snowflake; no shared change is possible

An LLM's real strength is structuring data, not rendering it. Rendering, designed once up front, can be reused. I want to structure that division of labor.

Why separate from the persistence layer

The place where data should be kept and the place where you just want to look at it have different lifecycles:

Keep (persistence layer)Just look (syokan)
Example"a concept I learned 3 months ago""today's RSS", "an in-progress review"
Lost on deletionKnowledgeNothing (reconstructible)
BackupRequiredUnnecessary
Schema stabilityHighLoose

Mixing these into the same store means:

  • Temporary review state pollutes long-term memory
  • "Today's RSS" shows up in a search three years later
  • Deletion/cleanup policies clash, so you end up carving out a separate table with retention anyway → in which case, separate them from the start

Therefore syokan does not persist data. Anything worth keeping is explicitly promoted to a separate persistence layer (the promotion path).

Why not pin down the interface

Locking the input path to MCP makes CLI, webhooks, and paste second-class citizens. Actual usage:

  • Claude Code posts via file-edit-based workflows
  • From the CLI, wrap local content (shared meeting notes, etc.) into an envelope on the spot and post it to view
  • A scheduled agent may push periodically in the future
  • A gh webhook may trigger on PR reviews

If all of these flow into the same JSON envelope at POST /api/snapshots, the renderer stays unchanged no matter how many input paths are added.

Core design

Separation of roles

LayerRoleThis project
Memory layer (for LLMs)Long-term memory, wiki, knowledge queriesOut of scope (a separate persistence layer owns this)
View layer (for humans)Ephemeral dashboards, in-progress reviews, today's RSSThis (syokan)

syokan does not persist data (ephemeral). Information that needs long-term storage is explicitly promoted to a separate persistence layer.

Ephemeral in appearance, kept as a record (archive). Once view writeback put human check state into snapshots, a destroying DELETE made sidebar tidying erase "what I looked at and finished that day". So DELETE /api/snapshots/:id archives: the envelope (check state included) is copied to state/archive/<id>.json — one file per id, always overwritten with the latest envelope; archivedAt marks the last archive time, not a generation — and dropped from the active store. The list and view routes treat it exactly like the old delete (SSE delete, view → not-found), but GET /api/snapshots/:id still returns it (active wins over an archive record of the same id; archivedAt is null while active), GET /api/snapshots?archived=1 lists archive records (a query param, because a literal /archived segment would collide with :id), and syokan snapshots list|get|purge is the LLM's entry. Re-posting an archived snapshot's idempotencyKey revives it under the same id/URL with the posted content (the key→id mapping outlives the active entry); the archive record is not consumed, and old checks are never merged into the new tree (that would reintroduce the item-correspondence problem). Physical removal is only POST /api/snapshots/:id/purge (AIP-136 custom method; Bun.serve can't route a :verb suffix) — archive records only, never an active snapshot. Why not export to files: two authorities (file + store) is the split-brain view-writeback removed. Why not a git backend: an external writer bypasses the write lock/CAS merge point; history/backup is a one-way downstream sync of state/archive/ (private remote only — it holds content and check state), not a request-path concern. Archive paths are built from :id, so an id must be a single safe path component ([A-Za-z0-9_-]{1,128}) before it reaches join(). PRD: docs/prd/snapshot-archive/.

Schema-driven view

Instead of having the LLM "write JSX", have it output a JSON tree that satisfies a schema. Zod validates it; the catalog maps it to React components.

{ type: "Heading", props: { text, href } }
                ↓
        catalog["Heading"] → <Heading {...props} />

Benefits: fewer LLM tokens, generation speed, type safety, design consistency, easy refactoring.

The catalog's type and props definitions have src/catalogs as the single SSOT. manifest.ts turns them into JSON Schema and exposes them at GET /api/catalog; the LLM (skill) pulls the props contract from this API rather than from a transcription in md. Copying into md drifts on every catalog change, so the SSOT is kept in exactly one place.

When you add / remove / rename a catalog type (or change the envelope, CLI, or a cross-cutting mechanism), update the in-repo skill skills/syokan/ in the same change. Props and the envelope shape are pulled live from GET /api/catalog, so they never need transcribing — but the skill's SKILL.md still hand-carries the prose describing the posting flow and each type's intended use, and that does not auto-follow the catalog. skills/syokan/ is the SSOT of the skill (the dotfiles .agents/skills/ copy is downstream and out of scope here). This is exactly what drifted when markdown support was removed and the primitive types were added.

Catalog admission bar

A new catalog node must buy at least one of: (1) interaction / viewer-local state (checks, folds — see Checklist/Collapsible), (2) validated data semantics (a schema that catches producer mistakes, with the renderer owning presentation consistency), (3) a rendering engine (diff view, mermaid, syntax highlighting), (4) structure consumed by navigation/anchors (id, in-view links). Alias / thin-wrapper nodes that only relabel another node are forbidden — the deleted PlainText node delegated to Code with no lang and bought nothing. Typography/prose belongs to Markdown, not to a new node.

Adding a node is non-breaking; removing one is breaking (consumers already emit it). This asymmetry sets the bar for adding at maximal, and a node whose usefulness is doubtful should be removed early — "might need it later" is not a keep reason; a removed node can always be re-added once an actual need shows up.

Templates — for "making a favorite view reproducible" — follow the same philosophy, with syokan as the SSOT. A template is "a saved envelope as-is"; syokan does not interpret its contents, it only stores and lists them (~/.local/share/syokan/templates/, GET/POST/DELETE /api/templates). There is no placeholder render engine. Assembling from a template (placeholder substitution, expanding variable-length lists) is the LLM's (skill's) job; syokan stays a vault. Unlike snapshots (ephemeral views; deleted ones survive only as archive records), templates are meant to be kept.

Interface-free

The input path is not fixed to any one of MCP / CLI / HTTP / paste. Everything is unified into the same JSON envelope at POST /api/snapshots (the envelope schema's SSOT is the envelope key of GET /api/catalog).

[Claude Code]      ──┐
[CLI]              ──┤
[scheduled agent]  ──┼──→ JSON tree → catalog → React render
[gh webhook]       ──┘

The store is the view (writeback + change notifications)

Every view renders the stored snapshot — there is no file reference or live-source mode (the TreeDoc file-reference node, /api/files*, and the file-watcher were removed; the driver was "view 上での操作が、データに還らない", docs/prd/view-writeback/). Why not the source file as SSOT: writes from outside syokan (LLM Write, editors, other agents) cannot be serialized, so no single merge point exists for the PATCH CAS — it degrades to silent last-writer-wins. stdin posts have no file at all, so a file-backed mode would recreate two authority kinds. And a file mid-edit is not guaranteed to be a valid catalog tree. file:<abspath> survives only as an idempotency identity; content authority moved to the store. What keeps views live is two write paths plus a notification:

  • Full refresh: PUT /api/snapshots replaces root/title in place by idempotencyKey (same id/URL). syokan <tree.json> inlines a bare catalog tree into { title: basename, root: <tree>, idempotencyKey: "file:<abspath>" } — the snapshot holds the file's content at invocation time; re-running updates the same view, and renaming the file mints a new one.
  • View writeback: PATCH /api/snapshots/:id applies a conditional item-scoped set { nodeId, item: { label, occurrence }, set, expect } to the node carrying id, inside the same write lock as PUT. item picks the occurrenceth (1-based) same-label element of the node's items — the node-identity item correspondence, never an index. expect is a prop-level precondition on the node: Checklist sends the rendered items array verbatim (a compare-and-set — null stands for an absent prop), so a same-label insertion that would silently shift occurrence is refused before the wrong item is rewritten. The mutated node's props must still satisfy its catalog schema — checked node-scoped, not tree-wide: stored trees aren't revalidated on read and may carry since-removed types, which must not freeze writebacks on unrelated nodes. Either way a view cannot corrupt the store. Refusals are 409 node_not_found / 409 target_not_found (label absent or occurrence out of range) / 409 value_conflict (expect no longer matches) / 422 invalid_set (schema or prototype-chain keys) — the view reverts its optimistic state and refetches (WritebackTarget.refresh) on any of them, then waits for the user to re-operate. Parallel PATCHes serialize on the write lock, so a second writer sharing an expect always loses the CAS; consecutive view writebacks fold queued write values into the next expect.items — the items confirmed by the previous write. Only Checklist checks write back today; useWritebackTarget (src/lib/viewState.tsx) is the gate — it yields a target only for an id-carrying node inside a store-backed, non-shared view, so share viewers and template previews can never write.
  • Change notifications: every active-store mutation (create — revive included — /update/patch/delete=archive; purge touches only the archive and emits nothing) emits event: change + { id, kind } over SSE at GET /api/snapshots/changes. Events coalesce over a short window into one invalidation carrying the union of ids; a patch can't move id/title/createdAt, so it refetches only the view showing that snapshot, while other kinds also re-run the shell list — a delete of the open snapshot drops to notFound. Notifications are in-process only (a second server instance writing the same store file isn't seen); the focus/visibilitychange refetch stays as the floor for that. Content is never pushed over SSE — "changed" only, then a GET — so initial mount and updates share one read path.
  • Origin guard: every mutation endpoint (snapshots POST/PUT/PATCH/DELETE/purge, templates, settings, probes, publish/auth) rejects a request whose Origin/Referer authority differs from the request's own (server/origin.ts) — a page on another origin, another localhost port included, cannot drive writes; requests carrying neither header (CLI/curl) pass.

Input is catalog trees only: markdown is deliberately not rendered — a second expression path would let LLMs bypass structuring and mask catalog gaps (history: .agent/prd/json-only-view/).

Directory structure

syokan/                      # bun workspace root (package "syokan-workspace"): delegation scripts only, no deps. tsconfig.base.json is the shared compilerOptions base
├── apps/syokan/             # the app (workspace package "@syokan/app"; version SSOT = its package.json)
│   ├── entry.ts             # dual-mode entry for the single binary (SYOKAN_SERVE switches cli/server)
│   ├── cli/syokan.ts        # CLI (post / open / stop / catalog / templates). Lazy-spawns the server (compiled: itself)
│   ├── build.ts             # compile script (host=dist/syokan; --release cross-compiles each OS/arch)
│   ├── src/
│   │   ├── frontend.tsx     # RouterProvider mount
│   │   ├── router.tsx       # TanStack Router route tree (root → _shell layout=AppShell → home/view)
│   │   ├── Home.tsx / ViewPage.tsx / Render.tsx  # each route's body + recursive JSON tree renderer
│   │   ├── schema/          # Zod schemas (catalog Item / envelope / validation formatting)
│   │   ├── lib/             # cross-cutting utils (paths=XDG 3-way (config/data/state) resolution / cn / date / code / snapshots ...)
│   │   ├── catalogs/        # ★ public types LLMs post as JSON. index.ts is the registry; manifest.ts JSON-Schemas it for GET /api/catalog
│   │   └── components/      # internal UI not registered in the catalog (ui=shadcn / AppShell / AppSidebar / PageLayout ...)
│   ├── server/             # Bun.serve (127.0.0.1 bind). routes.ts=/api/{snapshots(?archived=1),snapshots/:id/purge,snapshots/changes(SSE),catalog,templates,settings}, store.ts=active snapshots (ephemeral) + archive records (state/archive/<id>.json; DELETE archives, purge removes) + patch + change subscribers, origin.ts=foreign-Origin rejection on mutations, readFile.ts=text file reads for Probe, templates.ts=template storage (persistent), setting.ts=display-settings singleton (persistent), share.ts=publish/auth proxy entrypoint (a Hono sub-app mounted in Bun.serve — routing / CSRF guard / request+response shaping; the FE calls it via hc with the exported ShareAppType, so the FE depends only on its own server's contract, never on apps/share directly) with shareService.ts holding the operations (Worker client / auth.json token holder / publish assembly / list·delete proxy, returning domain results), redact.ts=probe redaction at publish
│   └── .storybook/         # visual review base for the catalog
├── apps/share/             # bun workspace package (@syokan/share): the public-share Cloudflare Worker (worker.ts=Hono /api/v1/*, hc<AppType> RPC types), viewer/ (share page + landing SPA), wrangler.jsonc. Deploys independently (`bun run deploy:share`). viewer imports apps/syokan/src/catalogs + Render.tsx directly — a deliberate coupling so catalog and viewer share one deploy unit (no schema drift). Design/history: .agent/prd/public-share/
└── skills/syokan/          # ★ SSOT of the syokan skill for LLMs. Bundled in the repo for distribution; the dotfiles side (.agents/skills/) is an installed copy. Always edit here when fixing the skill

Client-side routing uses TanStack Router (code-based routes; no Vite plugin — it rides Bun's bundler). The resident shell is a pathless layout route (_shell, component=AppShell) that mounts the sidebar and content slot exactly once; route transitions swap only the contents of <Outlet />. This keeps the sidebar's open/closed state, scroll position, and the already-fetched list alive across transitions, and the router's scrollRestoration restores the reading position in the body, keyed by pathname (per /snapshots/:id, not per history entry) so revisiting a snapshot restores where you left off. Direct URL / reload / back-forward work via the server's SPA fallback (/* → HTML). Paths matching no route are caught by a splat route (path: "$") under _shell so they render inside the shell (a pathless layout unmatches itself when no child matches, so a root-level splat would lose the shell).

The snapshot list is owned by the _shell layout's loader and is not re-fetched on child transitions (no loading flicker). Three refresh triggers, all via router.invalidate filtered to the relevant match (background revalidation = stale-while-revalidate, so the list never disappears): after an in-app deletion, on a /api/snapshots/changes SSE event (the view match only when its own id changed; _shell only for non-patch kinds, which can't move list fields), and on returning to the tab (focus / visibilitychange) as the floor for mutations the in-process SSE cannot see. Creation happens outside the app (CLI / LLM), so there is no in-app trigger; the SSE carries it to open windows in the same process. If the list fetch fails, the whole shell falls to the errorComponent (rather than leaving only the body, errors are surfaced together at the point of interaction — a deliberate trade-off).

The original MVP used full page reloads, premised on "no state carried between pages". Adding stateful chrome (the sidebar) broke that premise, so we deliberately switched to client routing (a reversal of the old "not adopting it" policy; history in .agent/prd/client-routing/).

Component collocation

Implement a component at <Name>/index.tsx and colocate its test (<Name>.test.tsx) and story (<Name>.stories.tsx) in the same dir. Imports point at the dir with a relative path, like ../PageLayout (resolves to index.tsx). No @/ alias — the share package pulls syokan sources across the workspace boundary, and a root-level alias made that resolution setup-dependent (tsconfig paths / Vite alias / Bun all had to agree), so imports are relative only (sole exception: components/ui/, below). Gathering related files in one place prevents stragglers during refactors/deletions.

  • Catalog-public (types LLMs post as JSON) go in catalogs/<Name>/; non-public internal parts go in components/<Name>/
  • Internal-only subparts (used solely by that component) nest under the parent dir (e.g. the catalog's Code/CopyButton/). Once shared by multiple components, promote it one level up. Scope = location
  • components/ui/ is the exception: shadcn's generated flat files (not dir-ified), and the one zone using # subpath imports (#lib/utils.ts etc.). components.json aliases point at package.json imports entries — officially supported by the shadcn CLI, so add output lands in the right place with working imports, untouched. # is Node-standard and resolves against this package's own package.json, so it has none of the cross-package ambiguity that got the @/ alias removed. (Relative aliases in components.json don't work: the alias string doubles as the verbatim import specifier in generated files, and a placement-correct value is import-broken and vice versa)

Setup / usage

Setup steps (mise / bun / portless) have README.md as SSOT. The catalog type list and the POST /api/snapshots envelope schema are sourced live from GET /api/catalog (syokan catalog) — transcribing into md drifts, so neither the README nor this file carries them. CLI commands, in the same spirit, have syokan --help (machine-readable via --help --json) as SSOT. To avoid duplication, none of it is written here.

Storybook is the visual review base for catalog components. <Name>/<Name>.stories.tsx enumerates prop variants, edge cases, and dark/light; .storybook/preview.tsx loads src/styles.css. The toolbar's .dark toggle verifies theme adherence. storybook is registered in .claude/launch.json, so it can also be started via preview. See the README for the launch command.

Tech stack

  • Runtime / bundler / HMR / TS execution: Bun (consolidated into one; no Vite / Hono / React Router)
  • Frontend: React + TypeScript (bundled via Bun's HTML import + native JSX/TSX)
  • UI: shadcn/ui (base-nova style) + @base-ui/react + Tailwind CSS v4
  • Backend: Bun.serve({ routes }) — the frontend is served via the index.html import (dev: on-the-fly bundle + HMR; compile: embedded in the binary). /api/* cohabits in the same process
  • Validation: Zod
  • Routing (server): Bun.serve routes patterns (/api/snapshots handler + /* SPA fallback)
  • Routing (client): TanStack Router (code-based routes, no Vite plugin). Chosen for meeting the loader / scrollRestoration / pending, notFound, error / resident-layout requirements
  • Component catalog: Storybook (@storybook/react-vite + @tailwindcss/vite). Catalog components become stories for visual review. The builder is Vite, but it is a Storybook-only devDep; the app's "no Vite" policy applies only to the app bundle (there is no Bun-native Storybook builder, so no alternative exists)

Distribution (compile / dev-global separation)

The global tool is a single executable (bun run compile → apps/syokan/dist/syokan). It runs without bun/node/npm. The split "see the latest while developing / use the pinned build day-to-day" is achieved not at the CLI or server level but by execution form (binary vs bun run dev).

  • Dual-mode entry: after compilation, cli + server + frontend live in one binary. entry.ts branches CLI / server on SYOKAN_SERVE. Lazy-spawn works as: "for the single binary, re-exec itself (process.execPath) with SYOKAN_SERVE=1; in dev, run bun apps/syokan/server/index.ts". The dev/compiled distinction is made by isCompiledBinary() (whether execPath's basename is bun). CLI args are argv.slice(2) in both modes (compiled builds also put the embedded entry in argv[1]).
  • The port is the only record of a running server; there is no pidfile. GET /api/health returns version + pid, which is the whole contract: whatever answers it is the server (adopt it, whoever started it), and the pid it reports is what stop signals. A file recording "the pid we started" was the previous design and it went stale the moment a server died or was started by hand — the CLI then spawned a rival that could never bind the occupied port, timed out after 15s on every call, and left the failed child running, so an agent looping over the CLI piled up servers. Corollaries: a readiness timeout reaps its own child (through the Bun child handle, never a bare pid, which may have been recycled), and a port that answers without that contract is reported as a port conflict rather than a readiness timeout — waiting cannot help when the child can never take the port. Nothing here is backward compatible with a server that predates the pid field; such a server reads as foreign.
  • The frontend is a static import index from "../index.html". Dev gets an on-the-fly bundle + HMR; at compile time Bun bundles the frontend and embeds it in the binary (the same static import serves both).
  • @tanstack/router-core is patched via bun patch (patches/, patchedDependencies in the root package.json) to defer one module-evaluation read inside its router.js <-> load-client.js import cycle, because Bun's HMR runtime links modules in one phase and hands null to one side of a cycle (oven-sh/bun#40248, fix oven-sh/bun#40259 unreleased as of bun 1.4.2) — without it the dev page is blank. Disabling HMR was rejected (pays for a Bun bug with the whole dev loop) and pinning the router was rejected (freezes every router bump). bun drops a patch silently when the name@version key stops matching, so apps/syokan/server/routerCorePatch.test.ts guards it: on a router-core bump run bun run patch:router-core (apps/syokan/scripts/patch-router-core.ts re-applies the wrap, commits the patch, and cleans stale keys/files); once the pinned bun ships the fix, delete patch + entry + test.
  • tailwind / bun-plugin-tailwind are devDeps. bun build --compile (the CLI) cannot take plugins, so build.ts wires them explicitly via Bun.build({ compile, plugins:[tailwind] }) to expand CSS at compile time.
  • dev / global separation: global = 5773 (the SSOT is src/lib/port.ts, shared by the server's bind and the CLI's base URL); dev = 5873 with the repo-local .syokan-dev/ (gitignored). The ports differ, so the two lazy-spawned servers never collide. Both stay clear of vite's 5173/4173 and the 5174+ walk it takes when occupied: the global server is long-lived, so squatting a port frontend dev servers reach for first would silently push every other project onto a fallback port (this is why the original 5173/5273 pair was abandoned). Persistence locations are consolidated in src/lib/paths.ts and split across XDG base directories by lifecycle: settings (keep; tracked in dotfiles) at ~/.config/syokan/settings.json; templates (keep; user data) at ~/.local/share/syokan/templates/; snapshots + the server log (machine-local; survive restarts but need no backup) at ~/.local/state/syokan/; the snapshot archive at ~/.local/state/syokan/archive/ stays machine-local state too, but it is the only copy of archived content and check state, so it is worth backing up — via a downstream, one-way git sync to a private remote, never by making git the store. Everything used to be consolidated under config, but machine-local data mixed into the dotfiles-tracked tree (~/.config) invited accidentally committing it to git, so they were separated (respecting XDG_{CONFIG,DATA,STATE}_HOME). Snapshots go in state, not cache: cache's contract is "a third party may purge without notice", but syokan does not regenerate snapshots automatically (the producer must re-post), so losing them mid-use is unrecoverable. Location overrides carry no bespoke SYOKAN_* envs — they are consolidated on XDG_*_HOME alone. In dev, the dev script in package.json points XDG_{CONFIG,DATA,STATE}_HOME at $PWD/.syokan-dev/{config,data,state} so global settings stay clean. XDG envs affect the whole process, so they are scoped via env to the server process (bun) only, to avoid dragging in other pipeline tools like portless. To check renderer changes against a local snapshot, post to the dev server with syokan snapshot.json: inside this repo the mise [shell_alias] remaps syokan to the local-source CLI pinned to SYOKAN_BASE_URL=http://localhost:5873 (= the dev server), so in-repo syokan never posts to the production instance by accident. Outside the repo the alias is unset and syokan is the global binary (= 5773, production). Trade-off: while cd'd in the repo you can't reach the production instance via bare syokan (rare; production use happens outside the repo).
  • Gatekeeper may block unsigned binaries on macOS. Local builds run as-is, but if a distributed/copied binary is rejected: codesign --sign - apps/syokan/dist/syokan.
  • Distribution: bun run compile:all (= build.ts --release) cross-compiles each OS/arch and emits apps/syokan/dist/syokan-<os>-<arch> (names in a form mise's github backend can use to detect OS/arch). Cross-compilation downloads the target bun runtime each time. Pushing a v* tag runs the whole pipeline in release.yml: build → smoke → publish → homebrew. The version bump and the tag are separate steps, deliberately. main is guarded by the main-required-checks ruleset (required status checks, no bypass actors), so a bump commit pushed straight to main is rejected with GH013 — the bump rides an ordinary PR like any other change. bun run release (scripts/release.ts) therefore does only what a ruleset cannot block: on a clean main in sync with origin, it tags the merged bump commit with v<package.json version> and pushes that tag ref alone. The script used to bump + commit + git push --follow-tags in one go, which failed at the push every time and left a stranded bump commit locally. Publishing is gated on the smoke test so a broken artifact never ships, and the assets carry a checksums.txt that both downstream install paths verify against. The three install paths are brew install wwwyo/tap/syokan (formula regenerated per release by scripts/generate-formula.ts and pushed to the separate wwwyo/homebrew-tap repo — a one-directional flow, so the formula is never hand-edited there), curl … | sh (install.sh, served from the repo's default branch so it updates independently of releases), and the pre-existing mise use -g github:wwwyo/syokan@latest. The tap push needs the TAP_GITHUB_TOKEN secret (fine-grained PAT scoped to that one repo); it runs after publish so a tap failure shows up red without holding back the release.
  • Smoke test as the release gate: scripts/smoke.ts drives a compiled binary end to end (post → view → re-post a bare tree file for an in-place PUT → receive the snapshot SSE change → refetch → PATCH writeback → delete=archive (list/get, syokan snapshots) → revive by re-posting the file → purge (record gone, active untouched) → cross-origin rejection → stop) on both ubuntu and macos runners. It exists because bun test runs in dev mode and therefore never exercises lazy-spawn (re-exec of the binary itself), the embedded frontend, or the real SSE/write-lock path. Keep it dependency-free and self-isolating (temp XDG dirs + an OS-assigned port) so it can never touch a real install.

Communication policy

  • Docs and product copy are English-first (Japanese versions live in *.ja.md, e.g. README.ja.md). Use "syokan" as a bare transitive verb in both languages — EN "syokan your notes", JA 「今日の RSS を syokan」 — never "do syokan" / 「syokan する」; "syokan" stands on its own
  • No flattery. Point out problems and risks bluntly
  • Write comments only to explain "why". Code comments (and JSDoc) are written in English
  • Uphold the "no data persistence" principle. When persistence becomes tempting, think about a promotion path to a separate persistence layer

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.