Skip to content
Conduit

mcp server

hedge-cms

HEDGE (Headless + Edge) CMS for Cloudflare Workers — D1, R2, and Email Sending. Bun, Hono, React + shadcn/ui.

bihaviour/hedge-cmscommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 26, 2026

Hedge

HEDGE (Headless + Edge) CMS. Content management that runs entirely on Cloudflare's edge — no origin server, no container, no cold starts. One Worker serves the admin UI, the management API, and a cached read-only delivery API.

Deploy to Cloudflare

Status: early scaffold. The foundation below works end to end; features are being deepened.

Stack

ConcernChoice
RuntimeCloudflare Workers
ToolchainBun (package manager, test runner, scripts)
APIHono + Zod
DatabaseCloudflare D1 (SQLite) via Drizzle ORM
File storageCloudflare R2
EmailCloudflare Email Sending (send_email binding)
Admin UIReact 19 + Vite + Tailwind v4 + shadcn/ui
Lint/formatBiome

What's here

  • Sites — one deployment holds many websites: a blog, a docs site, a landing page. Each is its own namespace for collections, entries, media, API keys and members.
  • Users and members — two separate account types. A User operates the CMS; a Member is a visitor of one site who signs in on that site to unlock gated content, and can never reach the admin.
  • Per-site roles — owners and admins run the instance and reach every site; editors and viewers only reach the sites they are granted, at the role they are granted.
  • Auth — Better Auth on both sides: signed session cookies for operators, bearer tokens for website members, and OAuth 2.1 for MCP clients. A first-run onboarding wizard, emailed invites, password reset, email verification, revocable sessions, and rate limiting backed by D1. Nobody ever sets somebody else's password: users and members alike are added by email and choose their own.
  • Collections — user-defined content types. Twelve field kinds (text, richtext, number, boolean, date, select, media, reference, url, email, color, json), edited in the admin and enforced on write.
  • Entries — draft/published/archived, public or members-only, per-locale, keyset-paginated, with an automatic revision snapshot on every update.
  • Media — uploads to R2 with type and size limits, served from /media/* with immutable cache headers so repeat hits never touch the bucket.
  • Delivery API — /api/v1/content/*, published content only, s-maxage cached at the edge, authenticated with scoped per-site API keys.
  • Analytics — a dashboard on / and a detail page on /analytics: which articles were read, where readers came from, share clicks, and newsletter delivery. Fed by a first-party snippet your website embeds, because the Worker sits behind the edge cache and never sees your readers. No cookies, no visitor identity, Do Not Track honoured — see docs/website-analytics.md. Entirely optional.
  • MCP — an AI assistant can run the whole CMS over OAuth 2.1: collections, entries, media, newsletters, sites, users and keys, bounded by scopes you approve and your own role.

Repository layout

apps/
  api/            Cloudflare Worker — API, media serving, admin asset hosting
    src/routes/   auth · users · sites · members · api-keys · collections · entries · media · content · mcp
    src/lib/      The services behind the routes, shared with the MCP tools
    src/mcp/      MCP tool definitions, one module per area of the CMS
    src/auth/     The two Better Auth instances — operators (with OAuth for MCP) and members
    src/db/       Drizzle schema and client
    migrations/   D1 migrations (generated by drizzle-kit)
  admin/          React admin UI, built to dist/ and served by the Worker
  installer/      Wizard that provisions and deploys Hedge without a Git repository
packages/
  core/           Zod schemas and types shared by both sides
  deploy/         Cloudflare deploy client — used by the in-Worker updater and the installer
wrangler.jsonc    The Worker's only config — bindings, assets, vars, migrations

apps/installer is never a dependency of the Worker: the shared half lives in packages/deploy, which both import, so nothing about the installer is in the bundle that serves every request.

Deploy your own

Three ways in. They differ in what they need from you and in how the deployment updates afterwards — that last column is the one that catches people out, so it is here rather than three sections down.

For someone whoNeedsGives upUpdates by
Deploy buttonwants CI, preview URLs and a repository to work ina GitHub or GitLab accountnothingthe admin, or a Git merge that Workers Builds redeploys
Installerwants a CMS, not a repositorya Cloudflare account and a downloaded runnerCI, preview URLs, redeploy-on-pushthe admin
CLIalready has a terminal and opinionsBun and a checkoutone-click anythingbun run deploy, or the admin

All three run the same Hedge on the same Cloudflare products, and all three can update from Settings → About & updates by pasting a Cloudflare API token that is used once and never stored. The deployment records which one made it, so the admin only ever shows you instructions that exist for your deployment.

R2 must be enabled on the account whichever you pick, since media lives there.

The deploy button

Deploy to Cloudflare

The button clones this repository into your GitHub or GitLab account — a clone, not a fork, so it has no upstream relationship to this one — then creates the D1 database and R2 bucket it needs, builds the Worker and deploys it. The only thing you must type is AUTH_SECRET, a long random string that openssl rand -base64 32 will produce. Every later push to your copy redeploys.

The setup page also lists a few variables. Every one of them is fine as it stands — prefilled (INSTALLED_BY arrives as button; leave it) or empty:

FieldWhat to enter
PUBLIC_URLLeave empty until you add a custom domain. Then it must be the full origin, scheme included, no trailing slash — https://cms.example.com — as covered under next steps.
EMAIL_FROMLeave empty. Set it later under Settings → Email, once a domain of yours is onboarded for sending.
INSTALLED_BYLeave as button — so Settings → About & updates shows the update instructions that exist for this deployment.
WORKER_NAMELeave empty. Only the installer sets it; a button deployment is always hedge-cms.

There is nothing to tell it about your repository: every Workers Builds deploy records the repository it deployed from as REPO_URL on its own, which is what lets the admin's update notice deep-link your copy.

You land on https://hedge-cms.<your-subdomain>.workers.dev, in the onboarding wizard, where you create the owner account and the first site. Nothing else is required to start writing content.

The installer

For someone who wants a CMS and has no Git account. It creates the resources and deploys Hedge straight onto your Cloudflare account, with no repository anywhere.

bun run installer     # from a checkout — see apps/installer/README.md

It opens a wizard, asks for a Cloudflare API token, and does the rest: creates the database and bucket, applies the migrations, uploads the admin and the Worker, and hands back the URL. It generates AUTH_SECRET itself, so there is no openssl to run.

Your Cloudflare token never leaves your machine. That is not a courtesy — Cloudflare's API refuses cross-origin browser requests, so an installer that ran as a hosted web page would have to route every user's token through someone else's server, and this one does not. The reasoning is written down in docs/spikes/37-browser-cloudflare-api.

What you give up: no repository means no Workers Builds, so no CI, no preview URLs and no redeploy on push. Updates come from the admin. Email also needs a domain onboarded from a terminal before anything sends, exactly as it does for the other two paths.

Next steps, whichever path you took

Two things are worth doing when you need them:

  • Email. Invites, password resets and newsletters send through Cloudflare Email Sending, which needs a domain of yours onboarded first: bunx wrangler email sending enable yourdomain.com. Until then those sends fail — the address they send from is set under Settings → Email, and a site can send its own newsletters and member email as itself by setting a sender under Site settings. Operator invites and password resets always use the deployment's.

  • A custom domain. Add the route in the Cloudflare dashboard, then set the PUBLIC_URL variable to that URL. Left empty, the deployment answers with whatever origin the request arrived on, which is right for the generated workers.dev URL and for local development but not for a Worker reached by two hostnames.

    PUBLIC_URL must be a full origin, scheme included — https://cms.example.com, not cms.example.com, and no trailing slash. Authentication is built from this value, so a bare hostname is not a near miss: it is rejected outright and every sign-in and admin request answers 500, while /api/health keeps returning ok because it never touches auth.

Keeping a deployment up to date

Settings → About & updates shows the version the deployment runs and, when this project has cut a newer release, how to take it. The page offers the path that exists for your deployment — it knows which one made it, from the INSTALLED_BY variable — so an installer deployment is never told to sync a repository it does not have.

  • From the dashboard. Available to every deployment, however it was installed. An owner clicks Update now, pastes a Cloudflare API token that carries Workers Scripts:Edit and D1:Edit, and Hedge uploads the new release, applies pending migrations, and deploys — in that order, so new code never serves against an un-migrated schema. The token is used once and discarded: it is never written to the database, never logged, and never returned. The Worker holds no standing deploy credential, so reaching CMS admin never becomes reaching the Cloudflare account.

  • From Git, for a button or CLI deployment. The button creates a clone, not a fork, so there is no "Sync fork" button — add this repository as an upstream and merge it:

    git remote add upstream https://github.com/bihaviour/hedge-cms
    git fetch upstream && git merge upstream/main && git push
    

    Workers Builds redeploys on the push and applies any new database migrations on the way, and records your repository's URL as REPO_URL so the About page links straight to it.

Both paths use the same migration bookkeeping, so switching between them never re-runs or skips one.

Every update is something a person starts by presenting a credential. Nothing self-updates on a schedule, and nothing runs without someone deciding it should.

Getting started locally

Requires Bun 1.3+. No Cloudflare account: wrangler dev emulates D1, R2 and the assets binding on your machine.

bun install

# Local secrets
echo 'AUTH_SECRET="'$(openssl rand -base64 32)'"' > .dev.vars

bun run db:migrate          # apply migrations to the local D1
bun run db:seed             # optional sample collection and entries

Run the two dev servers in separate terminals:

bun run dev:api             # Worker on http://localhost:8787
bun run dev:admin           # Admin UI on http://localhost:5173 (proxies /api to the Worker)

Open http://localhost:5173. A deployment with no users starts in the onboarding wizard: it creates the owner account, then the first site. Nothing is created behind your back — a fresh database has no site until you name one, and an interrupted setup picks up where it left off when you sign in.

In development, emails are not sent — invite and reset links are printed to the dev:api console so you can follow them locally.

Deploying from your machine

The button above is the short path; this is the same deployment done by hand.

bunx wrangler secret put AUTH_SECRET
bunx wrangler email sending enable yourdomain.com   # required before any email is sent
bun run deploy                                      # build, migrate the remote D1, deploy

The D1 database and R2 bucket are created on the first deploy — wrangler.jsonc names them but carries no ids, which is what lets anyone deploy this repository into their own account unedited. PUBLIC_URL and EMAIL_FROM are the two variables worth setting there once the deployment has a domain; PUBLIC_URL is what invite, reset and media links point at.

Using the delivery API

Create a key in Settings → API keys with the content:read scope, then:

curl https://your-worker.workers.dev/api/v1/content/posts \
  -H "Authorization: Bearer hdg_..."
EndpointReturns
GET /api/v1/content/:collectionPublished entries, paginated via nextCursor
GET /api/v1/content/:collection/:slugA single published entry
GET /api/v1/content/:collection/_schemaThe collection's field definitions

Query parameters: locale, limit, cursor, sort, order.

A key belongs to the site it was created on and can only read that site's content, so nothing extra is needed to pick a tenant.

Previewing unpublished content

The delivery API serves published entries only — which leaves no way to see a draft the way a reader would, short of publishing it and looking. Preview in the entry editor closes that: the CMS mints a short-lived token scoped to that one entry, opens your website's own preview route with it, and your website's server forwards it to the delivery API in X-Hedge-Preview to render the draft in your real layout.

Point a site at its preview route under Settings → Configuration → Overview; the website side is a page you write. docs/preview.md walks through it, with worked snippets, the header the embedded pane needs, and the one mistake worth naming — handing the delivery key to the browser.

Images

A media field stores an R2 object key, not a URL — a stored URL bakes this deployment's origin into your content and is wrong the day the CMS moves domain. So data.cover is blog/2026/07/k1a2-photo.jpg, which a browser would resolve against your website's origin.

Entries in a collection that declares a media field therefore carry a resolved media sibling alongside data, built per request from the CMS's own origin:

{
  "slug": "hello-world",
  "data": { "title": "Hello", "cover": "blog/2026/07/k1a2-photo.jpg" },
  "media": {
    "cover": {
      "key": "blog/2026/07/k1a2-photo.jpg",
      "url": "https://your-worker.workers.dev/media/blog/2026/07/k1a2-photo.jpg",
      "alt": "A cat asleep on a keyboard",
      "width": 1600,
      "height": 900
    }
  }
}
<img src={entry.media.cover.url} alt={entry.media.cover.alt ?? ''} />

data is unchanged, so nothing that reads the key today breaks. A field declared multiple resolves to an array in stored order, a single one to an object, and a field left empty is absent from media entirely. A value that was already an absolute URL is passed through with a null key. Members-only entries withhold media exactly as they withhold data.

metadata.ogImage is different: it is always an absolute URL, whether you stored a key or a URL. Open Graph rejects a relative one, and the tag has a single slot, so there is no additive version of that fix. Metadata is served for locked entries too, so a paywalled page still previews correctly when it is shared.

Seeing what readers did

The admin opens on a dashboard, and /analytics breaks it down: articles ranked by views with their trend, where readers arrived from, share clicks, and how each newsletter was delivered.

Hedge cannot measure any of it on its own. Your website is a separate frontend on its own origin, and its delivery API calls are absorbed by Cloudflare's cache — so delivery request counts are not pageviews, and nothing here pretends otherwise. Your pages report a view directly:

<script src="https://your-worker.workers.dev/api/v1/collect/script.js" data-site="blog" defer></script>

That is the whole integration. The script sets no cookie, stores no identifier, counts no unique visitors, and honours Do Not Track and Global Privacy Control by not sending. Share clicks need one more line, because no platform publishes share counts any more and only your own click handler can see one: hedge('share', 'x').

Newsletter delivery and subscriber growth need nothing embedded — they come from rows the CMS already writes.

Full integration notes, including what the numbers deliberately do not claim and a privacy-policy paragraph you can adapt: docs/website-analytics.md.

Writing content from a script

A key with a write scope also reaches the authoring routes, so an import or another service can create content without a person's password:

curl -X POST https://your-worker.workers.dev/api/v1/collections/posts/entries \
  -H "Authorization: Bearer hdg_..." -H 'content-type: application/json' \
  -d '{"slug":"hello","status":"published","data":{"title":"Hello"}}'
ScopeReaches
content:readThe delivery API — published entries only
content:writeCreating, editing and deleting entries
media:read / media:writeListing media, and uploading or deleting it
collections:writeCreating, changing and deleting collections
members:sessionSigning a reader in — see Members and gated content

An authoring key wants content:read and content:write: the first is what lets it read back what it wrote, including drafts.

A key with no write scope is confined to the delivery API and never sees a draft — that is the credential a public website holds, and the separation is enforced by which middleware runs on which path prefix, not by each route remembering to check. No key of any kind reaches users, sites, email, or the API key routes themselves; those need a signed-in person.

members:session is the one exception worth reading twice, and it is why it is a scope of its own rather than something the admin role carries. A key that holds it can sign in as any member of its site, and therefore read anything that site gates behind membership. It grants nothing else — not the member list, not an address, not a password — but grant it only to a server you would trust with the content it unlocks, and to nothing that runs in a browser.

Managing the CMS over MCP

Hedge exposes a Model Context Protocol server so an AI assistant can run the CMS directly — not just draft a post, but define the content model it lives in, caption the media it references, write the email template that announces it, and manage who has access. It is a single Streamable-HTTP endpoint on the Worker — no separate process — speaking JSON-RPC 2.0, with 48 tools grouped into read/write scope pairs:

AreaToolsScopes
CollectionsList, read, create, update, delete the content modelcollections:read / collections:write
EntriesList, read, draft, publish, delete, plus revision history and restoreentries:read / entries:write
MediaList and read uploads; edit alt text and filenames; deletemedia:read / media:write
NewslettersEmail templates, campaign drafts, subscribers, test sendsnewsletters:read / newsletters:write
SitesList and read sites; create, configure and delete themsites:read / sites:write
UsersList, invite, change roles, remove; grant and revoke per-site accessusers:read / users:write
API keysList, issue and revoke a site's keyskeys:read / keys:write

Two things the REST API can do are deliberately left out:

  • Sending a newsletter to its audience. It reaches real inboxes and cannot be recalled, so it stays a decision a person makes in the admin. send_test_newsletter mails one address you name, which is what an assistant actually needs to check its work.
  • Uploading media. It needs a multipart body streamed into R2, and base64 through a model's context window is not a substitute. Everything about an upload is exposed; the bytes arrive through the admin or POST /api/v1/media.

One thing worth knowing before you grant it: create_api_key returns a raw secret, which lands in the assistant's context and whatever transcript its client keeps. That is a weaker place than the admin's show-once dialog. Withhold keys:write unless you want that.

Connecting a client

Authentication is OAuth 2.1 — no key to paste. Point the client at the URL and it discovers the rest: it reads the protected-resource metadata, registers itself, and opens a browser for you to sign in and approve it.

claude mcp add hedge --transport http https://your-worker.workers.dev/api/v1/mcp
{
  "mcpServers": {
    "hedge": {
      "type": "http",
      "url": "https://your-worker.workers.dev/api/v1/mcp"
    }
  }
}

What happens behind that, and where each piece lives:

StepEndpoint
Discover the resourceGET /.well-known/oauth-protected-resource
Discover the authorization serverGET /.well-known/oauth-authorization-server
Register the client (RFC 7591)POST /api/v1/auth/mcp/register
Sign in and approveGET /api/v1/auth/mcp/authorize → /oauth/consent
Exchange the code (PKCE, S256)POST /api/v1/auth/mcp/token

An unauthenticated request is answered with 401 and a WWW-Authenticate: Bearer resource_metadata="…" challenge, which is what lets a client bootstrap from the URL alone.

Every authorization request goes through the consent screen even when the client does not ask for one — a token that can rewrite your content model should never arrive from a redirect nobody read. Access tokens last an hour; ask for the offline_access scope to get a refresh token. You can see what you have approved, and revoke it, under Settings → Account.

What a client is allowed to do

A token acts as the user who approved it, so it is limited twice over: by the scopes granted at the consent screen, and by that user's own role. Neither implies the other, and the narrower one always wins.

That means the same MCP client gives different people different powers, with nothing to configure:

The user approving itWhat their client can do
Viewer on the siteRead entries, collections and media
Editor on the siteAlso draft, publish and delete entries; write newsletters and templates
Admin on the siteAlso reshape collections, and issue that site's API keys
Instance adminAlso invite users, change their roles, and create sites
OwnerEverything, including deleting a site

So granting collections:write to an editor's client does not let it rewrite schemas — the editor could not either. And granting users:write to a site admin's client does not let it manage users, because who may sign in to the deployment is not one site's business.

Roles are checked at whichever level the tool belongs to: content, media, newsletters and keys against your role on the active site; users and creating or deleting sites against your role on the deployment. Send the site with the X-Hedge-Site header when there is more than one.

A client is only shown the tools its scopes cover, so an assistant approved for reading never sees a delete tool at all. Ask for a narrow set — a documentation assistant wants the :read halves and nothing else, and then no prompt it is fed can talk it into a write.

Delivery API keys are deliberately not accepted here. The key that serves a public website sits in that website's environment variables, which is the least protected place any Hedge credential lives; it has no business rewriting content models.

Roles

Two levels, deliberately separate — a site admin runs their site, not your instance.

LevelWhere it livesGrants
Instanceusers.roleowner and admin manage users and sites, and reach every site. editor / viewer here is only the role a user is granted with by default.
Sitesite_usersadmin — the site's content, keys and members. editor — write content. viewer — read.

For anyone below instance admin the grant is their access: no row, no site — it will not even appear in their site switcher. Inviting an editor or viewer grants them the site you invited them from; anything after that is handed out under Settings → Users → Manage.

Users are invited by email and set their own password, so an invited account shows as pending until they do. The invite can be sent again from that row — and doing so spends the previous link, so an invite forwarded to the wrong inbox stops working the moment a new one goes out.

Members and gated content

Set an entry's visibility to Members only and the delivery API stops handing out its data. It still appears in list responses, marked "locked": true and stripped of content, so a site can render a teaser; the single-entry endpoint answers 403 instead.

Visitors sign in against their site and get a bearer token to replay:

# The site is picked with the X-Hedge-Site header (a site slug), or by the request's Host
# when the site has a domain set.
curl -X POST https://your-worker.workers.dev/api/v1/member/login \
  -H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
  -d '{"email":"reader@example.com","password":"…"}'
# → { "data": { "token": "…", "expiresAt": "…", "member": { … } } }

curl https://your-worker.workers.dev/api/v1/content/posts/members-only-deep-dive \
  -H "Authorization: Bearer hdg_..." -H "X-Member-Token: …"
EndpointDoes
POST /api/v1/member/registerSign up, when the site allows it
POST /api/v1/member/loginExchange credentials for a token
POST /api/v1/member/logoutRevoke the presented token
GET /api/v1/member/meThe signed-in member
POST /api/v1/member/magic-linkEmail a sign-in link instead of a password
GET /api/v1/member/magic-link/verifyRedeem that link
POST /api/v1/member/forgot-passwordEmail a reset link
POST /api/v1/member/reset-passwordSet a new password from that link's token
POST /api/v1/member/send-verification-emailRe-send the confirmation email

The token travels in X-Member-Token, not Authorization — a website sends both at once, its delivery key identifying the site and the member token identifying the reader. It is rotated as the member keeps using it, and register, login and reset are rate limited.

Registering sends a confirmation email. It is not required to sign in, but emailVerified is there for a site that wants to gate on it. Reset links point at https://<your site domain>/reset-password when the site has a domain set; pass redirectTo to choose another page on that same domain.

Adding a member from the admin emails them a link. There is no field for a password, because an account a stranger knows the password to is not the reader's account. Until they follow that link they show as invited in Members, and the invite can be sent again from the same row. Someone who already reads another site in this deployment is simply granted this one — they keep the password they have.

Responses that carry a member token are returned private, no-store, so gated content never lands in a shared cache. Members have no access to the admin API at all — they authenticate against a separate Better Auth instance over separate tables, so a member token is not merely refused there, it cannot be resolved.

Signing readers in without a second password

A site whose readers are already signed in somewhere else — the application it documents, a customer portal — should not make them hold a Hedge password as well. Two routes cover the two ways they arrive, and neither one lets anybody set or learn somebody else's password.

From the application. Its backend mints a session for a member it has already authenticated:

curl -X POST https://your-worker.workers.dev/api/v1/member-sessions \
  -H "Authorization: Bearer hdg_..." -H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
  -d '{"memberId":"mem_..."}'
# → { "data": { "token": "…", "expiresAt": "…", "member": { … } } }

The answer is the one POST /member/login gives, and the session it names is the same kind of session — /member/me, logout and the daily rotation cannot tell them apart. It needs a key with members:session, or a signed-in site admin; it refuses a blocked member and a site that is invite-only exactly as a sign-in does; and it works for a member who has never set a password, because a password is the thing this flow exists to avoid. Every mint is logged with the credential that asked for it — this is the one route in Hedge that issues a credential to someone other than its owner.

From a search result. A reader who lands on a gated page cold has no application to be handed over from, so they ask for a link instead:

curl -X POST https://your-worker.workers.dev/api/v1/member/magic-link \
  -H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
  -d '{"email":"reader@example.com","callbackURL":"https://blog.example.com/welcome"}'
# → { "data": { "ok": true } }   ← the same answer for an address that is nobody

The link works once, expires in fifteen minutes, and lands the reader back on your site with the token in the URL fragment — #hedge_member_token=…&hedge_member_expires=… — which no browser sends to a server, so it stays out of your access logs and out of Referer. Read it from location.hash, store it, and clear the hash. callbackURL must be on the site's own domain or it falls back to the site root, the same check a reset link goes through; with no domain configured there is nowhere to land, so the session comes back as JSON instead. Both routes are rate limited, the link by recipient as well as by caller.

Redeeming a link marks the address verified — clicking something in an inbox proves it more directly than the confirmation mail does. On an account that was not yet verified it also drops the password, which is Better Auth's rule and a sound one: a password set before anyone proved they own the mailbox is not evidence of anything. That member shows as invited again in the admin, signed in, and free to choose a new password.

One account, many sites. A member identity belongs to the deployment, and member_sites records which sites they may read. A reader of your blog who also reads your docs site keeps one password, and a site can block them without touching their access to the other. Signing in to a second site joins it when that site takes signups, and is refused when it is invite-only.

Scripts

CommandDoes
bun run buildBuild core, admin, and the Worker bundle
bun run deployBuild, migrate the remote D1, then deploy
bun run testRun the test suite
bun run typecheckTypecheck every workspace
bun run lint / lint:fixBiome check, optionally with fixes
bun run db:generateGenerate a migration from schema changes
bun run cf-typegenRegenerate Worker binding types after config edits

Contributing

See CONTRIBUTING.md. Issues and pull requests are welcome.

License

MIT © Bihaviour

↑↓ navigate openesc close