Skip to content
ThemeAnaxPublic

About

Provision Ghost with a theme on any SSH-reachable host. MCP server + CLI. Hands the Admin API key to themeseed.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

20 Commits

Folders and files

Repository files navigation

πŸ‘» ghostkit

Get a real Ghost site running on your server. Then get out of the way.

Installs the latest Ghost on any SSH-reachable ISPConfig host β€” database, loopback port, systemd --user unit, vhost directives β€” and claims the owner account before the site is ever public. You pick the theme and wire up email yourself, in Ghost, where those choices belong.

Ships as an MCP server and a CLI.


What it does, and what it deliberately doesn't

ghostkit does left to you
Create the database and user Write the articles β€” that's themeseed
Allocate a free loopback port
Install the latest Ghost
Claim the owner account, over loopback
Create the Admin API key for themeseed
Upload and activate your theme, plus its routes.yaml
Generate a favicon, wordmark, accent colour and navigation
Wire SendGrid for transactional mail
Run it under systemd --user, no sudo for the tenant
Emit the vhost directives

Claiming the owner is automated because it is not really a choice β€” it is a race. Ghost's setup endpoint is unauthenticated and fires exactly once, so the only safe moment to call it is while Ghost is still bound to 127.0.0.1 and nobody else can reach it.

Install into Claude Code

As a plugin β€” one command, nothing else to install:

/plugin marketplace add ThemeAnax/ghostkit
/plugin install ghostkit@ghostkit

Then restart Claude Code. The plugin ships a self-contained bundled server, so there is no npm install, no node_modules, and no registry access needed.

Manage it with /plugin β€” it lists what's installed and lets you update or remove.

Or as a plain MCP server, if you'd rather not use a plugin:

npx -y @indianic/ghostkit install --editors claude

That writes the entry into ~/.claude.json pinned to @latest. This route needs access to the npm registry the package is published to.

Installing it into your other editors

ghostkit registers itself. Ask your agent to run detect_editors, pick the ones you want, and it calls register_editors:

Editor Config it writes
Claude Code ~/.claude.json
Codex ~/.codex/config.toml (TOML section, rest of the file untouched)
Cursor ~/.cursor/mcp.json
Windsurf ~/.codeium/windsurf/mcp_config.json
Gemini CLI ~/.gemini/settings.json
Antigravity via its own agy mcp add
Zed ~/.config/zed/settings.json (context_servers)
VS Code ~/Library/.../Code/User/mcp.json

Every file is backed up first, because rewriting JSON drops comments.

Updates take care of themselves. Registration uses npx -y --package @indianic/ghostkit@latest ghostkit-mcp, which re-resolves the registry on every launch β€” so restarting your editor is the update. version shows what is running against what is published. The server never rewrites its own code mid-session; that would change tool behaviour under a running agent and break every editor at once on a bad release.

Quick start

npx @indianic/ghostkit init      # writes a blank ghostkit.config.json
$EDITOR ghostkit.config.json     # set site.domain + server.sshcon_alias
npx @indianic/ghostkit preflight # is the host actually ready?

As an MCP server:

// ~/.claude.json  β†’  mcpServers
{
  "ghostkit": {
    "command": "npx",
    "args": ["-y", "--package", "@indianic/ghostkit", "ghostkit-mcp"]
  }
}

Then:

"Use ghostkit with dir ~/sites/blog.example.com β€” resolve_server, preflight, install_ghost, publish_site."

Configuration

The whole file, when you use sshcon:

{
  "site":     { "domain": "blog.example.com", "title": "My Blog", "port": null },
  "admin":    { "name": "Jane Doe", "email": "jane@example.com", "password": "", "api_key": "" },
  "theme":    { "source": "~/themes/bastian.zip", "activate": true },
  "branding": { "description": "", "accent_color": "", "generate_assets": true, "navigation": [] },
  "mail":     { "sendgrid_api_key": "", "from": "" },
  "author":   { "name": "", "email": "" },
  "server":   { "sshcon_alias": "blog-example" },
  "database": { "name": "", "user": "", "password": "" }
}

