@evcraddock/slug-cli
v0.13.1
Published
Command-line tool for Slugkit sites.
Readme
slug CLI
Foundation package for the slug command-line tool. Release instructions are documented in ../docs/cli-release.md.
Development
From the repository root:
npm run test --workspace @evcraddock/slug-cli
npm run typecheck --workspace @evcraddock/slug-cliRun locally with Node's TypeScript loader:
node --import tsx cli/src/index.ts --helpCommand help
Every command level accepts --help and -h. Top-level and parent help list available commands; nested help shows focused usage, required arguments, options, defaults and examples:
slug --help
slug post --help
slug post create --help
slug post import -h
slug media upload --help
slug site config set --help
slug config site add --help
slug login --helpExplicit help exits with status 0 without loading site configuration, authenticating, opening a browser, prompting, reading command input files, or sending requests. Site selection and required command operands are not needed for help. Help can follow operands and options, for example slug post edit hello-world --help. A short -h supplied as a value to a value-taking option remains data; use a separate help flag to request help. Normal commands still require their usual inputs and explicit --site <name> when API-backed. JSON-capable commands describe --json in their help; help itself remains human-readable.
Site initialization
Create a standalone site with:
slug init ./my-site --name my-site --site-title "My Site"Run slug init --help for template options. By default, slug init downloads the latest template zip artifact and falls back to the template artifact published with the installed CLI version if the latest artifact is missing. --template-dir remains available for local template development, --template-url supports alternate remote zip artifacts, and --template reserves the selection path for future named templates. Explicit --template-url and SLUGKIT_TEMPLATE_URL overrides do not fall back.
Login and configuration
The CLI stores named Slug API websites. There is no default or current site; API-backed commands require --site <name> so the target is explicit.
Add a site and API key directly:
slug config site add slugkit --api-base-url https://slugkit.com/api/v1 --api-key <key>
slug --site slugkit doctorOr obtain a site-issued API key from the website's credential management flow or operator, then verify and save it for a named site:
slug --site slugkit login https://slugkit.com/api/v1
slug --site slugkit doctorslug login prompts for the key and verifies it using GET /auth/check relative to the selected API base URL: for example, https://slugkit.com/api/v1/auth/check. Configured path prefixes are preserved. It works with any implementation of the Slugkit OpenAPI contract; it does not require /cli/auth, browser sessions, or CSRF tokens. Credential issuance is site-owned. The example template issues keys through its protected /settings/api-keys page; the legacy /cli/auth route is removed, without a redirect or replacement endpoint. Website credential-management pages are not required API endpoints.
CLI upgrade note: Login no longer opens a browser or guesses a website login URL. Obtain your key first and paste it when prompted. Existing sites, stored credentials, command syntax, and bearer API operations remain compatible; no website upgrade or redeployment is required. No optional browser discovery mechanism or new endpoint is introduced.
The CLI stores YAML config at ${XDG_CONFIG_HOME:-~/.config}/slug/config.yaml by default. Tests and isolated runs can set SLUG_CONFIG_HOME to override the config directory. slug config show and slug config site list report whether API keys are configured without printing key values.
Use slug --site <name> site config show to read supported runtime site configuration through the Slugkit API. Use slug --site <name> site config set <field> <value> to update supported fields such as name, url, tagline, description, homepage.intro, and homepage.body.
Use slug --site <name> doctor to check API compatibility and package metadata.
Doctor resolves api.contract.id and api.contract.version from the selected site's metadata against the immutable official reference at https://slugkit.com/api/<exact-version>/contract.json and openapi.json. It verifies the artifact's identity and SHA-256 digest before comparing declarations. These public requests never carry the site's API key or cookies and do not follow redirects. Package versions and the API route name do not substitute for an explicit contract declaration.
The official contract is the minimum supported interface. Additional endpoints, optional parameters, schema properties, responses, media types, headers, and unused schemas are permitted. Every official operation, parameter (including optional source-search q), response, and reachable schema declaration must remain present. Extra required inputs, changed types or validation constraints, missing response guarantees, and changed authentication declarations fail. Additional guaranteed response fields are allowed unless the official schema explicitly forbids extra properties. Documentation text, examples, extension annotations, and server addresses do not affect conformance.
Comparison resolves local schema references, including recursive references, rather than requiring identical component names. It conservatively preserves other structural declarations; it does not attempt general JSON Schema equivalence or infer compatibility for reordered compositions or changed constraints. Unsupported references, excessive document size or complexity, missing or conflicting identities, unknown releases, and unavailable or unverifiable references fail clearly. Legacy sites without an explicit versioned contract must update. A passing check establishes advertised contract compatibility, not that every live endpoint behaves correctly or that the CLI implements every feature of that contract. Doctor remains read-only.
Use slug --site <name> upgrade for advisory, non-destructive package update recommendations; it reports suggested dependency changes without rewriting site files.
Use slug --site <name> post list, slug --site <name> post show <slug>, slug --site <name> post create, slug --site <name> post edit, slug --site <name> post publish, slug --site <name> post unpublish, and slug --site <name> post delete to manage posts through the Slugkit posts API. For migration imports, pass --published-at <date-or-datetime> to post create or post publish to preserve an original publish date without triggering follower delivery by default.
Markdown imports
Import one Markdown file with slug --site <name> post import <file.md>. Imports require YAML frontmatter delimited by --- with slug and type; articles also require title. Supported frontmatter fields are title, slug, type, excerpt, date, publishedAt, tags, url, banner, banner_url, source, and authors. source and authors are supported only for links. date is used when publishedAt is absent. Dates must be ISO dates or datetimes. Tags support either a YAML list or a flow-style list such as [writing, notes].
---
title: Hello world
slug: hello-world
type: article
excerpt: A short introduction
date: 2020-01-02
tags:
- writing
- updates
banner: ./images/hero.png
---
The imported body.
Local Markdown body images are resolved relative to the source Markdown file, uploaded, and rewritten to the returned media URLs in the imported post. Inline and reference-style images are supported; remote URLs, existing /media/... URLs, links, and code examples are left unchanged. Each distinct local path uses a stable posts/<slug>/images/<path-hash>.<extension> key, so re-importing refreshes the same media objects rather than creating duplicates. Repeated references share one upload per import; a body reference to the local banner reuses its upload. Missing or unsupported local body files stop the import before mutations. As with local banners, absolute paths, parent-traversal segments, and backslashes are rejected, including URL-encoded forms. The source Markdown file is not modified.
An article banner starting with ./ is a local file path resolved from the Markdown file. It is uploaded through the Slugkit media API under posts/<slug>/banner.<extension> and saved as the post's explicit bannerUrl without modifying Markdown content. Re-importing that local banner uploads the current file again to refresh the deterministic media object key. A banner without ./ is an existing same-site media key, not a local file.
Migration: change existing local banner references such as banner: images/hero.png to banner: ./images/hero.png. There is no local-file fallback for missing media keys. Full URLs, absolute filesystem paths, .. path segments (including ../), empty values, backslashes, and URL-encoded or reserved path characters (:, %, ?, #) are rejected.
The command looks up the post by slug: it creates a missing post and updates an existing one, applying the supplied metadata, body, and tags. A new article without date or publishedAt is unpublished. Re-importing an existing article preserves its ID, slug, and publication timestamp when dates are omitted. Omitting banner leaves an existing banner unchanged; for a new article it means no banner. Link imports continue to map banner_url directly to bannerUrl.
Upload once and reuse an article banner
Upload a shared image with a stable key:
slug --site my-site media upload ./technology.png \
--key banners/technology.png \
--alt "Technology banner"Use that key in any number of articles:
---
title: Technology notes
slug: technology-notes
type: article
banner: banners/technology.png
---
The article body.slug --site my-site post import ./technology-notes.mdFor an API base of https://your-site.example/api/v1, the importer verifies https://your-site.example/media/banners/technology.png. It uses the API's origin and the site's /media/ route, not a separately configured external media hostname. It sends an unauthenticated HEAD request with a five-second timeout and requires HTTP 200 with an image/* content type. No image body is downloaded, no media database lookup is required, and no new upload is made. Redirects are rejected rather than followed.
A missing shared image or local file stops import before post or media mutations. Non-image responses, unsupported HEAD requests, network failures, and server errors also stop import with contextual errors; they are not silently treated as missing images or replaced with defaults.
Replacing an image at a shared key changes it for every article using that key, subject to browser or proxy caching. Deleting it affects all those articles too: when viewed in a JavaScript-enabled browser, unavailable article banners are removed without a broken-image placeholder or default banner. Articles with no banner have none. URL verification at import time is only a point-in-time availability check; it does not prevent later deletion.
Link imports may include one source and one or more authors:
---
type: link
slug: how-to-stop-being-boring
title: How to stop being boring
url: https://www.joanwestenberg.com/how-to-stop-being-boring/
banner_url: https://example.com/banner.jpg
excerpt: A short description.
tags: [adulting]
publishedAt: 2026-02-07
source:
name: Joan Westenberg
url: https://www.joanwestenberg.com
authors:
- name: Joan Westenberg
url: https://www.joanwestenberg.com/about
---
Optional commentary about the link.Link files may omit either or both relationship fields:
---
type: link
slug: discovered-link
title: Discovered link
url: https://publication.example/posts/discovered-link
---
Optional commentary.Explicit source and authors values override discovery independently. For each missing field, the importer fetches the link page without sending Slugkit API credentials. Source discovery prefers JSON-LD publisher data, then standard site-name metadata. Author discovery uses JSON-LD author data and standard author metadata. If source metadata is unavailable, the normalized destination hostname and origin become the source. Missing or malformed author metadata does not block the import; the link is imported without author contacts.
Remote discovery accepts HTML responses, uses a five-second timeout, and reads at most 1 MiB. Network errors, non-HTML responses, malformed metadata, timeouts, and oversized responses use the hostname source fallback. Duplicate discovered authors are collapsed by canonical URL or normalized name, preferring a direct author URL over fragment identifiers.
When a relationship URL is available, the importer matches existing sources and contacts by a canonical URL with fragments and trailing slashes removed. Without a URL, it matches a unique name after normalizing case and whitespace. Ambiguous matches fail instead of silently selecting a record. Missing authors are created as contacts, then associated with the resolved or newly created source and credited on the post. Existing source contacts and post credits are preserved. Re-importing the same file reuses records and relationships without duplicates.
All explicit source and author metadata is validated before API mutations. For relationship imports, missing contacts are created first, the source is created or updated second, and the post is created or updated last. The API does not provide a bulk transaction, so a later failure stops the import but records created by earlier successful requests remain; relationship mutation errors identify the failed frontmatter path.
Find posts
slug --site my-site post find "climate change"
slug --site my-site post find energy --type article --status all --tag science --json
slug --site my-site post find example.com --type link
slug --site my-site post find "follow up" --type note --status draft
slug --site my-site post show matching-slugpost find <query> [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json] searches titles, slugs, stored content, excerpts, and destination URLs using trimmed, case-insensitive literal substrings. Missing fields are safe; %, _, and [ are literal. Quote spaces and shell metacharacters. All types are searched by default, but only published posts; use --status all to include drafts. Type/status/tag filters combine with the query and preserve existing list semantics, including tag normalization and use of the first repeated --tag.
The CLI sends encoded q, reads every page until an empty page, and verifies matches locally. It fetches details when summaries omit fields needed to decide a match, including content. Older sites that ignore q remain compatible without an upgrade, but searching them may require many detail requests. Requests are read-only. Any request failure, missing detail content, or repeated/non-advancing page fails instead of printing partial results. Offset pagination is not a snapshot; avoid concurrent collection edits while searching.
Human results include ID, slug, type, title, and draft/published status. JSON aggregates original matching list summaries into { "data": [...] }, retaining their metadata/relationships and omitting misleading single-page meta. Fetched content is used for matching, not appended to summaries. No matches succeed with No posts found. or { "data": [] } (exit 0); missing/blank queries and invalid filters are usage errors, and authentication/network/API failures remain errors. Existing post commands and output are unchanged.
Updated APIs filter all fields before pagination and log effective query/filters, pagination, and returned count without credentials or post contents. See post-command details and optional existing-site upgrade instructions.
Find sources
Use slug --site <name> sources find <query> [--json] to find sources by name or URL without knowing an ID:
slug --site my-site sources find "example news"
slug --site my-site sources find example.com --json
slug --site my-site sources show 42Search uses case-insensitive literal substring matching on names and URLs, trims surrounding query whitespace, and safely handles sources without URLs. Quote queries containing spaces or shell metacharacters. Characters such as %, _, and [ are literal, not wildcards or regular expressions. All matches are returned in the site's source-list order. Human-readable output includes IDs, names, URLs, and relationship counts; JSON uses the normal { "data": [...] } source-list envelope with complete source records. No matches produce No sources found. or { "data": [] } with exit code 0. Missing or blank CLI queries are usage errors; authentication, network, and API failures remain errors.
The CLI sends GET /api/v1/sources?q=<query> and filters the response locally as well. Updated sites filter server-side; older Slugkit sites that ignore q still work correctly without an upgrade. This is read-only and does not alter sources list or sources show. See existing-site upgrade instructions to enable server-side filtering on a generated site.
Find accounts and contacts
Search account labels/URLs or contact names/URLs with the same literal, case-insensitive matching as sources find:
slug --site my-site accounts find "mastodon"
slug --site my-site accounts find example.com --owner-type contact --owner-id 12 --json
slug --site my-site contact find "jane example"
slug --site my-site contact find example.com --json
slug --site my-site accounts show 42
slug --site my-site contact show 12Queries are trimmed; missing or blank queries are usage errors. Quote spaces and shell metacharacters. %, _, and [ are literal, not wildcards or regular expressions. Missing contact URLs are safe. All matching records retain list ordering and metadata. Human-readable results include IDs, labels/names, URLs, and account owner type/ID. JSON retains the normal { "data": [...] } envelope and complete records. No matches succeed with No accounts found. / No contacts found. or { "data": [] } and exit code 0. Authentication, network, and API failures remain errors.
Account --owner-type contact|source|site and positive-integer --owner-id filters combine with the text query. Either filter may be used independently, as with accounts list; listing filters do not impose account-creation ownership rules. For example, --owner-type site --owner-id 12 is valid but finds no accounts. The contact namespace remains singular: contact find.
The commands send URL-encoded q to GET /api/v1/accounts or GET /api/v1/contacts and also filter the returned records locally. Updated sites filter server-side; older sites that ignore q remain compatible without a mandatory website upgrade. Searches are read-only and existing commands remain unchanged. Updated APIs log the effective query, supplied owner filters, and returned count, excluding credentials and record contents. See existing-site upgrade instructions for optional server-side filtering and logging.
Source imports
Import link sources and their optional contacts and accounts from YAML with slug --site <name> sources import <file.yaml>. The file has one required top-level sources array. Each source requires name and may include url, description, imageUrl, faviconUrl, contacts, and accounts. Contacts require name and may include url and accounts. Accounts require label and url and may include avatarUrl, kind, protocol, default, and sortOrder.
sources:
- name: Example Blog
url: https://example.com
description: Example writing
accounts:
- label: Feed
url: https://example.com/feed.xml
kind: feed
protocol: rss
default: true
contacts:
- name: Jane Example
url: https://example.com/jane
accounts:
- label: Mastodon
url: https://social.example/@jane
kind: social
protocol: activitypubThe CLI validates the complete file before sending requests. It creates contacts first, associates their returned IDs when creating each source, and then creates accounts with the returned contact or source owner ID. Sources may omit both contacts and accounts. An API failure stops the import; because the API does not provide a bulk transaction, records created by earlier successful requests remain.
Use slug tag list to list tags and post usage counts through the Slugkit tags API.
Example config:
sites:
slugkit:
apiBaseUrl: https://slugkit.com/api/v1
apiKey: slug_...
testapp:
apiBaseUrl: http://localhost:3000/api/v1