diff --git a/packages/cli/bin/journal.js b/packages/cli/bin/journal.js
new file mode 100644
index 0000000..94db200
--- /dev/null
+++ b/packages/cli/bin/journal.js
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+import { main } from '../src/cli.js';
+
+main(process.argv.slice(2)).catch((err) => {
+ console.error(`journal: ${err.message}`);
+ process.exit(1);
+});
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 0000000..2fdfa9f
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@solo/cli",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "The `journal` command: rolls dice, consults the oracle, and validates campaigns.",
+ "bin": {
+ "journal": "bin/journal.js"
+ },
+ "dependencies": {
+ "@solo/oracle": "workspace:*"
+ }
+}
diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js
new file mode 100644
index 0000000..32395cc
--- /dev/null
+++ b/packages/cli/src/cli.js
@@ -0,0 +1,583 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ rollDie,
+ rollExpr,
+ oracle as rollOracle,
+ parseTable,
+ tableRange,
+ rollTable,
+} from '@solo/oracle';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+const REQUIRED_DIRS = [
+ 'world/locations',
+ 'world/npcs',
+ 'world/factions',
+ 'characters',
+ 'state',
+ 'sessions',
+];
+
+const HELP = `journal — solo journaling tooling
+
+Usage:
+ journal roll
[--campaign ] [--game ] [--tag ] [--count ]
+ journal oracle [] [--complicate] [--campaign ] [--game ] [--table ]
+ journal dice
+ journal tables [--campaign ] [--game ]
+ journal check [--campaign ] [--game ]
+ journal new-campaign [--game ]
+ journal games
+ journal game []
+ journal [...] provided by the campaign's game package
+
+Context:
+ Tables are looked up in the campaign first, then the campaign's game.
+ Pass --campaign , set JOURNAL_CAMPAIGN, or have exactly one campaign.
+ Pass --game to work with a game without a campaign.
+
+Examples:
+ journal roll sparks --campaign ashfall
+ journal oracle "Is the gate locked?" --complicate --campaign ashfall
+ journal background --campaign ashfall
+ journal dice 2d6+1
+`;
+
+/**
+ * @param {string[]} argv
+ * @returns {{ positional: string[], flags: Record }}
+ */
+function parseArgs(argv) {
+ const positional = [];
+ const flags = {};
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg.startsWith('--')) {
+ const [key, inline] = arg.slice(2).split('=');
+ if (inline !== undefined) {
+ flags[key] = inline;
+ } else if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
+ flags[key] = argv[++i];
+ } else {
+ flags[key] = true;
+ }
+ } else {
+ positional.push(arg);
+ }
+ }
+
+ return { positional, flags };
+}
+
+/** Walk upward until the workspace root is found. */
+function findRoot(start) {
+ let dir = start;
+ for (;;) {
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ throw new Error('Could not locate project root (pnpm-workspace.yaml)');
+ }
+ dir = parent;
+ }
+}
+
+function subdirectories(dir) {
+ if (!fs.existsSync(dir)) return [];
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ .sort();
+}
+
+/**
+ * Read the scalar keys from a markdown file's YAML frontmatter. Only the flat
+ * `key: value` shape is supported, which is all the campaign header needs.
+ */
+function readFrontmatter(file) {
+ if (!fs.existsSync(file)) return {};
+ const text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
+ if (!text.startsWith('---')) return {};
+
+ const end = text.indexOf('\n---', 3);
+ if (end === -1) return {};
+
+ const out = {};
+ for (const line of text.slice(3, end).split('\n')) {
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
+ if (match) out[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
+ }
+ return out;
+}
+
+/**
+ * Resolve the campaign from flags/env, without touching the games directory.
+ *
+ * @returns {{ root: string, campaignsDir: string, gamesDir: string,
+ * campaignDir: string | null, campaign: object | null }}
+ */
+function resolveCampaign(flags) {
+ const root = findRoot(HERE);
+ const campaignsDir = path.join(root, 'campaigns');
+ const gamesDir = path.join(root, 'games');
+
+ const requested =
+ typeof flags.campaign === 'string' ? flags.campaign : process.env.JOURNAL_CAMPAIGN;
+ const real = subdirectories(campaignsDir).filter((n) => !n.startsWith('_'));
+
+ let campaignDir = null;
+ if (requested) {
+ campaignDir = path.join(campaignsDir, requested);
+ if (!fs.existsSync(campaignDir)) {
+ throw new Error(
+ `No such campaign: ${requested}\nAvailable: ${real.join(', ') || '(none)'}`,
+ );
+ }
+ } else if (real.length === 1) {
+ campaignDir = path.join(campaignsDir, real[0]);
+ } else if (real.length > 1 && typeof flags.game !== 'string') {
+ throw new Error(
+ `Multiple campaigns found; pass --campaign .\nAvailable: ${real.join(', ')}`,
+ );
+ }
+
+ const campaign = campaignDir
+ ? { name: path.basename(campaignDir), ...readFrontmatter(path.join(campaignDir, 'campaign.md')) }
+ : null;
+
+ return { root, campaignsDir, gamesDir, campaignDir, campaign };
+}
+
+/**
+ * Load a game package's manifest.
+ *
+ * @param {string} gamesDir
+ * @param {string} id
+ */
+async function loadGame(gamesDir, id) {
+ const dir = path.join(gamesDir, id);
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
+ const available = subdirectories(gamesDir);
+ throw new Error(`No such game: ${id}\nAvailable: ${available.join(', ') || '(none)'}`);
+ }
+
+ let manifest = {};
+ const entry = path.join(dir, 'index.js');
+ if (fs.existsSync(entry)) {
+ const mod = await import(pathToFileURL(entry).href);
+ manifest = mod.default ?? mod;
+ }
+
+ return {
+ id,
+ dir,
+ title: manifest.title ?? id,
+ description: manifest.description ?? '',
+ rules: manifest.rules ?? 'game.md',
+ commands: manifest.commands ?? {},
+ };
+}
+
+/**
+ * Resolve campaign + game for a command.
+ */
+async function resolveContext(flags) {
+ const base = resolveCampaign(flags);
+ const gameId = typeof flags.game === 'string' ? flags.game : base.campaign?.game || null;
+ const game = gameId ? await loadGame(base.gamesDir, gameId) : null;
+
+ return {
+ ...base,
+ game,
+ findTable: (name) => findTable({ ...base, game }, name),
+ helpers: {
+ rollDie,
+ rollExpr,
+ oracle: rollOracle,
+ parseTable,
+ rollTable,
+ tableRange,
+ findTable: (name) => findTable({ ...base, game }, name),
+ },
+ };
+}
+
+function tableDirs(ctx) {
+ const dirs = [];
+ if (ctx.campaignDir) {
+ dirs.push({ label: `campaign/${ctx.campaign.name}`, dir: path.join(ctx.campaignDir, 'tables') });
+ }
+ if (ctx.game) {
+ dirs.push({ label: `game/${ctx.game.id}`, dir: path.join(ctx.game.dir, 'tables') });
+ }
+ return dirs;
+}
+
+/**
+ * Find a table by name, searching the campaign's tables before the game's.
+ *
+ * @returns {{ file: string, label: string, entries: Array