Everything except site.domain, admin.name and admin.email is optional:

  • theme.source β€” a local zip path or an http(s) URL. Blank keeps Ghost's default theme.
  • branding.accent_color β€” blank derives a stable colour from the title.
  • author β€” byline for generated content; themeseed asks per blog without it.
  • content.source β€” a Ghost export to import: a .json, or a .zip holding that json alongside content/images/…. A bare .json brings no images with it.
  • admin.api_key and admin.password are written by ghostkit, not filled in by you.

Leave admin.password blank and ghostkit generates a 20-character one, writes it back to this file, and returns it in the tool result. Give it one instead if you'd rather; Ghost requires at least 10 characters. site.title falls back to the domain.

The file holds the database password and the admin password, so it is written 0600 β€” and a config left 0644 by an older version is corrected on the next write.

resolve_server runs sshcon list <alias> all and fills in the rest β€” the root exec alias, host, SSH port, the database the panel provisioned, and the allocated application port. Without sshcon, fill server.host, server.user, server.ssh_key_path and the whole database block by hand; nothing can discover them for you.

Note the server block has one field. Nothing is pre-answered: a value in this file is a decision you made, never one ghostkit guessed for you.

Slash commands

Installed as a Claude Code plugin, ghostkit ships two runbooks so a whole site is one line instead of a pasted brief. Each derives what it can from the theme name, asks once for the rest, then runs the steps β€” stopping at the places that have bitten before: the missing config fields, the preflight fixes, the loopback port, and the vhost check.

Command Content comes from
/ghostkit:new-site <theme> [server] the theme's own demo export (.json or .zip)
/ghostkit:new-site-seeded <theme> [server] themeseed-generated articles

Both refuse a loopback port outside 51000–51999 and stop on an IP-bound vhost, because each of those has taken a whole host down once already.

Tools

Tool Does
init_config Write a blank config; list what still needs filling
resolve_server Fill server + database from an sshcon alias
config_status Report remaining blank fields
preflight Host readiness matrix + fix commands
install_ghost Install the latest Ghost, then claim the owner over loopback
create_admin Claim the owner on its own β€” retry, or an older install
create_admin_key Admin API key as {id}:{secret}, saved to admin.api_key
install_theme Upload + activate a theme, and its routes.yaml
apply_branding Favicon, wordmark, accent colour, navigation
configure_mail SendGrid for transactional mail
publish_site Return the vhost directives; you apply them
status Is it up, and has anyone claimed it yet?
next_steps The hand-off instructions
detect_editors / register_editors Install ghostkit into your editors
version Running vs published

Why the order matters

POST /ghost/api/admin/authentication/setup/ is unauthenticated and works exactly once. Whoever calls it first owns the publication β€” no login required, no way to undo it.

That makes the obvious install order dangerous: point DNS and a reverse proxy at a fresh Ghost, then set up the owner, and there is a window β€” minutes, or days β€” in which any stranger who finds the URL can claim your client's blog.

ghostkit closes it by construction:

Step Reachable from Why
install_ghost 127.0.0.1 only nothing public exists yet
↳ owner claimed 127.0.0.1 only claimed where nobody else can reach it
publish_site the internet the site is already owned

status will tell you the truth at any point:

owner_claimed   true

If it ever says false on a published site, run create_admin immediately β€” though at that point you are in a race you may already have lost.

Requirements

preflight checks all of this and prints fix commands:

Requirement Notes
The website + an active shell user in ISPConfig Chroot = None
Node ^22.23.1 Ghost 6's own requirement
ghost-cli npm i -g ghost-cli
MySQL 8 or MariaDB see below
systemd (loginctl) for --user units + linger

Node and ghost-cli are checked as the tenant, not as root. Root's PATH says nothing about the shell user's, and that mismatch is a genuinely confusing way for an install to fail: preflight goes green, then ghost install cannot find ghost.

