Ghostwriter

Your app's personal config doesn't belong next to your app's code

Shipped

Ghostwriter’s personal data, voice profile, brand guide, and LinkedIn .env, used to live relative to wherever the skill’s own files happened to be installed. That was fine when there was one install. It stopped being fine once there were two: a dev repo checkout for Claude Code, and a separately version-pinned copy in Claude Desktop’s plugin cache. Each one wanted its own voice profile, its own .env, its own brand CSS, copied and kept in sync by hand. This release moves all of it to ~/.claude/ghostwriter/{voice,assets,.env}, a location independent of both the repo and the plugin cache, with the scripts falling back to the old repo-relative path if the shared one isn’t there yet.

The specific bug was Claude-flavored. The underlying problem, “where does user-specific config live when the same app can be installed more than one way,” isn’t.

Why relative-to-the-app breaks the moment there’s a second install

Storing personal config next to the application’s own code works fine for exactly one install. Path(__file__).resolve().parent.parent / ".env" gives you a path that’s always correct, right up until there’s a second copy of that file on disk. A dev checkout and a plugin cache are two different directories with two different lifecycles: the plugin cache gets wiped and reinstalled on updates, the dev checkout doesn’t. Config resolved relative to __file__ necessarily lives in one or the other, never both, so whichever copy you didn’t edit silently falls out of sync.

This is the same distinction the twelve-factor app methodology draws when it insists that config “is everything that is likely to vary between deploys” and must be strictly separated from the code that ships: credentials, API keys, anything environment- or user-specific (12factor.net/config). Ghostwriter isn’t a web service with deploys, but the shape of the problem is identical: two install paths are effectively two deploys of the same code, and the personal data that varies between them was, before this release, sitting inside the thing that’s supposed to be identical across both.

The Linux desktop ecosystem hit this decades ago and standardized an answer: the XDG Base Directory Specification defines $XDG_CONFIG_HOME (defaulting to $HOME/.config) specifically so user-specific configuration lives in one predictable, user-owned location instead of scattered across wherever each application happens to be installed (freedesktop.org). The home directory is the one location that’s independent of how, or how many times, a given app got installed.

Build it: home first, repo copy as a fallback, never a hard cutover

The fix is a small precedence chain, applied consistently across every script that touches personal data. First, the LinkedIn credentials:

from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
# Personal credentials live in the shared home dir (the same location every
# install of the app reads), so a fresh auth run isn't tied to whichever
# copy of the app happened to run it.
HOME_ENV = Path.home() / ".claude" / "ghostwriter" / ".env"
ENV_PATH = HOME_ENV if HOME_ENV.exists() else REPO / ".env"

Then the same shape for the brand stylesheet, with one more rung since a fresh clone still needs a usable default:

ASSETS = REPO / "assets"
HOME_CSS = Path.home() / ".claude" / "ghostwriter" / "assets" / "diagram.css"
CSS = ASSETS / "diagram.css"
CSS_EXAMPLE = ASSETS / "diagram.css.example"

def brand_css_path() -> Path:
    if HOME_CSS.exists():
        return HOME_CSS
    return CSS if CSS.exists() else CSS_EXAMPLE

Every call site that used to read CSS or ENV_PATH directly now goes through this resolver instead, so the precedence lives in exactly one place per file: shared home directory first, then the repo’s own copy (for anyone mid-migration or running from source), then the shipped example as a last resort so a brand-new clone never hard-fails with no config at all.

The important design choice is what this doesn’t do: it doesn’t delete or ignore the old repo-relative files, and it doesn’t force a migration. HOME_ENV.exists() gates the whole thing, so a user who hasn’t moved anything yet keeps working exactly as before. The new location only takes over once it’s actually populated.

Use it

A user setting up ghostwriter fresh runs:

mkdir -p ~/.claude/ghostwriter
cp .env.example ~/.claude/ghostwriter/.env
# then fill in LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET

Once that file exists, it’s the one every install reads: the Claude Code dev checkout, the Desktop plugin cache, and any future reinstall of either. Nothing in the workflow changes; the auth script, the image renderer, and the carousel renderer all resolve the same path internally without the user ever picking an install to configure.

Verify it and the edge case that matters

The regression to guard against isn’t “does it read ~/.claude/ghostwriter/.env,” that’s the easy path. It’s “does someone who hasn’t migrated yet keep working.” A test exercises the resolver with the home directory location absent and confirms it falls through cleanly to the repo copy, and separately with only a personal diagram.css.example-derived brand file present at the home location, confirming that a partial migration doesn’t break rendering. The failure mode worth naming: a precedence chain with no fallback would turn “you haven’t finished migrating” into “the tool is now broken,” which is a worse experience than the duplication it’s replacing. The fallback exists specifically so migration is optional right up until the moment you want it, not a breaking change forced on the next run.

Sources

Changelog

  • feat(ghostwriter): share voice/brand/.env across Claude Code and Desktop (0522cec)
  • test(ghostwriter): cover the HOME_CSS brand-guide fallback branch (6f78acf)
  • chore(release): bump ghostwriter to 0.9.0, devlog to 0.4.2 (3b69fb2)