Part VIII, Chapter 1

Part VIII: Loom builds Loom

Chapter 1: The Loom CLI

Package: packages/loom/src/main.ts

Language: TypeScript

Raw source

The Loom CLI

The @athrio/loom package is the command-line tool for Loom: it tangles .loom files into source, scaffolds a workspace's configuration, and activates the language services it uses. The tool is itself written in Loom.

The wordmark

loom init opens with the Loom wordmark, so the tool announces itself before it asks anything. banner prints the wordmark in teal above a dim tagline. teal and dim wrap a string in the ANSI escape codes a terminal renders as colour, then reset, so whatever prints next is plain again.

TypeScript
const reset = '\x1b[0m'
const teal = (s: string): string => `\x1b[38;5;44m${s}${reset}`
const dim = (s: string): string => `\x1b[2m${s}${reset}`

const wordmark = [
  '   ██╗      ██████╗  ██████╗ ███╗   ███╗',
  '   ██║     ██╔═══██╗██╔═══██╗████╗ ████║',
  '   ██║     ██║   ██║██║   ██║██╔████╔██║',
  '   ██║     ██║   ██║██║   ██║██║╚██╔╝██║',
  '   ███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║',
  '   ╚══════╝ ╚═════╝  ╚═════╝ ╚═╝     ╚═╝',
].join('\n')

const banner = Console.log(`\n${teal(wordmark)}\n   ${dim('a literate framework')}\n`)

Tangling a file

The tangle command emits a file's source, once or on every change. It resolves the path against the working directory and hands it to the tangler, which walks the looms there, emits each output, and records what it wrote in the lock. reportWritten logs every path it produced, or a plain note when a loom has no {path} sink to emit.

Without --watch the command runs once. tangleOnce tangles and reports, and when the tangler refuses a broken corpus — an unresolved ::[…] anchor would otherwise drop silently from the output — it prints the error and exits non-zero, writing nothing.

With --watch the command stays running. tangleWatch drains the tangler's watch stream, reports each pass the same way, and re-tangles as the looms change. A pass that fails prints its error and the watch goes on, since a syntax error mid-edit is ordinary and the next save recovers it — the exit that ends the one-shot run would end the session here.

TypeScript
const reportWritten = (written: ReadonlyArray<TangledFile>): Effect.Effect<void> =>
  written.length === 0
    ? Console.log('loom: no {path} sinks to tangle')
    : Effect.forEach(written, (f) => Console.log(`loom: tangled ${f.section} → ${f.path}`), {
        discard: true,
      })

const tangleOnce = (file: string) =>
  Effect.gen(function* () {
    const tangler = yield* LoomTangler
    const written = yield* tangler.tangle(resolvePath(process.cwd(), file))
    yield* reportWritten(written)
  }).pipe(
    Effect.catchTag('TangleError', (e) =>
      Console.error(e.message).pipe(Effect.andThen(Effect.sync(() => void (process.exitCode = 1)))),
    ),
  )

const tangleWatch = (file: string) =>
  Effect.gen(function* () {
    const tangler = yield* LoomTangler
    yield* Console.log(`loom: watching ${file} — re-tangling on change`)
    yield* Stream.runForEach(
      tangler.watch(resolvePath(process.cwd(), file)),
      Result.match({
        onSuccess: reportWritten,
        onFailure: (error: TangleError) => Console.error(error.message),
      }),
    )
  })

const tangle = (file: string, watch: boolean) => (watch ? tangleWatch(file) : tangleOnce(file))

Listing orphans

The orphans command lists the files that lost their host, one path per line and nothing else, so a shell can act on them — loom orphans | xargs rm removes every one. It takes no path: the lock covers the whole workspace, so the command does too, found from the working directory. It reads the lock, tangles every loom the project holds to see what is still produced, and prints the recorded paths the build no longer yields. With --prune it removes them itself and rewrites the lock, instead of leaving removal to a pipe. Either way a broken corpus prints its error to standard error, exits non-zero, and lists no path, so a pipe into rm deletes nothing when the build is not whole.

TypeScript
const orphans = (prune: boolean) =>
  Effect.gen(function* () {
    const tangler = yield* LoomTangler
    if (prune) {
      const removed = yield* tangler.prune(process.cwd())
      yield* removed.length === 0
        ? Console.log('loom: no orphans to prune')
        : Effect.forEach(removed, (path) => Console.log(`loom: pruned ${path}`))
    } else {
      const found = yield* tangler.orphans(process.cwd())
      yield* Effect.forEach(found, (path) => Console.log(path))
    }
  }).pipe(
    Effect.catchTag('TangleError', (e) =>
      Console.error(e.message).pipe(Effect.andThen(Effect.sync(() => void (process.exitCode = 1)))),
    ),
  )

Weaving a corpus

The weave command projects a corpus into data. It takes the corpus entry and an output path, resolves both against the working directory, hands them to the weaver, and reports the file it wrote. The weaver asks the compiler to project the whole corpus into a WovenCorpus and writes it as JSON — one file a consumer renders however it likes, static or live.

TypeScript
const weave = (file: string, out: string) =>
  Effect.gen(function* () {
    const weaver = yield* LoomWeaver
    const written = yield* weaver.weave(
      resolvePath(process.cwd(), file),
      resolvePath(process.cwd(), out),
    )
    yield* Console.log(`loom: wove ${file} → ${written}`)
  })

Writing the table of contents

The toc command regenerates the book's own contents. It takes the book's path, resolves it against the working directory, hands it to the writer, and reports the file it rewrote. The writer asks the compiler to read every chapter's frontmatter and splice a fresh table of contents into the book, so the table never drifts from the corpus it describes.

TypeScript
const toc = (file: string) =>
  Effect.gen(function* () {
    const contents = yield* LoomContents
    const written = yield* contents.write(resolvePath(process.cwd(), file))
    yield* Console.log(`loom: wrote the table of contents into ${written}`)
  })

Formatting looms

The format command formats looms the way an editor formats the one it has open: every prose paragraph on one line, and every code section laid out by its language's formatter. It takes a file or a folder, resolves it against the working directory, and lets LoomFormatter name the looms the path covers. Each loom it changes is rewritten in place and reported. With --check it rewrites nothing. It names each loom that would change, and exits non-zero when any would, so a CI step can hold a project to its format.

TypeScript
const format = (path: string, check: boolean) =>
  Effect.gen(function* () {
    const formatter = yield* LoomFormatter
    const target = resolvePath(process.cwd(), path)
    const languages = yield* formatLanguages(target)
    const looms = yield* formatter.looms(target)
    const changed = yield* Effect.filter(looms, (loom) =>
      Effect.gen(function* () {
        const text = yield* Effect.sync(() => readFileSync(loom, 'utf8'))
        const formatted = yield* formatter.format(loom, text, languages)
        if (formatted === text) return false
        if (!check) yield* Effect.sync(() => writeFileSync(loom, formatted))
        return true
      }),
    )
    yield* changed.length === 0
      ? Console.log('loom: every loom is formatted')
      : Effect.forEach(
          changed,
          (loom) => Console.log(`loom: ${check ? 'would format' : 'formatted'} ${loom}`),
          { discard: true },
        )
    if (check && changed.length > 0) yield* Effect.sync(() => void (process.exitCode = 1))
  })

The command formats code through the project's language services, the same packages the editor loads from the store. A loaded service reads Effect, the service contract and TypeScript from the host that loads it, so formatLanguages first installs that host runtime. It needs a TypeScript to lend, and it looks where the language server looks: in the project, then beside Loom, then in the workspace's store. The standalone executable has nothing beside it, so it skips the second place.

When none of the three holds a TypeScript, the command installs TypeScript 6 into the store, as the language server does, and lends that. A project in another language reaches this step on its first loom format, and every later run finds the copy in the store. When the install fails, the command says why, loads no service, and formats the prose alone, leaving every code section as written.

The loader brings the Loom language with it, and the Loom language brings Volar, which is CommonJS and calls require as it loads. So formatLanguages imports the loader and the host runtime only when it reaches them, after exposing a require the way the lsp branch does, and a plain tangle never pays to load Volar.

TypeScript
const typeScriptFrom = (from: string): Option.Option<string> =>
  Option.liftThrowable(() => createRequire(from).resolve('typescript/lib/typescript.js'))()

const foundTypeScript = (root: string): Option.Option<string> =>
  pipe(
    typeScriptFrom(resolvePath(root, 'package.json')),
    Option.orElse(() => (standalone ? Option.none() : typeScriptFrom(import.meta.url))),
    Option.orElse(() => storeTypeScript(root)),
  )

const storedTypeScript = (root: string): Effect.Effect<string, StoreError> =>
  Console.error(`loom: installing TypeScript ${typeScriptRange} into ${storeDir(root)}`).pipe(
    Effect.andThen(installTypeScript(root)),
    Effect.tapError((error) =>
      Console.error(`loom: could not install TypeScript — ${String(error.cause)}`),
    ),
  )

const projectTypeScript = (
  root: string,
): Effect.Effect<Option.Option<typeof import('typescript')>> =>
  Option.match(foundTypeScript(root), {
    onNone: () => storedTypeScript(root),
    onSome: Effect.succeed,
  }).pipe(
    Effect.flatMap((file) => Effect.tryPromise(() => import(pathToFileURL(file).href))),
    Effect.map((module) => Option.some(module.default ?? module)),
    Effect.orElseSucceed(() => Option.none()),
  )

const formatLanguages = (target: string) =>
  Effect.gen(function* () {
    const config = yield* LoomConfig
    const root = workspaceRoot(dirname(target))
    const manifest = yield* config.manifest(root)
    const ids = Object.keys(manifest?.languages ?? {})
    if (ids.length === 0) return [] as ReadonlyArray<LanguageService>
    const typescript = yield* projectTypeScript(root)
    return yield* Option.match(typescript, {
      onNone: () =>
        Console.error(
          'loom: found no TypeScript to load the language services with, so code sections stay as written',
        ).pipe(Effect.as([] as ReadonlyArray<LanguageService>)),
      onSome: (ts) =>
        Effect.gen(function* () {
          yield* Effect.sync(() => {
            ;(globalThis as { require?: unknown }).require ??= createRequire(import.meta.url)
          })
          const { installHostRuntime } = yield* Effect.promise(
            () => import('@athrio/loom-lang-services/Runtime'),
          )
          const { loadActive } = yield* Effect.promise(
            () => import('@athrio/loom-lang-services/LanguageLoader'),
          )
          yield* Effect.sync(() => installHostRuntime(ts))
          return yield* loadActive(ids, root)
        }),
    })
  })