Jailkit is a hard blocker

A jailkit chroot has no XDG_RUNTIME_DIR and no dbus socket, so systemctl --user cannot work inside one β€” the owner could never restart their own site. Set Chroot = None.

MariaDB

Ghost's official position is that MySQL 8 is the only supported database. In practice Ghost 6.62 installs and runs on MariaDB 10.11; there is no version check in ghost-cli or Ghost that blocks it. ghostkit installs the latest Ghost either way and tells you which engine it found.

Gotchas worth knowing

The vhost needs X-Forwarded-Proto, or nobody can sign in

Ghost issues its admin session cookie with Secure, and express-session will not issue a Secure cookie on a connection it believes is plain http. mod_proxy does not set X-Forwarded-Proto itself. Without that header POST /ghost/api/admin/session/ returns 201 with no Set-Cookie and the admin UI simply spins β€” a success status and a completely broken login.

publish_site writes this into the vhost itself, configtests, and reloads β€” it does not hand you a block to apply elsewhere, because the obvious route for that (sshmanager_set_domain_port) regenerates its own proxy block and drops the header.

Never pass ip_address when provisioning the domain

The one trap ghostkit cannot protect you from, because it happens before ghostkit is involved β€” in sshmanager_provision_domain, or in the ISPConfig panel.

An explicit IP makes ISPConfig emit <VirtualHost 192.0.2.1:443>. Every other site on a shared host uses <VirtualHost *:443>, and an IP-literal vhost outranks every wildcard one on the same address β€” so the new site silently becomes the default for every hostname on that IP. Visitors to unrelated domains get your site, and HTTPS throws certificate-mismatch errors because your cert does not cover their hostnames.

The symptom looks nothing like the cause: other people's sites break, yours looks fine.

Worse, the value persists in ISPConfig's web_domain row. Fixing the vhost file by hand works until the next time ISPConfig regenerates it β€” which any directive update does β€” and then the binding comes straight back. The real fix is the panel value: set the site's IPv4 address to *, as every other site on the host has it.

Check after provisioning. Empty output is correct:

sshcon exec <host> -- 'apache2ctl -S | grep -E "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:"'

publish_site rewrites an IP-bound vhost to a wildcard and reports rebound_to_wildcard, but that is a safety net for the file only: between provisioning and publishing the hijack is live, and the panel value still needs correcting.

Ghost emails a sign-in code, and that locks you out

Ghost 5.9x+ defaults security.staffDeviceVerification to true and emails a code on every staff sign-in. On a site with no mail configured the session endpoint fails outright β€” you cannot log in to the site you just created.

ghostkit sets it to false at install. Once SendGrid works, set it back to true. next_steps spells this out.

Never proxy /.well-known/

Proxy it and Let's Encrypt renewals fail silently about 60 days later. The emitted directives exclude it, and the installer moves .well-known aside and restores it around the install, because ghost install refuses any non-empty directory.

--process local is mandatory

--no-setup-systemd only skips creating the unit; it leaves "process": "systemd" in the config. Every later ghost command then shells out to sudo systemctl …, which hangs forever on a password prompt the tenant cannot answer.

ghost ls will say "stopped" β€” ignore it

Ghost-CLI's local process manager cannot see the systemd --user unit. Trust systemctl --user is-active ghost.

The web folder gets cleared

Ghost installs into the domain's own web folder, and ghost install requires it empty. ISPConfig defaults (standard_index.html, favicon.ico, robots.txt) and a previous Ghost are removed without ceremony; .well-known and error/ are preserved. Anything else stops the install until you pass wipe: true.

Development

npm install
npm run build

Probe the MCP server directly:

printf '%s\n%s\n%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 | node dist/mcp/index.js

Related

License

MIT Β© ThemeAnax

About

Provision Ghost with a theme on any SSH-reachable host. MCP server + CLI. Hands the Admin API key to themeseed.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages