Source

writer is plain bash, with no build step, no npm, and no compiled binary. Here's how it's structured.

Project layout

writer-cli/
├── writer.sh            ← entry point (30 lines)
├── INSTALL.sh           ← one-command installer
├── config.example       ← annotated example config
│
├── lib/
│   ├── defaults.sh      ← global variables, colours, err/info/ok helpers
│   ├── config.sh        ← config file parsing (INI key=value)
│   ├── args.sh          ← CLI flag parsing and --help text
│   ├── setup.sh         ← --setup onboarding wizard
│   ├── validate.sh      ← slug validation and ISO 8601 date generation
│   ├── frontmatter.sh   ← YAML and TOML frontmatter builders
│   ├── deps.sh          ← dependency pre-flight checks
│   └── post.sh          ← main post-creation workflow
│
└── tests/
    ├── test_writer.sh       ← test suite runner
    └── lib/
        ├── helpers.sh          ← isolated HOME, run_writer, assert helpers
        ├── test_args.sh        ← argument parsing and slug validation
        ├── test_frontmatter.sh ← YAML/TOML content, flags, date, cursor
        ├── test_config.sh      ← config file parsing
        ├── test_deps.sh        ← dependency pre-flight
        ├── test_exit.sh        ← exit codes and bundle format
        └── test_setup.sh       ← onboarding wizard

Entry point

writer.sh itself is 30 lines. It sets set -euo pipefail, resolves its own directory with ${BASH_SOURCE[0]} (so it works from any working directory, PATH install, or symlink), sources all eight lib/ modules in order, then calls main "$@".

#!/usr/bin/env bash
set -euo pipefail

WRITER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

source "${WRITER_DIR}/lib/defaults.sh"
source "${WRITER_DIR}/lib/config.sh"
source "${WRITER_DIR}/lib/args.sh"
source "${WRITER_DIR}/lib/setup.sh"
source "${WRITER_DIR}/lib/validate.sh"
source "${WRITER_DIR}/lib/frontmatter.sh"
source "${WRITER_DIR}/lib/deps.sh"
source "${WRITER_DIR}/lib/post.sh"

main "$@"

Modules

lib/defaults.sh

Declares all global variables with their hardcoded defaults, all CLI flag variables (FLAG_DRAFT, FLAG_DRY_RUN, etc.), colour escape sequences, and the three output helpers: err(), info(), ok().

lib/config.sh

_parse_config_file() reads an INI-style file line by line, strips inline comments, trims whitespace, and assigns known keys to global variables. Unknown keys exit with code 5. load_global_config() and load_local_config() call it for the right paths.

lib/args.sh

usage() prints the help text. parse_args() processes "$@" in a while/case loop, setting flags and the SLUG global. --setup is the only flag that is valid without a slug argument.

lib/setup.sh

_prompt() is a helper that prints a labelled prompt with the current value shown in green, reads into the global REPLY_VAL, and keeps the current value if Enter is pressed. run_onboarding() walks through all ten config keys, validates BUNDLE_FORMAT and FRONTMATTER_FORMAT in a re-prompt loop, and writes the config file with a date-stamped header.

lib/validate.sh

validate_slug() matches against ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ and prints specific diagnostic messages for uppercase, invalid characters, and leading/trailing hyphens. get_iso_date() generates an ISO 8601 timestamp with timezone offset, with a fallback for BSD date (macOS) when GNU date --iso-8601=seconds is unavailable.

lib/frontmatter.sh

build_yaml_frontmatter() and build_toml_frontmatter() take title, slug, date, tags, description, and draft as arguments and return the complete frontmatter block. Tags are split on commas, each trimmed and empty entries dropped. count_frontmatter_lines() counts lines in the generated block, used to position the editor cursor on the first blank line below the closing delimiter.

lib/deps.sh

check_deps() verifies that the editor binary, the first word of BUILD_CMD, and git are all on PATH, and that the working directory is inside a git repo. Skips all checks when --dry-run is set. Collects all missing deps before exiting so every problem is reported in one pass.

lib/post.sh

Contains main(), the orchestrator. Calls parse_args, handles the --setup and first-run paths, loads config in the correct layered order, applies CLI overrides, then runs the interactive post-creation flow: overwrite check, title/tags prompts, frontmatter generation, file creation, editor launch, description prompt, frontmatter rewrite (if a description was given), and the build/git sequence.

Test suite

tests/test_writer.sh is a thin runner that sources seven focused modules from tests/lib/, covering 104 assertions across argument parsing, slug validation, YAML/TOML frontmatter, all flags, tags edge cases, date format, config file parsing, dependency pre-flight, exit codes, bundle format, cursor line calculation, and the full onboarding wizard flow, mirroring the same modular structure as the main lib/.

# Run from the repo root
bash tests/test_writer.sh

The harness creates an isolated $HOME via mktemp -d for each test run, so the real ~/.config/writer/config is never touched. All tests use --dry-run or isolated temp directories. No real editor, build, or git operations run.

Design notes

Why bash?

The target environment is a remote Linux server with a minimal install, often just bash, git, and whatever the user put there. Bash is always present; Python may not be. It felt wrong to require Node just to publish a blog post.

Bash 3.2 compatibility

macOS ships bash 3.2 (GPL2, never updated). Writer avoids all bash 4+ features: no ${var,,} lowercasing (uses tr '[:upper:]' '[:lower:]' instead), no associative arrays, no mapfile/readarray.

set -euo pipefail discipline

Every conditional that might return non-zero uses if/then, never [[ condition ]] && cmd, which silently exits under set -e when the condition is false. read calls are all followed by || true to handle EOF on non-interactive stdin gracefully.

Sourced modules, not subshells

All lib/ files are sourced into the main shell, not executed as subshells. This means they share global state (all the config variables, flags, SLUG) without needing to pass arguments or use export. Each lib file has no shebang and no set -e, it inherits the caller's environment.


Links


← Reference   GitHub ↗