What this is

chell projects ChRIS onto a POSIX shell: files and feeds are paths, plugins are executables, jobs live under /proc, PACS queries under /net/pacs, and the verbs are the ones every operator already knows. That projection is itself a development surface. A program can speak it two ways: as .chell scripts run by the shell (chell -e -f script.chell, the analogue of a bash script), and as command lines handed to the engine from TypeScript, where each line returns typed envelopes instead of printing to a terminal.

Why this is

Sometimes the shell’s vocabulary is the right abstraction even when no human is typing: automation that mirrors what an operator would do stays reviewable by that operator, and one line like pacs pull AccessionNumber:12345678 carries orchestration that would take a page of client calls. The intent API (see the intent guide) is the right tool when your program thinks in typed operations; this vocabulary is the right tool when it thinks in operator actions.

How this relates to the other APIs

Every chell command is a brasa builtin, and the builtins call the same salsa and cumin functions the intent guide documents; nothing here is a second implementation. Driving lines through the engine is the session API (see the session guide) with the vocabulary as payload, and everything shown here works identically through a remote RemoteEngine attached to a daemon.

The command surface

The vocabulary groups by domain; help inside chell is the authoritative, always-current list, and help <command> documents each one.

Domain Commands

Filesystem

ls, cd, pwd, cat, touch, mkdir, cp, mv, rm, tree, du, upload, download, edit, pull

PACS

pacs query, pacs pull, pacs status, cubepath

DICOM

dcm series (a folder as a series), dcm tags (every tag of a file; constant versus varying across a folder)

Resources

feed, plugin, plugininstance, pipeline, workflow, pluginmeta, tag, group, user, compute, files

Jobs

the /proc tree (ls /proc/jobs, cat …​/status, …​/log, …​/params), proc refresh

Session

connect, logout, whoami, context, version, date, cal, fortune

Shell features

pipes (cat f.txt | wc -l), redirection (>, >>), semicolon batches, ! host-shell escape, wildcards

The examples are programs, and the programs are tests

Both examples below run in continuous integration against a live CUBE; the .chell script has guarded the nightly since the exemplar harness landed, and the TypeScript program joins the same run.

A .chell script

The shell analogue of a bash script, played as a manifest (docs/manifest.adoc): chell -f stops at the first refused line and exits non-zero, so a clean exit is the assertion. This is exemplar 10_fs_roundtrip.chell, included whole:

# Exemplar 10 — filesystem round-trip, chell style.
#
# Create → write → read → remove, entirely inside the CUBE filesystem.
# Run with -e (stop on error): the script aborts on the first failed
# command and exits non-zero, so a clean exit IS the assertion. The CUBE
# ends the run exactly as it began.
#
# Connect once first (see README "known quirks"), then:
#   chell -e -f exemplars/chell/10_fs_roundtrip.chell

# Start from the home directory (cd with no argument goes home).
cd

# Self-heal: a previous failed run may have left the scratch directory
# behind, and recreating over its remains upsets the CUBE. POSIX rm -f
# semantics make this a no-op on a clean start.
rm -rf e2e-chell-scratch

# Make a scratch directory and enter it.
mkdir e2e-chell-scratch
cd e2e-chell-scratch

# Write a file with known content; `cat` echoes it straight back from
# CUBE storage — the round-trip proof.
touch --withContents "hello from the chell exemplar" hello.txt
ls -l
cat hello.txt

# Remove the file, then the directory, and show the home listing again —
# eyeball that e2e-chell-scratch is gone.
rm hello.txt
cd ..
rm -r e2e-chell-scratch
ls

Rooting an analysis in place

Feed creation with a chained analysis is the one workflow a static script would seem unable to express, because ids normally flow between steps (feed to root instance to analysis instance). The projection dissolves that: registered plugins are executables, and running one inside a data directory creates the feed, roots it on the directory’s contents, and chains the analysis, no ids in sight. This is exemplar 31_feed_inplace.chell, a manual exemplar (it creates a feed the CUBE keeps, so it is run and cleaned up deliberately rather than by the nightly; its self-cleaning TS twin is exemplar 03_feedDcm2niix):

# Exemplar 31 — root an analysis in place, chell style.
#
# Registered plugins are executables: /bin lists them, and running one
# inside a data directory creates the feed (with a pl-dircopy root),
# attaches the plugin, and schedules it — no ids change hands, which is
# what makes this expressible as a static script at all. Point it at a
# directory of DICOMs (for example a pulled series) for a real
# conversion; the flow is identical for any plugin and any data.
#
# Manual exemplar: it creates a feed the CUBE keeps, so run it, inspect
# the result, and clean up when done. The residue-free, self-cleaning
# form of this workflow (with polling and verification) is the TS
# exemplar 03_feedDcm2niix.
#
# Connect once first, then:
#   chell -e -f exemplars/chell/31_feed_inplace.chell