Scaffolding a workspace

loom init sets a workspace up for review, and it is safe to run again — a second run only fills in what a first left undone, and never undoes it. The first run writes .loom/config.yaml, the compiled configuration the editor and the tangler read, and a .gitignore beside it that keeps the generated service store out of version control. It asks for the primary language a specifier-less section inherits, and for the languages the editor activates services for. A later run finds that configuration and leaves it untouched; loom add and loom remove change the activated languages afterwards.

The configuration the first run writes carries an empty ignore list. A new project needs no pattern there, because Loom already leaves out whatever Git ignores and every loom named *.fixture.loom. The empty key shows where a pattern goes when a folder of drafts or a copied example should stay out of the project.

Every run makes sure the corpus folder exists. The configuration names it, corpus unless a project chose another, and init creates it when it is missing, so a fresh project has a place for its first loom straight away. A folder that is already there is left exactly as it is, looms and all, so running init again is as safe for the corpus as it is for the configuration.

The primary language defaults to TypeScript, and the activation answer defaults to that same language, so a TypeScript workspace is two key-presses away. The activation answer is a space-separated list, since one workspace may run several languages. A prompt the author quits — with Ctrl-C — fails with QuitError, which init catches to leave with a word rather than a stack trace.

Every run also connects the workspace to a coding agent. It merges a loom entry into .mcp.json, the file an agent reads to find its tools, pointing it at the Design service's /mcp and leaving any other servers there in place. Run twice, the merge adds nothing new.

TypeScript
const mergeMcpConfig = (root: string): void => {
  const path = resolvePath(root, '.mcp.json')
  const existing = existsSync(path)
    ? (JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>)
    : {}
  const servers = (existing.mcpServers as Record<string, unknown> | undefined) ?? {}
  const merged = {
    ...existing,
    mcpServers: {
      ...servers,
      loom: { type: 'http', url: 'http://localhost:5710/mcp' },
    },
  }
  writeFileSync(path, JSON.stringify(merged, null, 2) + '\n')
}

Every run also tells the editor how wide a loom's lines run. A loom's prose keeps each paragraph on one line, which the editor wraps on screen, and its code keeps to the project's width, a hundred columns unless the configuration names another. An .editorconfig file holds settings many editors share, such as indentation, and Zed, Gram and Helix take a line's width from its max_line_length setting. So init writes a [*.loom] section into the project's .editorconfig that sets max_line_length to the project's width, and those editors wrap a loom's prose where its code ends.

The section sits in a managed block, a run of lines Loom owns inside a file the project's owner also edits. Two comment lines bound the block, so a later run finds the block and replaces it whole, and every other line of the file stays as its owner wrote it. A file without the block gets it at its end. When the owner sets the same thing outside the block, Loom leaves the file alone, since two settings for one thing would leave the editor to pick between them. withManagedBlock returns the file's text with the block in place, or nothing when the owner's own setting stands. In .editorconfig the owner's setting is a [*.loom] section of their own, and init reports that it kept that section.

TypeScript
interface ManagedBlock {
  readonly begin: string
  readonly end: string
  readonly lines: ReadonlyArray<string>
  readonly owned: RegExp
}

const withManagedBlock = (text: string, managed: ManagedBlock): Option.Option<string> => {
  const block = [managed.begin, ...managed.lines, managed.end].join('\n')
  const start = text.indexOf(managed.begin)
  const end = text.indexOf(managed.end, start)
  if (start >= 0 && end > start)
    return Option.some(text.slice(0, start) + block + text.slice(end + managed.end.length))
  if (managed.owned.test(text)) return Option.none()
  const kept = text.replace(/\s*$/, '')
  return Option.some(kept === '' ? `${block}\n` : `${kept}\n\n${block}\n`)
}

const editorconfigBlock = (width: number): ManagedBlock => ({
  begin: '# Loom: written by `loom init`, which replaces this block on every run',
  end: '# Loom: end',
  lines: ['[*.loom]', `max_line_length = ${width}`],
  owned: /^\s*\[\*\.loom\]\s*$/m,
})

init asks its questions only when no configuration exists yet. Every run then reads the configuration it found or wrote, and settles the corpus folder, the agent's .mcp.json and the editor's .editorconfig from it.

TypeScript
const init = (dir: Option.Option<string>) =>
  Effect.gen(function* () {
    const root = resolvePath(
      process.cwd(),
      Option.getOrElse(dir, () => '.'),
    )
    const config = yield* LoomConfig
    yield* banner

    const configFile = resolvePath(root, '.loom', 'config.yaml')
    const configured = yield* Effect.sync(() => existsSync(configFile))

    if (configured) {
      yield* Console.log(`\n   ${dim(`✓ using the configuration at ${configFile}`)}\n`)
    } else {
      const language = yield* Prompt.run(
        Prompt.String({
          message: 'Primary language for specifier-less sections',
          default: 'typescript',
        }),
      )
      const activation = yield* Prompt.run(
        Prompt.String({
          message: 'Activate editor services for (space-separated language ids)',
          default: language,
        }),
      )
      const ids = activation.split(/\s+/).filter((id) => id.length > 0)
      yield* config.materialize(root, {
        corpus: 'corpus',
        languages: Object.fromEntries(ids.map((id) => [id, {}])),
        primary: language,
        ignore: [],
      })
      yield* Effect.sync(() =>
        writeFileSync(resolvePath(root, '.loom', '.gitignore'), 'services/\n'),
      )
      yield* Console.log(
        `\n   ${teal('✓')} wrote ${configFile}\n` +
          `   ${dim(`primary language: ${language}`)}\n` +
          `   ${dim(`activated: ${ids.join(', ') || 'none'}`)}\n`,
      )
    }

    const manifest = yield* config.manifest(root)
    const corpusDir = resolvePath(root, manifest?.corpus ?? 'corpus')
    yield* Effect.sync(() => mkdirSync(corpusDir, { recursive: true }))
    yield* Console.log(`   ${teal('✓')} corpus at ${corpusDir}\n`)

    yield* Effect.sync(() => mergeMcpConfig(root))
    yield* Console.log(`   ${teal('✓')} wrote ${resolvePath(root, '.mcp.json')}\n`)

    const editorconfig = resolvePath(root, '.editorconfig')
    const current = yield* Effect.sync(() =>
      existsSync(editorconfig) ? readFileSync(editorconfig, 'utf8') : '',
    )
    const block = editorconfigBlock(manifest?.width ?? defaultWidth)
    yield* Option.match(withManagedBlock(current, block), {
      onNone: () => Console.log(`   ${dim(`✓ kept the [*.loom] section in ${editorconfig}`)}\n`),
      onSome: (text) =>
        Effect.sync(() => writeFileSync(editorconfig, text)).pipe(
          Effect.andThen(Console.log(`   ${teal('✓')} wrote ${editorconfig}\n`)),
        ),
    })
  }).pipe(Effect.catchTag('QuitError', () => Console.log(dim('\n   Cancelled.\n'))))

Activating a language

loom add <language> turns a language on. It installs the language's service into the store and records the language among the workspace's activated languages, because a language without its service gets no checks in the editor. loom remove <language> turns a language off, taking its service out of the store and its id out of the configuration.

The install goes through the store's installService, which brings the service to this Loom's version. The store may lack the service, hold it already, or hold it at another version that an older release installed, and loom add says which of the three it found and what it did. An install that fails leaves with the reason and a non-zero exit, the configuration untouched, because a language whose service never arrived must not read as activated.

loom add on a language that is already on changes nothing. It says so, and when that language's service is missing from the store or at another version, it names loom update, the command whose job that is. So turning a language on never doubles as quietly replacing its service.

Both commands record the change the same way. updateLanguages reads the workspace's activated languages, applies the change, and writes the new set back as the configuration's languages through its edit method. Every other line of .loom/config.yaml stays as it was written, so turning a language on or off changes only the set of languages. An activated language keeps its existing entry, with any service override in it, while a new one starts empty.

TypeScript
const updateLanguages = (
  config: LoomConfig['Service'],
  dir: string,
  change: (ids: ReadonlyArray<string>) => ReadonlyArray<string>,
): Effect.Effect<void> =>
  Effect.gen(function* () {
    const workspace = workspaceRoot(dir)
    const current = yield* config.manifest(workspace)
    const ids = change(Object.keys(current?.languages ?? {}))
    const languages = Object.fromEntries(ids.map((id) => [id, current?.languages?.[id] ?? {}]))
    yield* config.edit(workspace, ['languages'], languages)
  })

Three small pieces serve both loom add and loom update. activatedLanguages reads the ids the configuration turns on. installNote says in one line what an install did. failedInstall reports an install that failed and sets a non-zero exit. A fourth, alreadyActive, is what loom add says about a language that is on already.

TypeScript
const activatedLanguages = (
  config: LoomConfig['Service'],
  dir: string,
): Effect.Effect<ReadonlyArray<string>> =>
  config
    .manifest(workspaceRoot(dir))
    .pipe(Effect.map((manifest) => Object.keys(manifest?.languages ?? {})))

const installNote = (language: string, change: ServiceInstall): string =>
  ServiceInstall.$match(change, {
    Current: () => `${servicePackage(language)} ${serviceVersion} is already in the store`,
    Installed: () => `installed ${servicePackage(language)} ${serviceVersion}`,
    Updated: ({ from }) => `updated ${servicePackage(language)} from ${from} to ${serviceVersion}`,
  })

const failedInstall = (error: StoreError) =>
  refuse(`loom: could not install ${error.packageName} — ${String(error.cause)}`)

