parsh-files
@parshjs/files gives a parsh CLI a typed ctx.context.files for persistent JSON storage. Each file is declared with a Standard Schema v1 schema (Zod, Valibot, ArkType, …); reads and writes are validated and atomic.
For the broader parsh workflow (commands, codegen, Register), see ../parsh/SKILL.md.
When to use
Persistent CLI state — config, credentials, tokens, small caches. Not for large data, binary blobs, or hot-path I/O.
Setup
Inject createFilesContext into createCli's context and register the CLI so the types propagate.
// src/main.ts
import { join } from 'node:path';
import { createCli } from '@parshjs/core';
import { createFilesContext, osHomeConfigDir } from '@parshjs/files';
import { z } from 'zod';
import { commandTree } from './commandTree.gen.ts';
const cli = createCli({
programName: 'mycli',
tree: commandTree,
context: {
files: createFilesContext({
basePath: join(osHomeConfigDir(), 'mycli'),
files: {
credentials: {
filename: 'credentials.json',
schema: z.object({ accessKey: z.string().min(1), secretKey: z.string().min(1) }),
},
prefs: {
filename: 'prefs.json',
schema: z.object({ region: z.string(), color: z.boolean() }),
defaults: { region: 'us-east-1', color: true },
},
},
}),
},
});
declare module '@parshjs/core' {
interface Register {
cli: typeof cli;
}
}
await cli.main();
osHomeConfigDir() resolves per-OS (~/.config/<x>, ~/Library/Application Support/<x>, %APPDATA%\<x>). osHomeDir() is ~.
Patterns
Defaults — drop the ?? DEFAULTS boilerplate
Declaring defaults on a spec makes read() return them when the file is missing (instead of throwing). Defaults live in memory only — nothing is written until an explicit write. Type-checked against the schema's inferred output.
Gate a subcommand on a file existing
ensureExists() in beforeHandler produces a friendly user-facing error and lets the handler use read() (not maybeRead()).
defineCommand('s3 buckets list', {
options: {},
beforeHandler: async ({ files }) => {
await files.credentials.ensureExists({ message: 'Run `mycli configure` first.' });
},
handler: async ({ files }) => {
const creds = await files.credentials.read();
/* … */
},
});
Sync access with load()
For call sites that need synchronous field access (constructing a client at startup, reads inside a TUI loop), await load() to get a stateful handle with a sync .value. load() is idempotent — call it from any number of beforeHandlers, disk is read once.
defineCommand('serve', {
options: {},
beforeHandler: async ({ files }) => {
await files.prefs.load();
},
handler: async ({ files }) => {
const prefs = await files.prefs.load();
const client = createApiClient(prefs.value.region); // sync
await prefs.set({ region: 'eu-west-2' }); // partial write, updates .value
},
});
set() and replace() keep .value in sync with disk. reload() is only for the case where something outside this handle modified the file (another process, a hand-edit). Single-process ownership is assumed.
Common mistakes
- Forgetting the
Registeraugmentation. Without it,ctx.context.fileshas no type. See the parsh skill. read()withoutdefaultsorensureExists(). It throws on missing. Either declaredefaultson the spec, gate viaensureExists()inbeforeHandler, or usemaybeRead()and handlenull.- Hand-rolling JSON next to
@parshjs/files. Use the typed handle so writes are atomic and schema-checked. - Calling
load()again to refresh. It's idempotent — returns the cached handle. Usereload()on the loaded handle to re-read from disk.
Source: ilbertt/parsh — distributed by TomeVault.