# Which dcm2niix builds does this CUBE offer? Every registered plugin
# also appears as an executable under /bin.
plugin search dcm2niix

# Enter the directory whose contents should root the feed.
cd ~/data/SAG-anon

# Run the plugin as a command. chell creates the feed from the current
# directory and chains the analysis onto its root; the context after
# `--` sets feed-level fields.
pl-dcm2niix-v1.0.2 -- feed_title="SAG anon conversion"

# The feed exists immediately; the job appears under /proc as it runs.
feed list --limit 1
ls /proc/jobs

The in-place verb was verified against the public CUBE while writing this page: one command in a directory of files produced the feed, the pl-dircopy root, and the scheduled pl-dcm2niix instance, with the output path announced in the rendered result.

One-shot lines work the same way without a file: chell -c 'ls /home' executes and exits with the command’s status, which makes chell itself usable from cron jobs and CI steps.

The typed API

The vocabulary is also callable as functions: chellApi_create() returns an object whose methods are the commands themselves, entered one layer above the parser. Typed options in, typed envelopes out; each method runs the same implementation the shell’s parsed command runs, so the two entry points cannot drift, and the envelope’s model slot is typed per command (fs.mkdir carries its outcomes, fs.cwd the working directory), read as data without a cast. The complete program, run in continuous integration like everything else on this page:

/**
 * @file Standalone example — the typed chell API.
 *
 * The complete program: connect with cumin, then drive the shell's
 * vocabulary as function calls. No command line is assembled and no parser
 * runs; each method enters the same implementation the shell's parsed
 * command enters, and returns the command's envelope with its model slot
 * typed, so results are read as data without a cast.
 *
 * Run with the CUBE settings in the environment:
 *
 *   CUBE_URL=https://cube.chrisproject.org/api/v1/ \
 *   CUBE_USER=alice CUBE_PASSWORD=secret \
 *   node exemplars/ts/dist/standalone/chell_typed.js
 *
 * @module
 */
import { chrisConnection_init, NodeStorageProvider, type ChRISConnection } from '@fnndsc/cumin';
import { chellApi_create, type ChellApi, type TypedEnvelope } from '@fnndsc/brasa';

/**
 * Round-trips a file through the CUBE filesystem in typed calls.
 */
async function main(): Promise<void> {
  const url: string | undefined = process.env.CUBE_URL;
  const user: string | undefined = process.env.CUBE_USER;
  const password: string | undefined = process.env.CUBE_PASSWORD;
  if (!url || !user || !password) {
    console.error('Set CUBE_URL, CUBE_USER and CUBE_PASSWORD.');
    process.exit(2);
  }

  const connection: ChRISConnection = await chrisConnection_init(new NodeStorageProvider());
  if (!(await connection.connection_connect({ url, user, password, debug: false }))) {
    console.error('Authentication failed.');
    process.exit(1);
  }

  const sh: ChellApi = await chellApi_create();
  const scratch: string = `typed-${Date.now().toString(36)}`;

  await sh.cd(`/home/${user}`);
  const where: TypedEnvelope<'fs.cwd'> = await sh.pwd();
  if (where.status !== 'ok' || !where.model) {
    console.error('pwd failed');
    process.exit(1);
  }
  console.log(`working in ${where.model.data.path}`);

  const made: TypedEnvelope<'fs.mkdir'> = await sh.mkdir(scratch);
  if (made.status !== 'ok') {
    console.error(made.renderedErr ?? 'mkdir failed');
    process.exit(1);
  }

  const wrote: TypedEnvelope<'fs.touch'> = await sh.touch(`${scratch}/hello.txt`, {
    contents: 'hello from the typed API',
  });
  // The model is data, not text: outcomes are read field by field.
  const created: boolean = wrote.status === 'ok'
    && (wrote.model?.data ?? []).every((outcome) => outcome.created);
  if (!created) {
    console.error(wrote.renderedErr ?? 'touch failed');
    process.exit(1);
  }
  console.log(`created ${wrote.model?.data[0]?.path}`);

  // Listings and content are data, not scraped text.
  const listing: TypedEnvelope<'fs.listing'> = await sh.ls(scratch, { long: true });
  const names: string[] = (listing.model?.data[0]?.items ?? []).map((item) => item.name);
  console.log(`listed: ${names.join(', ')}`);

  const content: TypedEnvelope<'fs.cat'> = await sh.cat(`${scratch}/hello.txt`);
  if (!content.rendered.includes('hello from the typed API')) {
    console.error('cat content mismatch');
    process.exit(1);
  }
  console.log('content verified');

  // Copy and rename, still as data.
  await sh.mkdir(`${scratch}/sub`);
  const copied: TypedEnvelope<'fs.cp'> = await sh.cp(`${scratch}/hello.txt`, `${scratch}/sub`);
  if (copied.status !== 'ok' || copied.model?.data.copied !== 1) {
    console.error('cp failed');
    process.exit(1);
  }
  const renamed: TypedEnvelope<'fs.mv'> = await sh.mv(`${scratch}/sub/hello.txt`, `${scratch}/sub/renamed.txt`);
  if (renamed.status !== 'ok') {
    console.error(renamed.renderedErr ?? 'mv failed');
    process.exit(1);
  }
  console.log('copied and renamed');

  const gone: TypedEnvelope<'fs.rm'> = await sh.rm(scratch, { recursive: true });
  if (gone.status !== 'ok') {
    console.error(gone.renderedErr ?? 'rm failed');
    process.exit(1);
  }
  console.log(`removed ${scratch}`);
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});

The typed surface currently covers pwd, cd, mkdir, touch and rm; the vocabulary joins it command by command (see docs/typed-chell-api.adoc in the repository for the governing design), and until a command joins, the line form below drives it.

The typed envelope, in detail

Every command in mise, however invoked, concludes in exactly one command envelope: the single currency in which results travel from execution to whatever hosts it, a terminal, a daemon, a pipe, or your program. Understanding its four fields is understanding the whole result model.

Field Type Meaning

status

'ok' | 'error'

The command’s terminal verdict. This is the error channel: a failed command is an error-status envelope, never a thrown exception, so control flow is a field check rather than a try/catch.

rendered

string

The printable output, exactly what a terminal would show (ANSI permitted; the typed API’s cat disables highlighting so this channel is clean data). Piping, redirection and capture all consume this field.

renderedErr

string?

Printable error-stream text, when the command produced any. The place to look, and to show your users, when status is 'error'.

model

{ kind, data }?

The same result as data: a discriminated slot whose kind names the payload shape and whose data carries it. This is what a graphical panel, a bot, or your program reads instead of parsing rendered.

From untyped slot to TypedEnvelope<K>

On the wire and in the shell the model slot is deliberately loose: kind is a string and data is unknown, so the envelope’s shape never changes as commands evolve. The typed API adds the compile-time layer: a kind map (FsModelMap) declares, for every kind a command emits, the exact payload type its implementation builds, and TypedEnvelope<K> is nothing more than a CommandEnvelope whose model slot is narrowed to one entry of that map. Structurally the two are identical; the narrowing exists only in the type system, which is why the wire, the REPL and the typed API can share one envelope without conversion.

Each facade method declares its kind in its return type, so the payload arrives typed with no cast:

const where: TypedEnvelope<'fs.cwd'> = await sh.pwd();
if (where.status === 'ok' && where.model) {
  where.model.kind;        // 'fs.cwd' — the literal, not string
  where.model.data.path;   // string — the compiler knows the shape
}

The one place trust enters is a single audited helper (envelope_typed) inside the facade: each command core is the sole producer of its kind, so the envelope it returns carries that kind’s payload by construction. Nothing downstream ever asserts.

The kinds and their payloads

The filesystem vocabulary currently maps these kinds; the map grows as commands join the typed surface, and the generated brasa reference documents each payload interface in full.

Kind Payload Carried by

fs.cwd

{ path, shown? }

pwd, cd: the working directory, and its title-substituted display form when requested.

fs.listing

LsListing[]

ls: one entry per listed target, each holding the resolved path and its ListingItem[] (name, type, size, owner, date).

fs.mkdir

MkdirOutcome[]

mkdir: per-directory { path, created }.

fs.touch

TouchOutcome[]

touch: per-file { path, created }.

fs.cat

CatOutcome[]

cat: per-file { path, ok, binary, bytes? }; the content itself is the rendered channel.

fs.rm

RmOutcome[]

rm: per-target { path, removed, skipped }.

fs.cp

CpModelData

cp: the destination plus per-source outcomes and copied/failed counts.

fs.mv

MvModelData

mv: the destination plus per-source outcomes and moved/failed counts.

Consumption patterns

Three idioms cover practical use. Gate on status and presence, then read data:

const listing = await sh.ls('data');
if (listing.status !== 'ok' || !listing.model) {
  console.error(listing.renderedErr ?? 'ls failed');
  return;
}
const names: string[] = listing.model.data[0].items.map((item) => item.name);

Treat per-target commands as batch reports rather than booleans, because one failed target yields an error-status envelope whose model still reports every outcome:

const removed = await sh.rm(['a.txt', 'b.txt']);
const failures = (removed.model?.data ?? []).filter((outcome) => !outcome.removed);

And keep the channels in their lanes: model.data is for logic, rendered is for display (or for content, in cat’s case), and `renderedErr is for humans when things fail. A program that parses rendered where a model exists is reintroducing the screen-scraping this envelope exists to end.

The vocabulary as lines from TypeScript

Anything the shell can run, including commands not yet on the typed surface, can be driven as lines through the engine, each returning envelopes. The complete program:

/**
 * @file Standalone example — program with the shell's vocabulary.
 *
 * The complete program: connect with cumin, create the real chell engine,
 * and then work entirely in shell metaphors. No salsa calls appear here;
 * the API is `ls`, `mkdir`, `touch`, `cat`, `rm`, exactly the lines a chell
 * user would type, each returning typed envelopes instead of printing to a
 * terminal.
 *
 * Run with the CUBE settings in the environment:
 *
 *   CUBE_URL=https://cube.chrisproject.org/api/v1/ \
 *   CUBE_USER=alice CUBE_PASSWORD=secret \
 *   node exemplars/ts/dist/standalone/chell_vocabulary.js
 *
 * @module
 */
import { chrisConnection_init, NodeStorageProvider, type ChRISConnection, type CommandEnvelope } from '@fnndsc/cumin';
import { engine_create, type BrasaEngine } from '@fnndsc/brasa';

/**
 * Executes one shell line and returns its first envelope, exiting on error.
 *
 * @param engine - The chell engine.
 * @param line - The shell line to run, exactly as a user would type it.
 * @returns The command's envelope.
 */
async function line_run(engine: BrasaEngine, line: string): Promise<CommandEnvelope> {
  const envelopes: CommandEnvelope[] = await engine.line_execute(line);
  const first: CommandEnvelope | undefined = envelopes[0];
  if (!first || first.status !== 'ok') {
    console.error(`failed: ${line}\n${first?.renderedErr ?? ''}`);
    process.exit(1);
  }
  return first;
}

/**
 * Round-trips a file through the CUBE filesystem in shell vocabulary.
 */
async function main(): Promise<void> {
  const url: string | undefined = process.env.CUBE_URL;
  const user: string | undefined = process.env.CUBE_USER;
  const password: string | undefined = process.env.CUBE_PASSWORD;
  if (!url || !user || !password) {
    console.error('Set CUBE_URL, CUBE_USER and CUBE_PASSWORD.');
    process.exit(2);
  }

  const connection: ChRISConnection = await chrisConnection_init(new NodeStorageProvider());
  if (!(await connection.connection_connect({ url, user, password, debug: false }))) {
    console.error('Authentication failed.');
    process.exit(1);
  }

  // The same engine chell's REPL drives; here your program is the surface.
  const engine: BrasaEngine = await engine_create();

  const scratch: string = `vocab-${Date.now().toString(36)}`;
  const content: string = 'hello from the shell vocabulary';

  // Work exactly as a shell user would: navigate, then use relative paths.
  await line_run(engine, `cd /home/${user}`);
  await line_run(engine, `mkdir ${scratch}`);
  await line_run(engine, `cd ${scratch}`);
  await line_run(engine, `touch --withContents "${content}" hello.txt`);

  // `cat` returns the file's bytes in the envelope's rendered channel.
  const catted: CommandEnvelope = await line_run(engine, 'cat hello.txt');
  if (!(catted.rendered ?? '').includes(content)) {
    console.error('cat did not return the written content');
    process.exit(1);
  }
  console.log('round trip verified, in shell vocabulary');

  await line_run(engine, 'cd ..');
  await line_run(engine, `rm -r ${scratch}`);
  console.log(`removed ${scratch}`);
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});

The richer vocabulary (pacs pull, feed, the /proc tree) works through exactly the same line_execute call, one line in, envelopes out.

Through a door, from a script

Where a host runs a porter (the mise display manager), a script reaches the session through it with a door token instead of a password: once, on a terminal, chell auth login --door <url> (a browser code by default); after that chell -c "…" goes through that door with no flags. The lines the pangea nightly runs, from scripts/smoke-nightly.sh:

      # The operator mints a token for the identity by hand; the plaintext is shown once and carried on stdin.
      "$PORTER_BIN" --mint "$E2E_USER" --name "nightly@$(hostname -s)" | sed -n 2p \
        | node packages/chell/dist/index.js auth login --door "http://127.0.0.1:${PORTER_PORT:-4180}" --with-token
      # From here no flags are needed: this door is the default for this config.
      node packages/chell/dist/index.js auth status
      node packages/chell/dist/index.js -c pwd
      node packages/chell/dist/index.js -e -c "cd /nowhere; pwd" || true   # -e stops at the first error
      node packages/chell/dist/index.js auth logout

The rules the door file follows (0600 in your home, refused when loose, dies on its day, never over plain http to another host) are in the chell package’s docs/login.adoc, "Through a door".

Where to go next

The session guide shows how these same lines cross a WebSocket to a daemon-hosted engine, which is how a web surface or bot would speak the vocabulary remotely. The builtin implementations live in the brasa reference; each one renders through the envelope model the session guide describes.