const alreadyActive = (language: string, dir: string) =>
  Console.log(
    `\n   ${teal('✓')} ${language} is already activated\n` +
      Option.match(installedVersion(language, dir), {
        onNone: () =>
          `   ${dim(`its service is not in the store; \`loom update ${language}\` installs it`)}\n`,
        onSome: (version) =>
          version === serviceVersion
            ? ''
            : `   ${dim(`the store holds its service at ${version}; \`loom update ${language}\` brings it to ${serviceVersion}`)}\n`,
      }),
  )

const add = (language: string) =>
  Effect.gen(function* () {
    const config = yield* LoomConfig
    const dir = process.cwd()
    const activated = yield* activatedLanguages(config, dir)
    if (activated.includes(language)) return yield* alreadyActive(language, dir)
    const change = yield* installService(language, dir)
    yield* updateLanguages(config, dir, (ids) => [...ids, language])
    yield* Console.log(
      `\n   ${teal('✓')} activated ${language}\n` +
        `   ${dim(installNote(language, change))}\n` +
        `   ${dim(`store: ${storeDir(dir)}`)}\n`,
    )
  }).pipe(Effect.catchTag('StoreError', failedInstall))

const remove = (language: string) =>
  Effect.gen(function* () {
    const config = yield* LoomConfig
    const dir = process.cwd()
    yield* removeService(language, dir)
    yield* updateLanguages(config, dir, (ids) => ids.filter((id) => id !== language))
    yield* Console.log(`\n   ${teal('✓')} removed ${language}\n`)
  })

Bringing a language up to date

loom update <language>… brings the services of the named languages to this Loom's version, and loom update --all does the same for every activated language. After an upgrade of Loom, the store still holds the services the older release installed, and this is the step that replaces them. Each language goes through installService, so a current service stays as it is, one at another version is replaced, and a missing one is installed. A fresh clone is the common case of a missing service, since Git ignores the store. The command reports what it did for each language as it goes.

The command never changes the configuration. A language it is asked to update must be on already, and one that is not is refused with the loom add that turns it on, so update never activates a language behind the reader's back. An install that fails stops the command with the reason and a non-zero exit, as it does for loom add.

loom update alone names no languages, and a reader who types it may well mean Loom itself. So it prints the usage, points to loom upgrade, the command that upgrades Loom, and exits non-zero. Naming languages together with --all prints the usage too, since the two ask for different sets of languages.

TypeScript
const updateUsage = 'usage: loom update <language>… | loom update --all'

const update = (languages: ReadonlyArray<string>, all: boolean) =>
  Effect.gen(function* () {
    if (languages.length === 0 && !all)
      return yield* refuse(
        `loom: name the languages to update, or use --all — ${updateUsage}\n` +
          '      To upgrade Loom itself, run `loom upgrade`.',
      )
    if (languages.length > 0 && all)
      return yield* refuse(`loom: name languages or use --all, not both — ${updateUsage}`)
    const config = yield* LoomConfig
    const dir = process.cwd()
    const activated = yield* activatedLanguages(config, dir)
    const inactive = languages.filter((id) => !activated.includes(id))
    if (inactive.length > 0)
      return yield* Effect.forEach(
        inactive,
        (id) => refuse(`loom: ${id} is not activated; \`loom add ${id}\` turns it on`),
        { discard: true },
      )
    const ids = all ? activated : languages
    if (ids.length === 0) return yield* Console.log(`\n   ${dim('no languages are activated')}\n`)
    yield* Console.log('')
    yield* Effect.forEach(ids, (id) =>
      installService(id, dir).pipe(
        Effect.flatMap((change) => Console.log(`   ${teal('✓')} ${dim(installNote(id, change))}`)),
      ),
    )
    yield* Console.log(`   ${dim(`store: ${storeDir(dir)}`)}\n`)
  }).pipe(Effect.catchTag('StoreError', failedInstall))

Who installed Loom

Loom reaches a machine in several ways. The install script puts the standalone executable in a folder of its own. npm, Bun and pnpm install the npm package for the whole machine, and a project may install it among its own dependencies. Homebrew may install either form. Whoever installed a copy of Loom owns its files, so the command that upgrades the copy, or removes it, depends on who that was.

Installation names the possibilities. A file that carries no installer's mark is Unowned, since nothing says who put it there. The one exception is this Loom's own standalone executable, which is an Executable. A project's installation carries the project's folder and the package manager the project uses, since a command that changes the project must run there, with that package manager.

TypeScript
type ProjectManager = 'npm' | 'bun' | 'pnpm' | 'yarn'

type Installation = Data.TaggedEnum<{
  Executable: {}
  Unowned: {}
  Homebrew: {}
  Npm: {}
  Bun: {}
  Pnpm: {}
  Project: { readonly root: string; readonly manager: ProjectManager }
}>
const Installation = Data.taggedEnum<Installation>()

Each installer leaves a mark in the path of the files it writes. Homebrew keeps every package it installs under a folder named Cellar. Bun keeps the packages it installs for the whole machine in a .bun folder, and pnpm in a pnpm folder.

npm puts both kinds of package in a node_modules folder, so that folder alone does not tell them apart. A project's node_modules sits beside the project's package.json. npm's own sits in a folder npm made for itself, lib on macOS and Linux and npm on Windows, which holds no package.json. So a path through node_modules belongs to a project when the folder above its first node_modules holds a package.json, and to npm otherwise. On Windows npm also writes a launcher straight into its npm folder, loom.cmd, which runs the package from beside it.

installationAt reads those marks in that order, and the first that matches decides. marked turns a mark into a test of a path. A path with no mark falls to the installation the caller names. projectAt finds a project's folder in a path. projectManager names the package manager a project uses from the lockfile it keeps: bun.lock or the older bun.lockb for Bun, pnpm-lock.yaml for pnpm, yarn.lock for Yarn, and npm when it keeps none of them.

TypeScript
const lockfiles: ReadonlyArray<readonly [string, ProjectManager]> = [
  ['bun.lock', 'bun'],
  ['bun.lockb', 'bun'],
  ['pnpm-lock.yaml', 'pnpm'],
  ['yarn.lock', 'yarn'],
]

const projectManager = (root: string): ProjectManager =>
  pipe(
    Array.findFirst(lockfiles, ([lockfile]) => existsSync(resolvePath(root, lockfile))),
    Option.match({ onNone: (): ProjectManager => 'npm', onSome: ([, manager]) => manager }),
  )

const projectAt = (path: string): Option.Option<string> =>
  pipe(
    Option.fromNullishOr(/^(.*?)[\\/]node_modules[\\/]/.exec(path)),
    Option.flatMap((found) => Option.fromNullishOr(found[1])),
    Option.filter((root) => existsSync(resolvePath(root, 'package.json'))),
  )

const marked = (mark: RegExp) => (path: string) => mark.test(path)

const installationAt = (path: string, otherwise: Installation): Installation =>
  Match.value(path).pipe(
    Match.when(marked(/[\\/]Cellar[\\/]/), () => Installation.Homebrew()),
    Match.when(marked(/[\\/]\.bun[\\/]/), () => Installation.Bun()),
    Match.when(marked(/[\\/]pnpm[\\/]/), () => Installation.Pnpm()),
    Match.when(marked(/[\\/]node_modules[\\/]/), (p) =>
      Option.match(projectAt(p), {
        onNone: () => Installation.Npm(),
        onSome: (root) => Installation.Project({ root, manager: projectManager(root) }),
      }),
    ),
    Match.when(marked(/[\\/]npm[\\/][^\\/]+$/), () => Installation.Npm()),
    Match.orElse(() => otherwise),
  )

This Loom reads its own installation from the file it runs from. For the standalone executable that file is the executable itself, and for the npm package it is the bundle. A link may lead to either, so realOf follows every link to the file's real path, and keeps the path as it is when the file cannot be read.

A standalone executable that carries no installer's mark came from the install script or from a person's own download, and it upgrades itself. An npm package whose path carries no mark is taken for npm's, since npm installs wherever its prefix points. The tests run the CLI from its source, which carries no mark either, so in the tests Loom takes itself for an npm install.

TypeScript
const realOf = (path: string): string =>
  Option.getOrElse(Option.liftThrowable(() => realpathSync(path))(), () => path)

const thisFile = standalone ? process.execPath : fileURLToPath(import.meta.url)

const thisInstallation: Installation = installationAt(
  realOf(thisFile),
  standalone ? Installation.Executable() : Installation.Npm(),
)

describedInstallation names an installation in a phrase a report can use, such as "installed by npm". A command that names a folder puts the folder in quotes, so a folder whose name holds a space stays one word.

TypeScript
const describedInstallation = Installation.$match({
  Executable: () => 'a standalone executable',
  Unowned: () => 'a file no installer owns',
  Homebrew: () => 'installed by Homebrew',
  Npm: () => 'installed by npm',
  Bun: () => 'installed by Bun',
  Pnpm: () => 'installed by pnpm',
  Project: ({ root, manager }) => `installed by ${manager} in the project at ${root}`,
})

const quoted = (path: string): string => `"${path}"`

Upgrading Loom itself

loom upgrade replaces the installed Loom with its latest release. Who installed this Loom decides how.

An installer that owns Loom's files upgrades them too, so the command leaves those files alone and names the installer's own command instead. installerUpgrade is that command for each installation. Homebrew upgrades its own package. npm, Bun and pnpm install the latest release for the whole machine, over the one they installed. A project's package manager installs the latest release into the project, from the project's folder. Neither the standalone executable nor a file no installer owns has an installer behind it, so neither has such a command.

{{ version = ${version} }}

TypeScript
const projectCommands: Record<ProjectManager, { readonly add: string; readonly remove: string }> = {
  npm: { add: 'npm install', remove: 'npm uninstall' },
  bun: { add: 'bun add', remove: 'bun remove' },
  pnpm: { add: 'pnpm add', remove: 'pnpm remove' },
  yarn: { add: 'yarn add', remove: 'yarn remove' },
}

const installerUpgrade = Installation.$match({
  Executable: () => Option.none<string>(),
  Unowned: () => Option.none<string>(),
  Homebrew: () => Option.some('brew upgrade loom'),
  Npm: () => Option.some('npm install -g @athrio/loom@latest'),
  Bun: () => Option.some('bun add -g @athrio/loom@latest'),
  Pnpm: () => Option.some('pnpm add -g @athrio/loom@latest'),
  Project: ({ root, manager }) =>
    Option.some(`cd ${quoted(root)} && ${projectCommands[manager].add} @athrio/loom@latest`),
})

The standalone executable replaces itself instead. Every release of Loom on GitHub carries a gzipped executable for each platform Loom builds for, named loom-<system>-<architecture>.gz. Beside them sits SHA256SUMS, a list of the SHA-256 checksum of each file. The command asks GitHub's API for the latest release and reads the part of its answer that ReleaseSchema describes: the release's tag, and the name and address of each file it carries. LOOM_RELEASES overrides the address the command asks, which is how a test serves a release of its own.

The tag is the version with a v in front. versionOrder compares two versions number by number, so 0.0.10 comes after 0.0.9. A release no newer than this Loom leaves the executable as it is, and the command says so.

TypeScript
const loomVersion = '::[version]'

const releases = process.env.LOOM_RELEASES ?? 'https://api.github.com/repos/athrio-com/loom/releases'

const ReleaseSchema = Schema.Struct({
  tag_name: Schema.String,
  assets: Schema.Array(Schema.Struct({ name: Schema.String, browser_download_url: Schema.String })),
})

type Release = typeof ReleaseSchema.Type

const versionOrder: Order.Order<string> = Order.mapInput(
  Order.Array(Order.Number),
  (version: string) => version.split('.').map(Number),
)

Every request the command makes can fail. The network may be down, GitHub may answer with an error, or the answer may not be a release. Each of these fails with ReleaseUnreachable, which names the address that failed and why. fetched makes a request and fails unless the answer is a success. latestRelease decodes the latest release, and bytesAt reads a whole file.

TypeScript
class ReleaseUnreachable extends Data.TaggedError('ReleaseUnreachable')<{
  readonly url: string
  readonly cause: unknown
}> {}

const fetched = (url: string): Effect.Effect<Response, ReleaseUnreachable> =>
  Effect.tryPromise({
    try: () => fetch(url, { headers: { 'user-agent': 'loom' } }),
    catch: (cause) => new ReleaseUnreachable({ url, cause }),
  }).pipe(
    Effect.filterOrFail(
      (response) => response.ok,
      (response) =>
        new ReleaseUnreachable({ url, cause: `${response.status} ${response.statusText}` }),
    ),
  )

const latestRelease: Effect.Effect<Release, ReleaseUnreachable> = Effect.gen(function* () {
  const url = `${releases}/latest`
  const response = yield* fetched(url)
  const body = yield* Effect.tryPromise({
    try: () => response.json(),
    catch: (cause) => new ReleaseUnreachable({ url, cause }),
  })
  return yield* Schema.decodeUnknownEffect(ReleaseSchema)(body).pipe(
    Effect.mapError((cause) => new ReleaseUnreachable({ url, cause })),
  )
})

const bytesAt = (url: string): Effect.Effect<Uint8Array, ReleaseUnreachable> =>
  fetched(url).pipe(
    Effect.flatMap((response) =>
      Effect.tryPromise({
        try: async () => new Uint8Array(await response.arrayBuffer()),
        catch: (cause) => new ReleaseUnreachable({ url, cause }),
      }),
    ),
  )

executableName is the name of the executable this platform needs, with .exe on Windows, and assetName is that name gzipped, the file the release carries. A release that lacks it, or lacks the list of checksums, fails with ReleaseLacksAsset.

The command checks the download before it writes a byte. It computes the file's SHA-256 checksum and looks it up in the list, where listedChecksum finds the line that names the file. A file whose checksum differs from the one listed fails with ChecksumMismatch, and the executable stays as it was. The list comes from the same release as the file, so the check catches a download that was cut short or corrupted on the way, but it cannot prove who built the file. That proof needs a signature, which Loom's releases do not carry yet.

TypeScript
const executableName =
  process.platform === 'win32'
    ? `loom-windows-${process.arch}.exe`
    : `loom-${process.platform}-${process.arch}`

const assetName = `${executableName}.gz`

class ReleaseLacksAsset extends Data.TaggedError('ReleaseLacksAsset')<{
  readonly version: string
  readonly asset: string
}> {}

class ChecksumMismatch extends Data.TaggedError('ChecksumMismatch')<{
  readonly asset: string
}> {}

const assetUrl = (release: Release, asset: string): Effect.Effect<string, ReleaseLacksAsset> =>
  Option.match(
    Array.findFirst(release.assets, (candidate) => candidate.name === asset),
    {
      onNone: () => Effect.fail(new ReleaseLacksAsset({ version: release.tag_name, asset })),
      onSome: (found) => Effect.succeed(found.browser_download_url),
    },
  )

const listedChecksum = (sums: string, asset: string): Option.Option<string> =>
  pipe(
    sums.split('\n'),
    Array.map((line) => line.trim().split(/\s+/)),
    Array.findFirst((fields) => fields[1] === asset || fields[1] === `*${asset}`),
    Option.flatMap((fields) => Option.fromNullishOr(fields[0])),
  )

const verified = (
  bytes: Uint8Array,
  sums: string,
  asset: string,
): Effect.Effect<Uint8Array, ChecksumMismatch> =>
  Option.contains(listedChecksum(sums, asset), createHash('sha256').update(bytes).digest('hex'))
    ? Effect.succeed(bytes)
    : Effect.fail(new ChecksumMismatch({ asset }))

Once the checksum matches, the command unpacks the executable from its gzip. A file that matches its checksum yet will not unpack means the release itself is broken, and it fails with UnpackFailed.

TypeScript
class UnpackFailed extends Data.TaggedError('UnpackFailed')<{
  readonly asset: string
  readonly cause: unknown
}> {}

const unpacked = (archive: Uint8Array): Effect.Effect<Uint8Array, UnpackFailed> =>
  Effect.try({
    try: () => new Uint8Array(gunzipSync(archive)),
    catch: (cause) => new UnpackFailed({ asset: assetName, cause }),
  })

The new executable takes the old one's place in a single rename. The command writes the download beside the running executable under a temporary name, so the rename never crosses from one file system to another, and then renames it over the old one. Anything already running the old executable keeps running it, since the rename gives the name to a new file and leaves the old file whole until its last user exits.

Windows refuses to replace an executable that is running, but lets one be renamed. So on Windows the old executable first moves aside to a name ending in .old, and the next upgrade clears it away. A folder Loom may not write to, such as one owned by the system, fails with ReplaceFailed, and the temporary file is removed.

TypeScript
class ReplaceFailed extends Data.TaggedError('ReplaceFailed')<{
  readonly path: string
  readonly cause: unknown
}> {}

const moveAside = (target: string): void => {
  if (process.platform !== 'win32') return
  rmSync(`${target}.old`, { force: true })
  renameSync(target, `${target}.old`)
}

const replaceExecutable = (bytes: Uint8Array): Effect.Effect<void, ReplaceFailed> => {
  const target = process.execPath
  const partial = `${target}.${process.pid}`
  return Effect.try({
    try: () => {
      writeFileSync(partial, bytes, { mode: 0o755 })
      moveAside(target)
      renameSync(partial, target)
    },
    catch: (cause) => new ReplaceFailed({ path: target, cause }),
  }).pipe(Effect.tapError(() => Effect.sync(() => rmSync(partial, { force: true }))))
}

A new Loom expects every service at its own version, and the store still holds the services the old one installed. So once the executable is replaced, the command names loom update --all, which brings a workspace's services in step. Each failure prints what went wrong and exits non-zero.

TypeScript
const upgradeStandalone = Effect.gen(function* () {
  const release = yield* latestRelease
  const latest = release.tag_name.replace(/^v/, '')
  if (!Order.isGreaterThan(versionOrder)(latest, loomVersion))
    return yield* Console.log(
      `\n   ${teal('✓')} Loom ${loomVersion} is up to date; the latest release is ${latest}\n`,
    )
  const archive = yield* assetUrl(release, assetName).pipe(Effect.flatMap(bytesAt))
  const sums = yield* assetUrl(release, 'SHA256SUMS').pipe(
    Effect.flatMap(bytesAt),
    Effect.map((bytes) => new TextDecoder().decode(bytes)),
  )
  yield* verified(archive, sums, assetName).pipe(
    Effect.flatMap(unpacked),
    Effect.flatMap(replaceExecutable),
  )
  yield* Console.log(
    `\n   ${teal('✓')} upgraded Loom from ${loomVersion} to ${latest}\n` +
      `   ${dim('run `loom update --all` in each workspace to bring its services along')}\n`,
  )
})

upgrade lets the installation choose. When installerUpgrade names a command, the command prints it, says who installed Loom, and names loom update --all as well, since an upgrade through an installer leaves the store just as behind. When it names none, the standalone executable replaces itself.

TypeScript
const upgrade = Effect.gen(function* () {
  const command = installerUpgrade(thisInstallation)
  if (Option.isNone(command)) return yield* upgradeStandalone
  yield* Console.log(
    `\n   Loom was ${describedInstallation(thisInstallation)}. Upgrade it with:\n\n` +
      `   ${command.value}\n\n` +
      `   ${dim('then run `loom update --all` in each workspace to bring its services along')}\n`,
  )
}).pipe(
  Effect.catchTags({
    ReleaseUnreachable: (error) =>
      refuse(`loom: could not reach the latest release at ${error.url} — ${String(error.cause)}`),
    ReleaseLacksAsset: (error) =>
      refuse(`loom: release ${error.version} carries no ${error.asset}`),
    ChecksumMismatch: (error) =>
      refuse(
        `loom: the checksum of the downloaded ${error.asset} does not match the release's list, so Loom was left as it was`,
      ),
    UnpackFailed: (error) =>
      refuse(`loom: could not unpack ${error.asset} — ${String(error.cause)}`),
    ReplaceFailed: (error) =>
      refuse(
        `loom: could not replace ${error.path} — ${String(error.cause)}\n` +
          '      Run the install script again, or move Loom somewhere you can write to.',
      ),
  }),
)

Reporting the workspace

loom status shows the state of the workspace at a glance: where the store sits, the primary language a specifier-less section inherits, and the activated languages. Beside each activated language it marks the state of its service. A filled dot means the store holds the service at this Loom's version. A half-filled one means the store holds it at another version, which the mark names, with the loom update that brings it to this Loom's. A hollow one means the store lacks it, with the loom update that installs it.

TypeScript
const status = Effect.gen(function* () {
  const config = yield* LoomConfig
  const dir = process.cwd()
  const manifest = yield* config.manifest(workspaceRoot(dir))
  const primary = manifest?.primary
  const languages = Object.keys(manifest?.languages ?? {})
  const mark = (id: string): string =>
    Option.match(installedVersion(id, dir), {
      onNone: () => `   ${dim('○')} ${id}${dim(`  — run \`loom update ${id}\``)}`,
      onSome: (version) =>
        version === serviceVersion
          ? `   ${teal('●')} ${id}`
          : `   ${dim('◐')} ${id}${dim(`  — ${version} is installed; run \`loom update ${id}\` for ${serviceVersion}`)}`,
    })
  yield* Console.log(
    `\n   ${dim('store')}      ${storeDir(dir)}\n` +
      `   ${dim('primary')}    ${primary ?? dim('(none)')}\n` +
      `   ${dim('activated')}  ${languages.length === 0 ? dim('(none)') : languages.join(', ')}\n`,
  )
  if (languages.length > 0) yield* Console.log(`${languages.map(mark).join('\n')}\n`)
})

A check-up for Loom

loom doctor looks for what goes wrong around Loom rather than inside it, and pairs each problem it finds with the command that fixes it. It asks four questions. Which loom does a terminal run, and who installed it? Does any other loom wait on PATH? Does the store hold each activated language's service at this Loom's version? Can the language server find a TypeScript? The first two concern the machine, so the doctor always asks them. The last two concern a workspace, so the doctor asks them only inside one.

Each answer is a Finding. A fine finding notes that something is in order. A problem carries its fix, the command a person runs to put it right, and the type admits no problem without one.

TypeScript
type Finding = Data.TaggedEnum<{
  Fine: { readonly note: string }
  Problem: { readonly note: string; readonly fix: string }
}>
const Finding = Data.taggedEnum<Finding>()

A terminal looks for a command in the folders PATH lists, in order, and runs the first file of that name it meets. programsOnPath makes the same search and keeps every match, not only the first. A match is a file the person may run, by its plain name on macOS and Linux, and on Windows by the name with .exe, or with .cmd for the launchers npm writes. Two folders may lead to one file through a link, so the search keeps each real file once. loomsOnPath is that search for loom.

TypeScript
const commandNames = (command: string): ReadonlyArray<string> =>
  process.platform === 'win32' ? [`${command}.exe`, `${command}.cmd`] : [command]

const isProgram = (file: string): boolean =>
  pipe(
    Option.liftThrowable(() => statSync(file))(),
    Option.exists(
      (stats) => stats.isFile() && (process.platform === 'win32' || (stats.mode & 0o111) !== 0),
    ),
  )

const programsOnPath = (command: string): ReadonlyArray<string> =>
  pipe(
    (process.env.PATH ?? '').split(delimiter),
    Array.filter((folder) => folder !== ''),
    Array.flatMap((folder) =>
      Array.map(commandNames(command), (name) => resolvePath(folder, name)),
    ),
    Array.filter(isProgram),
    Array.dedupeWith((one, other) => realOf(one) === realOf(other)),
  )

const loomsOnPath = (): ReadonlyArray<string> => programsOnPath('loom')

installationOf reads the installation of a loom on PATH, where a file with no installer's mark is Unowned. It may be an older standalone executable, or a script a person wrote, and the path cannot tell which.

A loom on PATH is this Loom when its real path is the file this Loom runs from. pnpm, and npm on Windows, put a launcher on PATH rather than a link, and a launcher's real path is its own. A machine holds at most one install by each of those installers, so a loom with this Loom's installation counts as this Loom too. A file on PATH with no installer's mark reads as Unowned, never as this Loom's Executable, so for the standalone executable only the file decides.

TypeScript
const installationOf = (file: string): Installation =>
  installationAt(realOf(file), Installation.Unowned())

const isThisLoom = (file: string): boolean =>
  realOf(file) === realOf(thisFile) || Equal.equals(installationOf(file), thisInstallation)

The doctor's first finding names this Loom, its version, its file, and who installed it. The findings after it read PATH.

When the first loom on PATH is this one, all is well. When it is another, a terminal runs that one instead, and its fix removes it. Every later loom that is not this one is a problem too. It is a stale copy, and a change to PATH could put it first again. Its fix removes it the same way. installerRemoval names the command that removes a copy, and it runs the installer's own uninstall, so the installer forgets the copy as well. A file no installer owns has no uninstall, so its removal deletes the file.

A PATH with no loom at all leaves a terminal with nothing to run. The install script fixes that, since it puts Loom in a folder of its own and adds the folder to PATH.

TypeScript
const installLine =
  'curl -fsSL https://github.com/athrio-com/loom/releases/latest/download/install.sh | sh'

const installerRemoval = (installation: Installation, file: string): string =>
  Installation.$match(installation, {
    Executable: () => `rm ${quoted(file)}`,
    Unowned: () => `rm ${quoted(file)}`,
    Homebrew: () => 'brew uninstall loom',
    Npm: () => 'npm uninstall -g @athrio/loom',
    Bun: () => 'bun remove -g @athrio/loom',
    Pnpm: () => 'pnpm remove -g @athrio/loom',
    Project: ({ root, manager }) =>
      `cd ${quoted(root)} && ${projectCommands[manager].remove} @athrio/loom`,
  })

const otherLoom = (file: string, note: string): Finding =>
  Finding.Problem({
    note: `${note}, ${describedInstallation(installationOf(file))}`,
    fix: installerRemoval(installationOf(file), file),
  })

const thisLoomFinding = Finding.Fine({
  note: `loom v${loomVersion} runs from ${thisFile}, ${describedInstallation(thisInstallation)}`,
})

const pathFindings = (looms: ReadonlyArray<string>): ReadonlyArray<Finding> =>
  Array.match(looms, {
    onEmpty: () => [
      Finding.Problem({
        note: 'no loom is on PATH, so a terminal finds none to run',
        fix: installLine,
      }),
    ],
    onNonEmpty: ([first, ...rest]) => [
      isThisLoom(first)
        ? Finding.Fine({ note: `a terminal runs this loom, at ${first}` })
        : otherLoom(first, `a terminal runs ${first} instead`),
      ...pipe(
        rest,
        Array.filter((file) => !isThisLoom(file)),
        Array.map((file) => otherLoom(file, `${file} is on PATH too`)),
      ),
    ],
  })

Inside a workspace the doctor checks each activated language's service, as loom status marks it. A service at this Loom's version is fine. A missing service, or one at another version, is a problem, and loom update with the language's id fixes it. A workspace that activates no language needs no service at all.

TypeScript
const serviceFinding = (id: string, root: string): Finding =>
  Option.match(installedVersion(id, root), {
    onNone: () =>
      Finding.Problem({ note: `the store holds no service for ${id}`, fix: `loom update ${id}` }),
    onSome: (version) =>
      version === serviceVersion
        ? Finding.Fine({ note: `the store holds ${id}'s service at this Loom's ${serviceVersion}` })
        : Finding.Problem({
            note: `the store holds ${id}'s service at ${version}, and this Loom needs ${serviceVersion}`,
            fix: `loom update ${id}`,
          }),
  })

const serviceFindings = (languages: ReadonlyArray<string>, root: string): ReadonlyArray<Finding> =>
  Array.match(languages, {
    onEmpty: () => [
      Finding.Fine({ note: 'no language is activated, so the store needs no service' }),
    ],
    onNonEmpty: (ids) => Array.map(ids, (id) => serviceFinding(id, root)),
  })

The last question is the TypeScript the language server checks code against. VS Code sends the server one of its own, but every other editor leaves the server to search, and the doctor searches where the server does. It looks in the project, then beside Loom, then in the store, the search foundTypeScript already makes for loom format.

When the search finds nothing, the server puts TypeScript 6 into the store the first time it starts. The standalone executable unpacks the copy it carries, which always works. The npm package installs TypeScript with npm, which fails when npm is not on PATH, as on a Linux system whose Node came without it. That failure is the one problem here. The install script fixes it, because the standalone executable needs no npm.

TypeScript
const missingTypeScript = (root: string): Finding =>
  Match.value({ standalone, npm: programsOnPath('npm').length > 0 }).pipe(
    Match.when({ standalone: true }, () =>
      Finding.Fine({
        note: `the language server unpacks the TypeScript Loom carries into ${storeDir(root)} when it first starts`,
      }),
    ),
    Match.when({ npm: true }, () =>
      Finding.Fine({
        note: `the language server installs TypeScript ${typeScriptRange} into ${storeDir(root)} with npm when it first starts`,
      }),
    ),
    Match.orElse(() =>
      Finding.Problem({
        note: 'the language server finds no TypeScript, and has no npm to install one with',
        fix: installLine,
      }),
    ),
  )

const typeScriptFinding = (root: string): Finding =>
  Option.match(foundTypeScript(root), {
    onNone: () => missingTypeScript(root),
    onSome: (file) =>
      Finding.Fine({ note: `the language server finds TypeScript in ${dirname(dirname(file))}` }),
  })

const workspaceFindings = (
  root: string,
): Effect.Effect<ReadonlyArray<Finding>, never, LoomConfig> =>
  Effect.gen(function* () {
    const config = yield* LoomConfig
    const manifest = yield* config.manifest(root)
    return [
      ...serviceFindings(Object.keys(manifest?.languages ?? {}), root),
      typeScriptFinding(root),
    ]
  })

The doctor prints its findings in two groups, the machine's and then the workspace's. Each finding is one line with a mark, teal for fine and amber for a problem. A problem's fix sits on the line below it, ready to copy. Outside a workspace the second group is one line that says so. The report ends by counting the problems, and the command exits non-zero when it found any, so a script can run it as a check.

TypeScript
const amber = (s: string): string => `\x1b[38;5;214m${s}${reset}`

const findingLines = (findings: ReadonlyArray<Finding>): string =>
  Array.map(
    findings,
    Finding.$match({
      Fine: ({ note }) => `   ${teal('✓')} ${note}`,
      Problem: ({ note, fix }) => `   ${amber('✗')} ${note}\n       ${fix}`,
    }),
  ).join('\n')

const doctor = Effect.gen(function* () {
  const dir = process.cwd()
  const machine = [thisLoomFinding, ...pathFindings(loomsOnPath())]
  const workspace = workspaceOf(dir)
  const inWorkspace = yield* Option.match(workspace, {
    onNone: () => Effect.succeed([] as ReadonlyArray<Finding>),
    onSome: workspaceFindings,
  })
  const problems = Array.filter([...machine, ...inWorkspace], Finding.$is('Problem')).length
  yield* Console.log(
    `\n   ${dim('this machine')}\n${findingLines(machine)}\n\n` +
      Option.match(workspace, {
        onNone: () => `   ${dim(`no workspace holds ${dir}, so no services to check`)}\n`,
        onSome: (root) => `   ${dim(`the workspace at ${root}`)}\n${findingLines(inWorkspace)}\n`,
      }),
  )
  if (problems === 0) return yield* Console.log(`   ${teal('✓')} no problems found\n`)
  yield* Console.log(`   ${problems === 1 ? '1 problem' : `${problems} problems`} found\n`)
  yield* Effect.sync(() => void (process.exitCode = 1))
})

Setting a workspace variable

loom set <name> <value> writes a workspace variable and re-tangles the corpus. Every variable lives under the configuration's variables, so the name alone identifies it and the command needs no variables. prefix. A variable feeds the value warps that interpolate it, so writing one without re-tangling would leave the composed output — a version in every manifest, say — behind the configuration. This command changes the one value and reconciles the whole corpus to it in a single step.

The name and the value are two arguments with a space between them — loom set version 0.0.4, no =. A name that is not a plain word of letters, digits, and underscores, or a value written with a leading =, is rejected with the usage rather than bound, so a stray = never becomes the value. Given good input, the command changes the one variable in .loom/config.yaml through the configuration's edit method, which leaves every other line of the file as it was written. Then it tangles the corpus directory the configuration names.

TypeScript
const set = (name: string, value: string) =>
  Effect.gen(function* () {
    const config = yield* LoomConfig
    const tangler = yield* LoomTangler
    const workspace = workspaceRoot(process.cwd())
    const usage = 'usage: loom set <name> <value>'
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
      yield* Console.error(`loom: \`${name}\` is not a variable name — ${usage}`)
      yield* Effect.sync(() => void (process.exitCode = 1))
      return
    }
    if (value.startsWith('=')) {
      yield* Console.error(
        `loom: a value cannot begin with \`=\`; give the name and value with a space, no \`=\` — ${usage}`,
      )
      yield* Effect.sync(() => void (process.exitCode = 1))
      return
    }
    const current = yield* config.manifest(workspace)
    yield* config.edit(workspace, ['variables', name], value)
    const corpus = resolvePath(workspace, current?.corpus ?? 'corpus')
    const written = yield* tangler.tangle(corpus)
    yield* Console.log(`loom: set ${name} = ${value} (re-tangled ${written.length} files)`)
  }).pipe(
    Effect.catchTag('TangleError', (e) =>
      Console.error(e.message).pipe(Effect.andThen(Effect.sync(() => void (process.exitCode = 1)))),
    ),
  )

Standing Design in front of an application

loom design <name> <address> runs Loom Design over an application already running somewhere else. Design proxies that application and holds a review surface over it. Both arguments are Design's own — the name its notes are filed under, then the address it stands in front of — and they travel through unread.

Design is a separate product, in a repository of its own and under a licence of its own, so this command runs Design's binary rather than loading Design's modules. Nothing here imports @athrio/loom-design, and that absence is the point: a bundler cannot follow an import that is not written, so Design's code stays out of this package's bundle and Design's source stays out of its source maps.

Node's own resolver finds the binary. Asking it to resolve Design's manifest from the working directory walks up through every node_modules above the project, exactly as an import would, and the binary sits beside that manifest. A project's own copy therefore wins; a copy installed beside the CLI answers when the project has none.

TypeScript
class DesignMissing extends Data.TaggedError('DesignMissing')<{
  readonly directory: string
}> {}

const manifestOf = (base: string) =>
  Effect.try(() => createRequire(base).resolve('@athrio/loom-design/package.json'))

const designBinary = Effect.gen(function* () {
  const directory = process.cwd()
  const manifest = yield* manifestOf(resolvePath(directory, 'index.js')).pipe(
    Effect.catch(() => manifestOf(import.meta.url)),
    Effect.mapError(() => new DesignMissing({ directory })),
  )
  return resolvePath(dirname(manifest), 'dist', 'loom-design.js')
})

Design inherits this terminal rather than being read through a pipe. It writes its own addresses and its own warnings where the person is already looking, an interrupt reaches it because it runs in the same process group, and its exit code becomes this program's.

A missing binary is the one failure worth handling here, and it is the likely one, since a developer who has Loom need not have Design. The message names the package to install. Anything else that stops the launch is printed as it happened, because guessing at a spawn failure costs more time than showing it.

TypeScript
const design = (project: string, application: string, port: number) =>
  Effect.gen(function* () {
    const binary = yield* designBinary
    const code = yield* Effect.scoped(
      Effect.flatMap(
        ChildProcess.make(binary, [project, application, '--port', String(port)], {
          stdin: 'inherit',
          stdout: 'inherit',
          stderr: 'inherit',
        }),
        (running) => running.exitCode,
      ),
    )
    yield* Effect.sync(() => void (process.exitCode = code))
  }).pipe(
    Effect.catchTag('DesignMissing', () =>
      Console.error(
        'loom: Loom Design is not installed.\n' +
          '      Add it with `bun add -d @athrio/loom-design`, then run this again.',
      ).pipe(Effect.andThen(Effect.sync(() => void (process.exitCode = 1)))),
    ),
    Effect.catchCause((cause) =>
      Console.error(`loom: Loom Design could not start\n${Cause.pretty(cause)}`).pipe(
        Effect.andThen(Effect.sync(() => void (process.exitCode = 1))),
      ),
    ),
  )

The grammar the CLI carries

Two commands set an editor up for looms, loom helix and loom nvim, and both build the grammar this CLI carries. The grammar travels inside the CLI, so it always matches the Loom that is installed. Running either command again after an upgrade rebuilds the grammar from the new release, and nothing needs fetching from the network.

The CLI's build reads the grammar's files into the bundle as text: the generated parser, the scanner, their headers, and the queries each editor colours with. They sit in a module of their own, virtual:tree-sitter-loom, which maps each file's path in the tree-sitter-loom package to its contents. The CLI imports that module only when a command needs the grammar, so a plain tangle never loads it. The npm package and the standalone executable carry the grammar the same way, because the executable is compiled from the very bundle the npm package ships.

A C compiler reads files from disk, not text from a module. So the command writes the grammar's files into a fresh folder of its own under the system's temporary folder and compiles from there. The folder is private to the person running the command. A second user on the same machine cannot plant a parser in it, so the library Loom builds is always the one it carries. The command removes the folder when it is done, whether it succeeded or not.

TypeScript
const embeddedGrammar: Effect.Effect<Option.Option<Readonly<Record<string, string>>>> =
  Effect.tryPromise(() => import('virtual:tree-sitter-loom')).pipe(
    Effect.map((module) => Option.some(module.default)),
    Effect.orElseSucceed(() => Option.none()),
  )

const unpackedGrammar = (
  files: Readonly<Record<string, string>>,
): Effect.Effect<string, never, Scope.Scope> =>
  Effect.acquireRelease(
    Effect.sync(() => mkdtempSync(resolvePath(tmpdir(), 'loom-grammar-'))),
    (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })),
  ).pipe(
    Effect.tap((dir) =>
      Effect.forEach(
        Object.entries(files),
        ([path, text]) =>
          Effect.sync(() => {
            mkdirSync(dirname(resolvePath(dir, path)), { recursive: true })
            writeFileSync(resolvePath(dir, path), text)
          }),
        { discard: true },
      ),
    ),
  )

A CLI run from its source, as the tests run it, has no bundle and so no such module. It reads the workspace's own tree-sitter-loom package instead, the files the build reads. A folder counts as the grammar when it holds the generated parser. When neither the bundle nor the workspace holds one, the command fails with GrammarMissing.

TypeScript
class GrammarMissing extends Data.TaggedError('GrammarMissing')<{}> {}

const grammarIn = (dir: string): Option.Option<string> =>
  existsSync(resolvePath(dir, 'src', 'parser.c')) ? Option.some(dir) : Option.none()

const workspaceGrammar = (): Option.Option<string> =>
  Option.flatMap(
    Option.liftThrowable(() =>
      createRequire(import.meta.url).resolve('tree-sitter-loom/package.json'),
    )(),
    (manifest) => grammarIn(dirname(manifest)),
  )

const shippedGrammar: Effect.Effect<string, GrammarMissing, Scope.Scope> = embeddedGrammar.pipe(
  Effect.flatMap(
    Option.match({
      onSome: unpackedGrammar,
      onNone: () =>
        Option.match(workspaceGrammar(), {
          onNone: () => Effect.fail(new GrammarMissing()),
          onSome: (dir) => Effect.succeed(dir),
        }),
    }),
  ),
)

The system's C compiler builds the grammar into a shared library, the same way Helix and nvim-treesitter build a grammar they fetch. The compiler is cc, or whichever one the CC variable names. The parser and the scanner compile together into one library, against the headers in the src folder beside them.

The compiler writes to a temporary name first, and the finished library then replaces the old one in a single rename. An editor that is already running keeps the library it loaded, rather than reading one half written. A compiler that is missing, or that stops with an error, fails the command with GrammarCompileFailed, which names the compiler it tried.

TypeScript
class GrammarCompileFailed extends Data.TaggedError('GrammarCompileFailed')<{
  readonly compiler: string
}> {}

const compileGrammar = (grammar: string, library: string) =>
  Effect.gen(function* () {
    const compiler = process.env.CC ?? 'cc'
    const partial = `${library}.${process.pid}`
    const source = resolvePath(grammar, 'src')
    const code = yield* Effect.scoped(
      Effect.flatMap(
        ChildProcess.make(
          compiler,
          [
            '-shared',
            '-fPIC',
            '-O2',
            '-I',
            source,
            resolvePath(source, 'parser.c'),
            resolvePath(source, 'scanner.c'),
            '-o',
            partial,
          ],
          { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' },
        ),
        (running) => running.exitCode,
      ),
    ).pipe(Effect.catchCause(() => Effect.succeed(-1)))
    if (code !== 0) return yield* Effect.fail(new GrammarCompileFailed({ compiler }))
    yield* Effect.sync(() => renameSync(partial, library))
  })

A person who chose the standalone executable may well have no C compiler. So the release compiles the grammar on each platform's own machine and adds the library to that platform's executable, under the name an editor loads it by. grammarLibrary is that name: loom.so on macOS and Linux, and loom.dll on Windows. buildGrammar writes the library the executable carries when there is one, and compiles the grammar otherwise, as the npm package always does. writeLibrary writes it the way the compiler does, under a temporary name first and then renamed into place.

TypeScript
const grammarLibrary = process.platform === 'win32' ? 'loom.dll' : 'loom.so'

const writeLibrary = (prebuilt: Blob, library: string): Effect.Effect<void> =>
  Effect.promise(() => prebuilt.arrayBuffer()).pipe(
    Effect.flatMap((bytes) =>
      Effect.sync(() => {
        const partial = `${library}.${process.pid}`
        writeFileSync(partial, new Uint8Array(bytes), { mode: 0o755 })
        renameSync(partial, library)
      }),
    ),
  )

const buildGrammar = (grammar: string, library: string) =>
  Option.match(embeddedFile(grammarLibrary), {
    onSome: (prebuilt) => writeLibrary(prebuilt, library),
    onNone: () => compileGrammar(grammar, library),
  })

Each command checks the editor's configuration before it touches anything, so a clash leaves the configuration as it was. It then builds the library, copies the queries, and writes the configuration, reporting each step, and ends by naming the check that confirms the result. A failure prints what went wrong and how to put it right, and exits non-zero. Both commands answer a failed compile with the same advice.

TypeScript
const refuse = (message: string) =>
  Console.error(message).pipe(Effect.andThen(Effect.sync(() => void (process.exitCode = 1))))

const cannotCompile = (error: GrammarCompileFailed) =>
  refuse(
    `loom: could not compile the grammar with \`${error.compiler}\`.\n` +
      '      Install a C compiler, or name one in the CC variable, then run this again.',
  )

Setting up Helix

loom helix sets up the Helix editor for looms in one step. Helix reads a language from three places in its configuration folder: the grammar it parses the language with, the queries that colour the parse, and an entry in languages.toml that names the file extension and the language server. The command writes all three. It builds the grammar into runtime/grammars, copies the Helix queries beside it, and adds Loom to languages.toml.

The configuration folder is the one Helix itself reads. On macOS and Linux that is helix under $XDG_CONFIG_HOME, or under ~/.config when the variable is unset, and on Windows it is helix under %AppData%. Helix loads a grammar as a shared library, by the name grammarLibrary gives it.

TypeScript
const helixConfigDir = (): string =>
  process.platform === 'win32'
    ? resolvePath(process.env.APPDATA ?? homedir(), 'helix')
    : resolvePath(process.env.XDG_CONFIG_HOME ?? resolvePath(homedir(), '.config'), 'helix')

Loom's entry in languages.toml is two tables. The language table makes every .loom file a loom, treats the folder that holds .loom/config.yaml as its project, and lets Markdown hand a fence marked loom to the grammar. It also turns on soft wrap at a text width of defaultWidth, a hundred columns, the width Loom formats code to. A loom's prose keeps each paragraph on one line, so Helix wraps it on screen at that width, and the prose and the code share one right edge without a line break written into the file. In a project that loom init has set up, Helix takes the width from the project's .editorconfig instead, so a project with a width of its own wraps at that. The server table starts loom lsp. Helix needs no table for the grammar, because it finds the compiled library in its runtime folder by the language's name.

Helix 25.07 finds a project by joining each root marker to every folder above the file and asking whether that path exists, so a marker may name a file inside a folder. A bare .loom would also match the ~/.loom folder that holds the standalone executable, which is no project. A later Helix may match a marker against names alone, and then this marker matches nothing, so Helix falls back to the folder it was started in, or the repository around it, and never to a wrong project.

The entry is a managed block, like the section loom init writes into .editorconfig, so a later run replaces it whole and leaves the rest of languages.toml as its owner wrote it. Here the owner's own setting is a Loom entry outside the block. The command then changes nothing and fails with HelixConfigClash, since two entries for one language would leave Helix to pick between them.

TypeScript
const helixBlock: ManagedBlock = {
  begin: '# Loom: written by `loom helix`, which replaces this block on every run',
  end: '# Loom: end',
  lines: [
    '[[language]]',
    'name = "loom"',
    'scope = "source.loom"',
    'file-types = ["loom"]',
    'roots = [".loom/config.yaml"]',
    'injection-regex = "^loom$"',
    'language-servers = ["loom"]',
    'soft-wrap = { enable = true, wrap-at-text-width = true }',
    `text-width = ${defaultWidth}`,
    '',
    '[language-server.loom]',
    'command = "loom"',
    'args = ["lsp", "--stdio"]',
  ],
  owned: /^\s*name\s*=\s*"loom"/m,
}

class HelixConfigClash extends Data.TaggedError('HelixConfigClash')<{
  readonly file: string
}> {}

const withLoomLanguages = (file: string, text: string): Effect.Effect<string, HelixConfigClash> =>
  Option.match(withManagedBlock(text, helixBlock), {
    onNone: () => Effect.fail(new HelixConfigClash({ file })),
    onSome: Effect.succeed,
  })

The command settles languages.toml first, then builds the library and copies the queries, and writes the file last. Helix's health check confirms the result.

TypeScript
const helix = Effect.gen(function* () {
  const config = helixConfigDir()
  const languagesFile = resolvePath(config, 'languages.toml')
  const grammar = yield* shippedGrammar
  const current = yield* Effect.sync(() =>
    existsSync(languagesFile) ? readFileSync(languagesFile, 'utf8') : '',
  )
  const languages = yield* withLoomLanguages(languagesFile, current)

  const library = resolvePath(config, 'runtime', 'grammars', grammarLibrary)
  yield* Effect.sync(() => mkdirSync(dirname(library), { recursive: true }))
  yield* buildGrammar(grammar, library)
  yield* Console.log(`\n   ${teal('✓')} built the grammar into ${library}`)

  const queries = resolvePath(config, 'runtime', 'queries', 'loom')
  yield* Effect.sync(() =>
    cpSync(resolvePath(grammar, 'editors', 'helix', 'queries', 'loom'), queries, {
      recursive: true,
    }),
  )
  yield* Console.log(`   ${teal('✓')} copied the queries into ${queries}`)

  yield* Effect.sync(() => writeFileSync(languagesFile, languages))
  yield* Console.log(
    `   ${teal('✓')} added Loom to ${languagesFile}\n` +
      `   ${dim('check it with `hx --health loom`')}\n`,
  )
}).pipe(
  Effect.scoped,
  Effect.catchTags({
    GrammarMissing: () => refuse('loom: this installation of Loom carries no grammar for Helix.'),
    GrammarCompileFailed: cannotCompile,
    HelixConfigClash: (error) =>
      refuse(
        `loom: ${error.file} already sets up Loom.\n` +
          '      Remove its Loom entries, then run this again to replace them with the managed block.',
      ),
  }),
)

Setting up Neovim

loom nvim sets up Neovim for looms in one step, with no plugin manager and no nvim-treesitter. Neovim 0.11 already holds what a loom needs: a tree-sitter host that loads a parser by its language's name, and a language-server client configured in Lua. What it lacks are three files. The command builds Loom's parser, copies the queries that colour the parse, and writes a plugin that names the file type and starts the server.

All three go into the site folder of Neovim's data folder. The data folder is the one Neovim's stdpath('data') names: nvim under $XDG_DATA_HOME, or under ~/.local/share when the variable is unset, and on Windows nvim-data under %LocalAppData%. The NVIM_APPNAME variable renames the nvim part, for a person who keeps several configurations side by side. Neovim puts site on its runtime path from the start, so it finds what the command writes there without a line of the person's own configuration. It is also where nvim-treesitter installs the parsers it builds, so Loom's parser sits beside theirs.

Neovim finds a parser by the language's name under parser, whatever the file's extension, and the command names it loom.so on every platform, as nvim-treesitter does.

TypeScript
const nvimSiteDir = (): string => {
  const name = process.env.NVIM_APPNAME ?? 'nvim'
  return process.platform === 'win32'
    ? resolvePath(process.env.LOCALAPPDATA ?? homedir(), `${name}-data`, 'site')
    : resolvePath(
        process.env.XDG_DATA_HOME ?? resolvePath(homedir(), '.local', 'share'),
        name,
        'site',
      )
}

Neovim runs each Lua file in a plugin folder on its runtime path as it starts, so the command writes plugin/loom.lua. The plugin makes every .loom file the loom file type. It configures the language server with the table nvim-lspconfig collects for Loom, the command, the file type and the way to find a loom's root, and enables it. It starts tree-sitter for each loom as it opens, because Neovim colours a buffer with tree-sitter only when asked.

Neovim matches a root marker against the names in each folder above a file, and a name cannot reach inside a folder to .loom/config.yaml. A bare .loom would also match the ~/.loom folder that holds the standalone executable, which is no project. So the plugin gives Neovim a root_dir function instead. It takes the nearest folder whose .loom holds a config.yaml, then the nearest Git repository, and otherwise starts the server with no root at all, as Neovim does for a loose file.

The plugin also keeps Neovim from breaking a loom's lines while the person types. Neovim reads a project's .editorconfig and sets its text width from the max_line_length that loom init writes there. At that width, Neovim's t format option breaks a line of text as soon as typing runs past it, and its c option does the same to a line Neovim takes for a comment, such as a list item or a heading. A loom keeps each paragraph on one line, so the plugin removes both options in every loom.

The plugin's first line says loom nvim wrote it, and a later run replaces only a file that opens on that line. A plugin/loom.lua without it is the person's own, so the command changes nothing and fails with NvimConfigClash.

TypeScript
const nvimHeader = '-- Loom: written by `loom nvim`, which replaces this file on every run'

const nvimPlugin = [
  nvimHeader,
  "vim.filetype.add({ extension = { loom = 'loom' } })",
  '',
  "vim.lsp.config('loom', {",
  "  cmd = { 'loom', 'lsp', '--stdio' },",
  "  filetypes = { 'loom' },",
  '  root_dir = function(bufnr, on_dir)',
  '    local workspace = function(name, path)',
  "      local config = vim.fs.joinpath(path, name, 'config.yaml')",
  "      return name == '.loom' and vim.uv.fs_stat(config) ~= nil",
  '    end',
  "    on_dir(vim.fs.root(bufnr, workspace) or vim.fs.root(bufnr, '.git'))",
  '  end,',
  '})',
  "vim.lsp.enable('loom')",
  '',
  "vim.api.nvim_create_autocmd('FileType', {",
  "  pattern = 'loom',",
  '  callback = function()',
  '    vim.treesitter.start()',
  "    vim.opt_local.formatoptions:remove({ 't', 'c' })",
  '  end,',
  '})',
  '',
].join('\n')

class NvimConfigClash extends Data.TaggedError('NvimConfigClash')<{
  readonly file: string
}> {}

The command reads the plugin it would replace first, then builds the parser, copies the queries, and writes the plugin last. Neovim's tree-sitter health check lists the parser it loads.

TypeScript
const nvim = Effect.gen(function* () {
  const site = nvimSiteDir()
  const pluginFile = resolvePath(site, 'plugin', 'loom.lua')
  const grammar = yield* shippedGrammar
  const current = yield* Effect.sync(() =>
    existsSync(pluginFile) ? Option.some(readFileSync(pluginFile, 'utf8')) : Option.none<string>(),
  )
  if (Option.exists(current, (text) => !text.startsWith(nvimHeader)))
    return yield* Effect.fail(new NvimConfigClash({ file: pluginFile }))

  const parser = resolvePath(site, 'parser', 'loom.so')
  yield* Effect.sync(() => mkdirSync(dirname(parser), { recursive: true }))
  yield* buildGrammar(grammar, parser)
  yield* Console.log(`\n   ${teal('✓')} built the grammar into ${parser}`)

  const queries = resolvePath(site, 'queries', 'loom')
  yield* Effect.sync(() => cpSync(resolvePath(grammar, 'queries'), queries, { recursive: true }))
  yield* Console.log(`   ${teal('✓')} copied the queries into ${queries}`)

  yield* Effect.sync(() => {
    mkdirSync(dirname(pluginFile), { recursive: true })
    writeFileSync(pluginFile, nvimPlugin)
  })
  yield* Console.log(
    `   ${teal('✓')} added Loom to ${pluginFile}\n` +
      `   ${dim('check it with `:checkhealth vim.treesitter` in Neovim')}\n`,
  )
}).pipe(
  Effect.scoped,
  Effect.catchTags({
    GrammarMissing: () => refuse('loom: this installation of Loom carries no grammar for Neovim.'),
    GrammarCompileFailed: cannotCompile,
    NvimConfigClash: (error) =>
      refuse(
        `loom: ${error.file} was not written by \`loom nvim\`.\n` +
          '      Move it aside, then run this again.',
      ),
  }),
)

Where the world ends

The command program is sixteen subcommands. loom tangle <path> emits a .loom's files, the path a file or a directory of them, recording each in the lock; with --watch it stays running and re-tangles as the looms change, so a dev server watching the output reacts to a loom edit. loom orphans lists the files across the workspace that the lock records but no loom still produces, one path per line for a shell to pipe into rm, and --prune removes them and rewrites the lock. loom weave <path> <out> projects a corpus into a WovenCorpus and writes it as JSON to out. loom toc <path> rewrites a book's table of contents from the corpus's own frontmatter. loom format <path> formats the looms a file or a folder holds, and with --check names the ones that would change instead. loom init [dir] scaffolds a .loom/config.yaml, an .mcp.json and an .editorconfig, defaulting to the working directory. loom add <language> and loom remove <language> turn a language on and off, and loom update <language>… or loom update --all brings the services of active languages to this Loom's version. loom upgrade replaces Loom with its latest release, or names the installer's command that does. loom status reports what the workspace has activated and what the store holds, and loom doctor checks the install, PATH and the workspace's services, naming the command that fixes each problem it finds. loom set <name> <value> writes a workspace variable and re-tangles the corpus, so a version bump is one command. loom design <name> <address> stands Loom Design in front of a running application. loom helix sets the Helix editor up for looms, and loom nvim sets up Neovim. @effect/cli parses the line, dispatches to the matching command, and prints help when none matches. The run site provides every service the commands reach for — the tangler and its disk source, the weaver, the contents writer, the formatter, the package config, the configuration, and the Node platform behind the prompts and the filesystem.

One entry point sits outside the command program. loom lsp --stdio starts the language server for any editor. It loads Volar only when it is reached, so a plain tangle never pays to bundle it, and it runs on its own Bun runtime rather than nesting one inside a command. Volar is CommonJS and calls require at runtime for the built-in modules; this bundle is an ES module, where require is absent, so before loading the server the lsp branch exposes one through createRequire on globalThis — the shim Volar's bundled code reaches for. That cost lands only on the lsp path.

TypeScript
const tangleCommand = Command.make(
  'tangle',
  { path: Argument.String('path'), watch: Flag.Boolean('watch').pipe(Flag.withDefault(false)) },
  ({ path, watch }) => tangle(path, watch),
)

const orphansCommand = Command.make(
  'orphans',
  { prune: Flag.Boolean('prune').pipe(Flag.withDefault(false)) },
  ({ prune }) => orphans(prune),
)

const weaveCommand = Command.make(
  'weave',
  { path: Argument.String('path'), out: Argument.String('out') },
  ({ path, out }) => weave(path, out),
)

const formatCommand = Command.make(
  'format',
  { path: Argument.String('path'), check: Flag.Boolean('check').pipe(Flag.withDefault(false)) },
  ({ path, check }) => format(path, check),
)

const tocCommand = Command.make('toc', { path: Argument.String('path') }, ({ path }) => toc(path))

const initCommand = Command.make(
  'init',
  { dir: Argument.optional(Argument.Directory('dir')) },
  ({ dir }) => init(dir),
)

const addCommand = Command.make('add', { language: Argument.String('language') }, ({ language }) =>
  add(language),
)

const removeCommand = Command.make(
  'remove',
  { language: Argument.String('language') },
  ({ language }) => remove(language),
)

const updateCommand = Command.make(
  'update',
  {
    languages: Argument.String('language').pipe(Argument.variadic()),
    all: Flag.Boolean('all').pipe(Flag.withDefault(false)),
  },
  ({ languages, all }) => update(languages, all),
)

const upgradeCommand = Command.make('upgrade', {}, () => upgrade)

const statusCommand = Command.make('status', {}, () => status)

const doctorCommand = Command.make('doctor', {}, () => doctor)

const setCommand = Command.make(
  'set',
  { name: Argument.String('name'), value: Argument.String('value') },
  ({ name, value }) => set(name, value),
)

const designCommand = Command.make(
  'design',
  {
    project: Argument.String('name'),
    application: Argument.String('address'),
    port: Flag.Int('port').pipe(Flag.withDefault(5710)),
  },
  ({ project, application, port }) => design(project, application, port),
)

const helixCommand = Command.make('helix', {}, () => helix)

const nvimCommand = Command.make('nvim', {}, () => nvim)

const loom = Command.make('loom').pipe(
  Command.withSubcommands([
    tangleCommand,
    orphansCommand,
    weaveCommand,
    tocCommand,
    formatCommand,
    initCommand,
    addCommand,
    removeCommand,
    updateCommand,
    upgradeCommand,
    statusCommand,
    doctorCommand,
    setCommand,
    designCommand,
    helixCommand,
    nvimCommand,
  ]),
)

const program = Command.run(loom, { version: loomVersion }).pipe(
  Effect.provide(LoomTangler.layer),
  Effect.provide(LoomWeaver.layer),
  Effect.provide(LoomContents.layer),
  Effect.provide(LoomFormatter.layer),
  Effect.provide(DocumentSource.layer),
  Effect.provide(PackageConfig.layer),
  Effect.provide(LoomConfig.layer),
  Effect.provide(NodeServices.layer),
)

if (process.argv[2] === 'lsp') {
  ;(globalThis as { require?: unknown }).require = createRequire(import.meta.url)
  const { startLanguageServer } = await import('@athrio/loom-lang/LoomServer')
  startLanguageServer()
} else {
  NodeRuntime.runMain(program)
}

What the program needs

The program is built on Effect, a functional TypeScript library for composing and running effectful programs. Effect's CLI toolkit parses the command line and drives the interactive prompts, and the Node platform supplies the runtime, the terminal, and filesystem access. Stream drives tangle --watch, draining the tangler's watch as a sequence of passes, and Result carries each pass's outcome.

Node's path resolves a workspace directory for init. node:fs writes the configuration, the .mcp.json and the .editorconfig, creates the corpus folder, unpacks the grammar into a temporary folder and removes it again, copies and renames the files Helix and Neovim read, and writes the executable loom upgrade downloads. Effect's child-process module runs Loom Design's binary for design and the C compiler for helix and nvim, Data types their failures, and Scope keeps the unpacked grammar until the command that unpacked it is done. node:module supplies the resolver that finds Design's binary, the workspace's grammar and a TypeScript — the same resolver the lsp branch borrows to give Volar a require. pipe threads each search through Option, node:os supplies the home folder that Helix's configuration and Neovim's data live under and the temporary folder the grammar unpacks into, and node:url turns the bundle's own address into a path.

For loom upgrade, Schema decodes the release GitHub describes, Array finds a file in it, Order compares two versions, node:crypto computes the checksum of a download, and node:zlib unpacks it. Match reads an installer's marks in a path, and realpathSync follows the links that lead to a file. For loom doctor, delimiter splits PATH into its folders, statSync tells whether a file in one of them is a program, and Equal compares two installations by what they hold.

The language server contributes six services: LoomTangler, which walks a .loom file, emits the tangled output, and watches it for changes; LoomWeaver, which projects a corpus and writes the woven data; LoomContents, which regenerates a book's table of contents; LoomFormatter, which formats a loom and names the looms a folder holds; DocumentSource, which feeds them source text from disk; and PackageConfig, which resolves a .loom's package settings. TangledFile and TangleError ride along from the compiler, typing the files a pass writes and the error a broken corpus raises. From @athrio/loom-config comes LoomConfig, which resolves a file's configuration, reads the whole compiled manifest, and writes it back, and defaultWidth, the width a loom's lines keep to when its project names none.

From @athrio/loom-lang-services come the store operations behind activation: installService and removeService install and uninstall a language's package, with ServiceInstall saying what an install did and StoreError the reason one failed, installedVersion reads the version of a service the store holds, and serviceVersion is the version this Loom expects. storeDir locates the store, workspaceRoot finds the workspace it sits in, workspaceOf says whether any workspace holds a folder, and servicePackage names a language's package. storeTypeScript and installTypeScript find and install the TypeScript the store keeps, at the range typeScriptRange names, standalone says whether Loom runs as the standalone executable, and embeddedFile finds the grammar library an executable carries. For format, loadActive loads the project's language services from the store and installHostRuntime lends them the host's Effect and TypeScript, both imported only when format reaches them; LanguageService types what the loader returns, and pathToFileURL turns the TypeScript it finds into the address import takes.

TypeScript
import {
  Array,
  Cause,
  Console,
  Data,
  Effect,
  Equal,
  Match,
  Option,
  Order,
  Result,
  Schema,
  type Scope,
  Stream,
  pipe,
} from 'effect'
import { Argument, Command, Flag, Prompt } from 'effect/cli'
import { ChildProcess } from 'effect/process'
import { NodeRuntime, NodeServices } from '@effect/platform-node'
import { createHash } from 'node:crypto'
import {
  cpSync,
  existsSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  realpathSync,
  renameSync,
  rmSync,
  statSync,
  writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { homedir, tmpdir } from 'node:os'
import { delimiter, dirname, resolve as resolvePath } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { gunzipSync } from 'node:zlib'
import { DocumentSource, type TangledFile, type TangleError } from '@athrio/loom-lang/LoomCompiler'
import { LoomTangler } from '@athrio/loom-lang/LoomTangler'
import { LoomWeaver } from '@athrio/loom-lang/weave/LoomWeaver'
import { LoomContents } from '@athrio/loom-lang/weave/LoomContents'
import { LoomFormatter } from '@athrio/loom-lang/LoomFormatter'
import { PackageConfig } from '@athrio/loom-lang/PackageConfig'
import { defaultWidth, LoomConfig } from '@athrio/loom-config/LoomConfig'
import {
  installedVersion,
  installService,
  embeddedFile,
  installTypeScript,
  removeService,
  ServiceInstall,
  type StoreError,
  servicePackage,
  serviceVersion,
  standalone,
  storeDir,
  storeTypeScript,
  typeScriptRange,
  workspaceOf,
  workspaceRoot,
} from '@athrio/loom-lang-services/LoomStore'
import type { LanguageService } from '@athrio/loom-lang-services/LanguageService'
Loom