Standardize Command Back-ends
I move a database file and four commands break. No errors – they just stop working. The SQL queries inside them point at a path that no longer exists, and nothing tells me until I try to list my scheduled tasks and get nothing back.
That's the moment I stop pretending the command directory is fine.
Friday has a pile of commands at this point. One has YAML front-matter. The rest don't. Four contain hardcoded SQLite paths and raw SQL like this:
sqlite3 -header -column /home/friday/Code/friday.assertchris.dev/meta/friday.db \
"SELECT id, description, cron_expression, next_run_at, last_run_at, \
CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status \
FROM scheduled_tasks ORDER BY is_active DESC, next_run_at ASC;"
If the database moves, four files break. If a column gets renamed, four files break. You only find out when you try to use them.
The five workflow commands – new feature, plan, work, PR, merge – are the most-used in the system, but they have no shared prefix. They're scattered alphabetically across the command list. You have to know they exist to find them.
Front-matter as Contract
Claude Code commands are markdown files with optional YAML front-matter. The front-matter is how Claude knows what a command is allowed to do. Without it, a command is just a markdown blob – Claude will run it, but it has no metadata about what tools it should use, what the command is for, or whether a user should be calling it directly.
The first fix is the simplest: every command gets front-matter. Not just the one that happens to have it already – all of them.
The fields that matter are allowed-tools and description. allowed-tools can restrict which tools a command is permitted to use – a command that only needs Bash shouldn't be able to write files. description can make commands self-documenting in any listing.
Here's what the schedule list command looks like with front-matter:
---
name: custom-schedule-list
user-invocable: true
description: List all scheduled tasks, showing ID, description, schedule, next run time, last run, and status.
allowed-tools: Bash
---
One block of YAML, and the command describes itself. You can see what it does, whether it's meant to be called directly, and what tools it needs – all without reading the implementation.
The HTTP API Boundary
Front-matter is cosmetic. The real problem is the database coupling. Four commands have hardcoded paths to friday.db and raw SQL baked into their markdown.
Friday already has an HTTP API running on localhost:3100 for HUD entries and background tasks. We can extend it to cover scheduling too. Instead of raw SQL in a markdown file, a command can call curl -s http://localhost:3100/scheduled-tasks and let the API handle the database.
The routes follow the same Hono pattern as the rest of the app:
export function scheduledTaskRoutes(store: TaskStore): Hono {
const routes = new Hono();
routes.get('/scheduled-tasks', (c) => {
const all = c.req.query('all') === 'true';
const tasks = all ? store.listAll() : store.listActive();
return c.json(tasks);
});
routes.post('/scheduled-tasks', async (c) => {
const body = await c.req.json();
const task = store.create(body);
return c.json(task, 201);
});
// PATCH and DELETE follow the same pattern
return routes;
}
This is from
src/routes/scheduled-tasks.ts
The route handler doesn't care what backs the store. SQLite, Postgres, a flat file – doesn't matter. The commands don't need to know.
After this, the schedule list command goes from raw SQL to one line:
curl -s http://localhost:3100/scheduled-tasks
If the database moves or the schema changes, the command files don't break.
The Workflow Rename
The five workflow commands – new feature, plan, work, PR, merge – are the most-used in the system. But they have no shared prefix, so they scatter alphabetically across the command list.
We can fix that with a custom-workflow- prefix. custom-plan becomes custom-workflow-plan. Tab-completing custom-workflow- in Claude Code now shows all five together.
The catch is cross-references. custom-new-feature references custom-plan internally. custom-work mentions custom-merge. Renaming the files is five changes; fixing the internal references touches more.
Symlinks add another layer. ~/.claude/commands/ contains symlinks to the actual files in the project repo. Rename a file without updating the symlink and the old name still resolves – to nothing. The new name doesn't appear at all. After the rename, verifying every symlink with readlink becomes a mandatory step.
Schema Extraction
With the API handling all database access, the next question is where the schema lives. Right now, table definitions are embedded in database.ts as part of a setup function. If you want to know what scheduled_tasks looks like, you have to read through the database initialisation code and find the right CREATE TABLE call.
We can do better. Each table can get its own TypeScript file in src/db/schema/, exporting a table name and a CREATE TABLE IF NOT EXISTS statement:
export const tableName = 'scheduled_tasks';
export const createTable = `
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
prompt TEXT NOT NULL,
cron_expression TEXT NOT NULL,
next_run_at TEXT NOT NULL,
last_run_at TEXT,
fired_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
project_context TEXT
);
`.trim();
This is from
src/db/schema/scheduled-tasks.ts
An index file can collect them all:
import * as state from './state.js';
import * as scheduledTasks from './scheduled-tasks.js';
import * as backgroundTasks from './background-tasks.js';
import * as hudEntries from './hud-entries.js';
import * as pendingApprovals from './pending-approvals.js';
export const allSchemas: Schema[] = [
state,
scheduledTasks,
backgroundTasks,
hudEntries,
pendingApprovals,
];
This is from
src/db/schema/index.ts
A standalone setup script can iterate over the array and run each statement:
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
for (const schema of allSchemas) {
db.exec(schema.createTable);
console.log(`✓ ${schema.tableName}`);
}
This is from
scripts/setup-db.ts
No numbered migrations, no up/down functions, no migration runner. The CREATE TABLE IF NOT EXISTS pattern is idempotent – safe to re-run on an existing database, and it can create everything from scratch on a fresh one. Adding a new table means creating one file in src/db/schema/, registering it in the index, and running the setup script.
I also add DB_TYPE, DB_HOST, and related config parameters. Friday will almost certainly run on SQLite forever. But the schema extraction is worth doing regardless – having table definitions in dedicated files instead of a setup function is just better organisation.
Conclusion
Every command describes itself. No command touches SQLite directly. The workflow commands group together in Claude Code instead of scattering alphabetically. The schema lives in dedicated files instead of a setup function.
This was a housekeeping feature. Nothing new became possible. You don't see front-matter. You don't care whether a command calls sqlite3 or curl.
But when something breaks – and something always breaks – the fix is in one place instead of four.