@mx-space/cli is implemented on top of Effect v4 (effect, effect/cli, effect/http, @effect/platform-node). All effects are composed in Effect-TS — there is no commander, no global mutable state, no ad-hoc Promise handling.
This document covers the five patterns that recur throughout the codebase, plus a short walkthrough for adding a new command.
Every service is a Context.Service paired with a Layer that builds it from its dependencies. The Default layer is the one consumed by application code; test code substitutes alternative layers.
The most representative example is Config (src/services/Config.ts). The contract is a ConfigService interface, the tag binds the interface to an implementation, and Default declares the dependencies it needs (the platform FileSystem and Path services from effect):
// src/services/Config.ts
export interface ConfigService {
readonly resolveConfig: (
overrides?: StoreOverrides,
) => Effect.Effect<ResolvedConfig, ConfigMissingApiUrl | ProfileNotFound>
// ...
}
export class Config extends Context.Service<Config, ConfigService>()('Config') {
static Default: Layer.Layer<
Config,
never,
FileSystem.FileSystem | Path.Path
> = Layer.effect(
Config,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
return makeService(fs, path)
}),
)
}Layer composition for the whole application lives in src/layers/App.ts. Services that depend on per-invocation flags (Api, Resolver) are constructed in src/bin/mxs.ts after global flags are parsed, then merged into the application layer via Layer.provideMerge.
CLI handlers are small Effect.gen blocks that pull the services they need with yield* and call their methods. src/cli/post/list.ts is the canonical short example:
// src/cli/post/list.ts
import { postListView } from './view'
export const list = Command.make(
'list',
{ page, size, state, sort },
({ page, size, state, sort }) =>
Effect.gen(function* () {
const api = yield* Api
const renderer = yield* Renderer
const res = yield* api.request('/posts', {
query: {
page: unwrap(page),
size: unwrap(size),
state: unwrap(state),
sortBy: unwrap(sort),
},
})
yield* renderer.emit(postListView, res)
}),
).pipe(Command.withDescription('list posts'))The handler does not import the concrete implementations of Api or Renderer — it depends on the tags. Layer wiring at the program entry point resolves them. This makes every handler trivially testable: provide an in-memory layer for either service and observe behaviour.
The renderer call is intentionally generic. emit(view, data) accepts any View<T> value defined by the resource and dispatches across the structural output modes (readable / llm / xml). The view itself is a plain value imported by the command — see §6 for the contract.
Most external integrations (the file system, the editor, package-manager subprocesses, the lexical JSON-to-markdown bridge) expose Promise-based APIs. The Effect way to bring them in is Effect.tryPromise, which captures rejections into the typed error channel:
// src/services/Editor.ts
openEditor: (opts) =>
Effect.tryPromise({
try: async () => {
const editor = opts.editor ?? process.env.EDITOR ?? process.env.VISUAL
if (!editor) {
throw new Error('$EDITOR not set; ...')
}
// ... spawn editor, await exit ...
return await readFile(tmpPath, 'utf8')
},
catch: (err) =>
new Generic({
message: messageOf(err),
cause: err,
}),
})For synchronous throwing code (e.g. parsing LiteXML), the parallel constructor is Effect.try. See src/services/Lexical.ts#litexmlToPayload:
litexmlToPayload: (xml) =>
Effect.try({
try: () => deserializeFromXml(wrapped, getRegistry()) as LexicalState,
catch: (err) =>
new ValidationXml({
message: `failed to parse LiteXML: ${messageOf(err)}`,
cause: err,
}),
})Errors are tagged classes that extend Data.TaggedError — see src/domain/errors.ts for the full tree. The compiler tracks which tags can escape an effect, so failure handling is exhaustive without coupling handlers to discriminator strings.
The CLI bin wires the top-level error renderer and exit-code mapping with tapError + catch:
// src/bin/mxs.ts (top-level error path)
const core = preflight(flags).pipe(
Effect.zipRight(cli(parsed.rest)),
Effect.tapError((err) =>
isCliError(err)
? Effect.flatMap(Renderer, (r) => r.emitError(err))
: Effect.sync(() => undefined),
),
Effect.catch((err) =>
Effect.sync(() => {
const tag = isCliError(err) ? err._tag : 'Generic'
process.exit(exitCodeForTag(tag))
}),
),
)For tag-specific recovery in handlers, prefer Effect.catchTag('Foo', ...) over catch — it narrows the residual error type so unrelated failures still bubble.
src/domain/errors.ts#exitCodeForTag is the single source of truth for the documented exit-code table (1, 2, 3, 4, 5, 6, 7, plus sysexits 70/73/75 for self-update errors).
Tests use it.effect from @effect/vitest to run an effect directly and provide service layers via Effect.provide. The two custom helpers under test/helper/ cover the two most common substitutions:
test/helper/test-fs.tsexposes an in-memoryFileSystemlayer (TestFs.make) — used wherever a service writes to or reads from disk (Config,Profile,Migration).test/helper/test-http.tsexposes a canned-responseHttpClientlayer — used byApiandResolvertests to drive request/response cycles without real network I/O.
Pattern:
import { it } from '@effect/vitest'
import { Effect, Layer } from 'effect'
import { TestFs } from '../helper/test-fs'
import { Config } from '../../src/services/Config'
const testLayer = Config.Default.pipe(Layer.provide(TestFs.make({ /* seeded files */ })))
it.effect('resolves an active profile', () =>
Effect.gen(function* () {
const config = yield* Config
const resolved = yield* config.resolveConfig()
expect(resolved.profileName).toBe('prod')
}).pipe(Effect.provide(testLayer)),
)Integration tests under test/integration/ spawn the actual binary via child_process.spawn(BIN, ...) and assert on stdout/stderr/exit code — these cover the cli surface end-to-end. See test/integration/cli-error-envelope.test.ts for the canonical example.
Output rendering is split across two boundaries: a domain-agnostic dispatcher in src/services/Renderer/, and per-resource view modules in src/cli/<kind>/view.ts that own all schema knowledge.
The view contract (src/services/Renderer/view.ts):
export interface ViewCtx {
readonly color: boolean
readonly verbose: boolean
}
export interface View<T> {
readonly kind: string // used only in error messages
readonly modes: ReadonlySet<OutputMode> // which --output values are valid
readonly readable: (data: T, ctx: ViewCtx) => string
readonly llm?: (data: T) => string // missing → readable with color=false
readonly xml?: (data: T) => string // missing → "unsupported mode" error
}Dispatch rules implemented in src/services/Renderer/service.ts#emit:
--json/--output json/--output pretty-jsonbypass the view entirely and emit{ ok: true, data }envelopes (or pretty-printed raw payloads). The view is never called for JSON.--output <mode>whereview.modesdoes not include the mode emits theunsupported --output value for <kind>: <mode>error to stderr.--output llmwith noview.llmfalls back toview.readable(data, { color: false, verbose }). Helpers insrc/cli/render/honour thecolorflag, so passingfalsestrips ANSI.--output xmlwith noview.xmlis a hard error (xml is a machine format; silent fallback would corrupt downstream parsers).
Layout:
src/services/Renderer/
index.ts — Context.Service, Layer, public surface re-exports
view.ts — View<T>, ViewCtx (leaf module; no service imports)
options.ts — OutputMode, OutputOptions, currentOutputOptions Context.Reference
service.ts — makeService(): emit, emitSuccess, emitView, emitMarkdown,
emitInfo/Warn/Error/InfoBlock
primitives.ts — writeStdout/writeStderr/color
errors.ts — emitErrorSync, error envelope formatting
content.ts — Lexical → LiteXML adapter, document field helpers
(publishState, relationLabel, formatScalar, ...)
lists.ts — renderReadableGeneric (used by emitSuccess fallback)
src/cli/render/ — shared view helpers: frontmatter, metadata-block,
envelope, markdown→ANSI renderer, codehighlight
src/cli/ui/ — bespoke TTY primitives (badges, rounded box)
src/cli/<kind>/view.ts — per-resource View<T> values
A view file composes domain-specific collectFields against shared rendering helpers. cli/post/view.ts shows the full pattern — the same field list powers readable (ANSI metadata block), llm (YAML frontmatter + body), and envelope (<mxpost> LiteXML). View files are typically under 150 lines.
When to add a view vs use a primitive:
- Add a
View<T>for any read command whose output has a stable shape acrossreadable/llm/envelope. The view lives next to the verbs incli/<kind>/view.ts. - Use
emitSuccessfor mutation responses (post create,note update, etc.) — they render as generic key/value because the user is acting on the resource, not consuming it.emitSuccesshonours--json/--output json(envelope) and--output readable(generic key/value viarenderReadableGeneric). - Use
emitView/emitMarkdownfor ad-hoc one-off blocks without a reusable schema (login device-code banner, update-available notice).
Test layering follows the same split:
- View tests under
test/cli/<kind>/view.test.tssnapshotview.readable/view.llm/view.envelopefor representative inputs — these are pure functions with no Effect machinery. test/services/Renderer.test.tscovers mode dispatch, JSON-envelope shape, and the suppression rules ofemitInfo/emitWarn/emitInfoBlockunder--quiet/--json.- Helper tests under
test/cli/render/*.test.tscover YAML quoting, alignment, XML escaping.
Each command lives in its own file under src/cli/<resource>/<verb>.ts. The aggregator file src/cli/<resource>.ts wires the verbs into a Command.withSubcommands group.
-
Create the verb. Define flags with
Flag.*/Argument.*, thenCommand.makewith a handler that yields the services it needs. Keep it small — most logic should live in services. Mutation verbs useemitSuccess; typed-read verbs useemit(view, data)(see step 2).// src/cli/post/archive.ts export const archive = Command.make( 'archive', { slugOrId: Argument.String('slugOrId') }, ({ slugOrId }) => Effect.gen(function* () { const api = yield* Api const renderer = yield* Renderer const res = yield* api.request(`/posts/${slugOrId}/archive`, { method: 'POST', }) yield* renderer.emitSuccess(res) }), ).pipe(Command.withDescription('archive a post'))
-
(For typed read verbs) Add or reuse a
View<T>. If the verb returns a document or list and you needreadable/llm/enveloperendering, define the view insrc/cli/<kind>/view.ts(one file per resource —postView,postListView,noteView, etc.). Compose shared helpers fromsrc/cli/render/and document helpers fromsrc/services/Renderer/content.ts. Handlers then callrenderer.emit(theView, data). -
Register the verb. Add it to the resource aggregator (
src/cli/post/index.ts):import { archive } from './archive' // ... export const postCmd = Command.make('post').pipe( Command.withSubcommands([list, get, create, edit, update, delete_, publish, unpublish, archive]), )
If the new verb is observable in
mxs post --help, also register aCommandHelpentry viaregisterCommandHelpinsrc/cli/help/registry.ts-callers (the side-effect import insrc/cli/help/index.tsloads each resource module once). -
(Optional) Add a service. If the verb needs a new capability, define it in
src/services/<Service>.tsusing theContext.Service + Layerpattern from §1, add it tosrc/layers/App.tsso the application layer can build it, and wire any required platform services. If the service depends on per-invocation global flags (--api-url,--token,--profile, ...), construct it insrc/bin/mxs.tsafterparseGlobalFlagsinstead —ApiandResolverare the existing examples. -
Write tests. Most verbs need a unit-level test under
test/cli/<resource>/<verb>.test.ts(usingit.effect+ canned layers) and — if the verb has user-visible output — an entry in the relevant integration test undertest/integration/. New views also get atest/cli/<kind>/view.test.tswith snapshots for each declared mode. -
Run typecheck + vitest scoped to the changed files before opening a PR. Both
pnpm typecheckandpnpm testare file-cheap.
The six patterns above (Tag/Layer, Effect.gen, tryPromise, catchTag, test layers, View) are sufficient to implement every command in v0.3. Anything that doesn't fit one of them is a sign that the abstraction is wrong; revisit the service boundary rather than reaching for unsafeRun* escape hatches.