npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@unknown-creatives/substrate

v2.2.0

Published

--- purpose: "The substrate CLI: setup, check, generate and upgrade for consumer projects" authority: source audience: [maintainers, consumers] children: skip ---

Readme


purpose: "The substrate CLI: setup, check, generate and upgrade for consumer projects" authority: source audience: [maintainers, consumers] children: skip

@unknown-creatives/substrate

The distribution CLI. substrate setup takes a fresh or stale consumer repo to a project that checks and generates: it installs the signed engine bundle, wires the host and each AI tool (skills, instruction file, the opt-in Bloom agent) across the platform matrix (Claude Code, Cursor, Windsurf, Copilot, Gemini, Codex, …), installs the check gates, and adds package scripts. substrate check, generate and migrate run the installed engine, and substrate doctor reports what is wrong with a project and how to fix it. The CLI also owns catalog scaffolding through substrate add, deterministic substrate upgrade engine swaps, and exact-byte engine artifact assembly through substrate artifact assemble. setup and upgrade cross one delivery seam: both consume the signed engine bundle and share a single verification implementation (distribution architecture v2). substrate init was removed: calling it exits 1 and names setup.

This README is the command reference. To bring Substrate into a project, follow getting started: it runs every step from the handoff files to an upgrade, and a test runs its commands. The delivery runbook covers the handoff and release operations.

Running the CLI

This package is published to npm. The package is @unknown-creatives/substrate; a bare npx substrate still installs an unrelated third-party package — always use the scoped name:

npx @unknown-creatives/substrate@<v> <command>

The bare substrate <cmd> spellings below assume that invocation. The published version always equals the engine release version (single-version coupling), so npx @unknown-creatives/substrate@<v> means "the CLI released alongside engine <v>".

The published package is self-contained: it declares and installs its own dependencies (js-yaml, and esbuild for artifact assemble). The engine runs from its sealed compiler.mjs and needs nothing installed in the client.

Working on Substrate itself, run the bin from your checkout after npm ci:

node <substrate-checkout>/packages/cli/bin/substrate-init.js setup --help

One-step setup

Without --plan or --apply, setup plans and applies in one invocation (getting started §4 shows the command). It takes --brand and --target (repeatable), --ai <host> (repeatable), --bloom or --no-bloom, --adopt <folder>, --yes and --json. It prints the plan and asks before applying; --yes skips the prompt, and a run that can't prompt must pass it. --json prints the apply result (or { "status": "failed", "error", "findings" }) as one JSON document on stdout for an agent. The exit code is 0 when setup committed or found everything in place, and 1 when it failed or left a needed edit. The result's skipped lists every edit setup left to the client with its exact text (blocking: true when the app can't import Substrate until it is made), and next names the commands that check and generate the project.

The transaction described below also:

  • wires AI hosts. Each detected host, or each --ai <host>, gets every skill in the engine's skills/ copied into its skills folder and the Substrate section of its instruction file (between substrate:start and substrate:end markers for append-mode files). --bloom copies agents/bloom/; --no-bloom removes the copy an earlier setup made, and the choice is recorded in .substrate/state.yaml. Links an earlier install made into an engine are removed. aiHostInstall in state.yaml records each folder setup installed, per host, with a content hash. Setup overwrites or removes only those folders, and only while they hold what it installed; a recorded skill the engine no longer ships is removed. A same-named folder it didn't install, or a recorded one the client edited, is a blocking skipped item: rename or move yours, or pass --adopt <folder> to let setup take it;
  • adds package scripts. substrate:check and substrate:generate run the installed compiler.mjs, so they need no global CLI. A script the project already uses under either name is kept and listed under skipped;
  • migrates and checks the content. The bundled engine's config migrations run over the staged content root, and substrate check must pass, before anything is generated. A finding stops setup with its file, line and fix; a migration that needs a decision stops it with that note;
  • replaces an engine no delivery record owns, such as a vendored engine an older install left, and removes its links from the content root, so a stale repo comes current with the same command.

Setup planning and transactional apply

substrate setup --plan is the non-mutating front door for self-serve setup. It inspects a supported Vite + React consumer through client-visible signals (package.json, one vite.config.*, index.html, the React application entry, the application tsconfig, and one lockfile family) and prints a canonical JSON plan to stdout. substrate setup --apply <plan.json> --signing-public-key <key> then applies that plan.

--dry-run is an alias for --plan. Both modes only read the consumer repository. Keep a serialized plan outside the consumer repository: the plan binds the repository's real path and exact file snapshot, so adding the plan itself inside that tree correctly makes the original snapshot stale.

Apply accepts only that plan and a trusted Ed25519 public key. It rechecks the repository snapshot, re-derives the complete plan, verifies the retained bundle bytes against their attestation, checksum, signature, and public key, and stages the engine plus ownership records before committing any consumer-visible resource. A stale, edited, or foreign plan fails without mutation. Pre-commit failure removes staging and leaves the consumer byte-equivalent to its starting state; an exact rerun reports retained without rewriting files.

Apply also installs the write-time gates, listed in the plan's check-gates phase. Each runs .substrate/hooks/check-gate.mjs, which calls the engine's check and reports every finding with its fix:

  • a Claude Code PostToolUse hook on Write|Edit|MultiEdit in .claude/settings.json, when Claude Code is detected;
  • a git pre-commit gate that blocks a commit with a broken staged config, added to .husky/pre-commit, a lefthook config, or the pre-commit file in core.hooksPath or git's hooks folder;
  • .github/workflows/substrate-check.yml, which runs the check on every push and pull request.

A gate setup can't own, such as a project inside a larger git repository, is listed under skipped with the line to add by hand. The pre-commit gate judges the staged text, so a partly staged config is checked as it will be committed. substrate upgrade rewrites the installed gates to its own version.

Apply is also the reconciliation boundary for an existing declaration. It copies the current generated root into staging, runs the complete declared project generation there, hash-validates every generated path declared by the new manifest.gen.json, and compares that manifest with the previous generated ownership record. Unmanifested passthrough files are checked separately and must remain byte-identical. A removed brand, component, or target deletes every path owned by the previous manifest, including a locally edited copy; unmanifested files are copied through unchanged, and a newly generated path that would collide with an unowned file fails the transaction. Generation or pre-commit failure cannot publish a partial prune or advance ownership.

The apply JSON reports generated artifacts separately from committed transaction resources. artifacts.targets.css, .swift, and .compose each list created, updated, removed, and unchanged client-relative paths; the ownership-manifest report independently identifies whether manifest.gen.json was created, updated, or unchanged. Repeating the same declaration or contraction therefore produces an all-unchanged artifact report and no committed resource changes.

The local engine input is the substrate-engine-<version>.bundle file emitted by substrate artifact assemble, together with its .attestation.json, .bundle.sha256, and .bundle.sig sidecars. Planning reads the bundle's declared member metadata to enumerate future destinations, but labels those declarations unverified: provider verification of the retained bundle bytes is mandatory before apply can extract anything. It never accepts an unpacked directory as a second delivery format.

The JSON identifies the vite-react host adapter, operational root remaps and integration points, inferred or existing substrate/project.yaml, detected AI hosts, and the future acquisition, ownership, projection, host-integration, generation, and verification phases. Every planned resource includes its future operation and reason. Generated target projections carry only target-neutral brand/target selections; engine adapters own their concrete output topology and the generation phase owns manifest.gen.json.

The client-owned declaration has three keys. Components are not listed: every folder under substrate/components/ with a config.yaml is generated.

version: 2
brands:
  - acme
targets:
  - css
  - swift
  - compose

Targets are output selections, not AI hosts. AI-host detection remains a separate operational concern destined for .substrate/state.yaml; delivery provenance remains in .substrate/manifest.yaml; generated topology and freshness remain in substrate/generated/manifest.gen.json.

The installed engine resolves this declaration into one platform-neutral declared project graph before target descriptor construction. CSS, Swift, and Compose consume target selections from the same graph, so brand identity, component dependency closure, system inputs, and provenance resolve once while adapter syntax remains separate.

When substrate/project.yaml already exists, plan mode reads and validates it instead of accepting selection flags as a second authority; a version 1 file is planned as the version 2 declaration the engine's migration produces. Unsupported or ambiguous host shapes fail with a resolution before any edit, except a Vite config that declares its own top-level resolve: setup leaves it alone, lists the exact alias edit under skipped, skips the build gate, and reports the run incomplete until the edit is made. The supported Vite seam directly imports defineConfig from vite, exports one object literal through that binding, and declares exactly one top-level plugins array. Transactional apply verifies and stages the sealed engine, durable project state, generated CSS projection, and deterministic Vite/TypeScript/ React integration before committing them. It then runs the consumer-owned scripts.build, whose supported contract performs both the TypeScript check and Vite production build; setup reports success only when that command passes. Repeated setup retains the managed integration byte-for-byte. Unsupported, ambiguous, or symlinked host layouts fail before any client file is edited. The plan never schedules the existing client-owned project declaration for rewrite, and apply preserves its exact bytes. An engine root whose exact file set and hashes match the unverified bundle declaration is retained only when its delivery manifest also owns the same engine root, version, and member hash set. An engine root no delivery record owns is replaced; a recorded one that differs is rejected with a direction to use substrate upgrade. Verification is mandatory before apply relies on any case.

Onboarding P0 release gate

Run the complete release-level onboarding acceptance gate from the Substrate repository root:

npm run verify:onboarding:p0

The gate requires Node 22, npm registry access for disposable installs, a real swiftc, and a Java-backed kotlinc. Missing native toolchains are blocking failures; there is no local or CI skip mode.

The command packs and installs @unknown-creatives/substrate, assembles the allowlisted engine bundle through that installed public command, and invokes onboarding only through the documented substrate setup --plan / --apply interface. Disposable consumers have registry-installed Vite, React, and TypeScript dependencies and no source-checkout aliases or links. The gate builds the real Vite application, compiles minimal Swift and Kotlin consumers against the shipped kernels and projected output, proves an unchanged rerun, and contracts brands, components, and targets from generated-manifest ownership while preserving an unmanifested client file. It also proves byte restoration and no success output for artifact tampering, invalid declaration, generation, Swift/Compose compilation, and consumer-build failures.

The pull-request checks job and the release workflow run this exact command. P0 is not green, and a release cannot be cut, unless every black-box contract passes with all three CSS, Swift, and Compose targets enabled.

Toolchain aliases

Setup writes the supported @substrate/* import aliases for the project's toolchain (UCS-703). The alias list is sourced from the engine's src/aliases.ts policy SSOT (UCS-702), so a contract change can't drift from what setup writes.

  • tsconfig: the two public aliases (@substrate/engine, @substrate/generated/*) go into compilerOptions.paths, preserving other options and paths. baseUrl is never added: TypeScript 7 removed it (TS5102).
  • Bundlers: .substrate/aliases.js exports the same aliases resolved to absolute paths. The vite-react adapter adds them to vite.config.*; any other bundler config is listed under skipped with the exact edit.

Targets resolve against any roots: remapping in .substrate/state.yaml.

Catalog add

substrate add is the P4·c v0 catalog path. It resolves a path-derived entry id from a local catalog, validates the author-facing entry.yaml, copies scaffoldable files into the client content root with provenance headers, records the fetch in .substrate/manifest.yaml, and runs the caller-provided generate command. V0 deliberately has no customer permission, tier, or delivery-token model.

substrate add components/button \
  --catalog ./catalog-staging \
  --generate-command "npm run substrate:generate"

Inside the Substrate repo, the same public command scaffolds entries from catalog-staging/ into a client-shaped target for dogfooding.

Local engine artifact assembly

substrate artifact assemble is the P0 local engine delivery path. It compiles the generator and its runtime packages into a standalone Node compiler entrypoint, independently bundles the browser-safe runtime entrypoint, adds only sealed engine data from the explicit allowlist, and writes one deterministic artifact bundle plus verification metadata:

substrate artifact assemble \
  --source-root . \
  --output-directory dist/local-engine \
  --version 0.39.0 \
  --signing-private-key env:SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEY

For version 0.39.0, the output set is:

  • substrate-engine-0.39.0.bundle — the exact bytes delivered and consumed
  • substrate-engine-0.39.0.attestation.json — version, byte length, checksum, signature, member hashes, and compiler/browser-runtime input provenance
  • substrate-engine-0.39.0.bundle.sha256
  • substrate-engine-0.39.0.bundle.sig

The final-member allowlist contains compiler.mjs, the browser runtime.mjs plus its public TypeScript declarations, a minimal artifact package.json, sealed system/property YAML, the type configs a client component extends (types/action, types/chart, types/feedback, types/input and types/text), the public alias policy, and static web cascade CSS. It also contains the exact Swift/Kotlin kernel sources and SwiftUI/Compose API stubs required to compile generated native projections during client setup. Those native validation inputs have their own canonical provenance list and must match bundle members byte-for-byte. The runtime is compiled separately from the Node compiler so the disposable application imports one browser-safe public entrypoint rather than engine source internals.

generated/, src/brands/, src/components/, and client-owned substrate.config.yaml are not members. Compiler source and package inputs are also enumerated by exact path in checked-in compiler and browser-runtime allowlists; both exact path sets are recorded in artifact provenance. Assembly fails if either build graph reaches a newly imported test, demo, generated registry, source, or package file until that input is explicitly reviewed and added.

Setup can use the local provider seam without trusting a mutable path:

import {
  createLocalEngineArtifactProvider,
  inspectEngineArtifact,
} from '@unknown-creatives/substrate/engine-artifact';

const declaration = inspectEngineArtifact({
  artifactPath: './incoming/substrate-engine-0.39.0.bundle',
  attestationPath: './incoming/substrate-engine-0.39.0.attestation.json',
});
// declaration.verification === 'unverified'
// declaration exposes version, entrypoint, and immutable memberPaths for planning.

const provider = createLocalEngineArtifactProvider({
  artifactPath: './incoming/substrate-engine-0.39.0.bundle',
  attestationPath: './incoming/substrate-engine-0.39.0.attestation.json',
  checksumPath: './incoming/substrate-engine-0.39.0.bundle.sha256',
  signaturePath: './incoming/substrate-engine-0.39.0.bundle.sig',
  publicKey,
});
const artifact = await provider.acquire();
// artifact.verification === 'verified'
artifact.extractTo(stagingDirectory);

inspectEngineArtifact() and provider.inspect() parse only declared, read-only planning metadata and never extract or confer trust. Setup may use their immutable member paths to build a plan, but it must not mutate a client from an unverified declaration. acquire() reads the bundle once, then verifies the byte length, SHA-256 checksum sidecar, Ed25519 signature sidecar, matching attestation, version, attested provenance, member set, member hashes, and safe paths. readBytes() returns a copy of those retained verified bytes and extractTo() materializes from the retained bundle, so replacing the local file after verification cannot change what gets staged. Extraction requires an empty staging directory. The artifact test executes the extracted compiler.mjs against client-owned brand intent, then asserts that the compiler plans and emits a generated registry without node_modules or a source checkout. The release workflow uses this same exact-byte assembler for engine delivery. Both substrate setup --apply and substrate upgrade acquire the engine through this one provider, so the artifact that onboards a client is the artifact that upgrades them (UCS-1118). The existing directory-based catalog signer remains separate.

Deterministic engine upgrade

substrate upgrade is the P3·d updater v1: no git merge, no semantic merge, and no silent overwrite of manifest-tracked engine edits.

Upgrade takes the same signed .bundle and sidecars that setup --apply consumes — one delivery contract across onboarding and updates. Verification is not reimplemented here: upgrade calls createLocalEngineArtifactProvider().acquire(), the single implementation described above, so byte length, checksum, signature, attestation, member hashes, and safe member paths are checked identically on both paths. The engine is then extracted from the retained verified bytes, never copied from a mutable directory. The command runs seven reported steps: verify artifact, codemods, migrate and check, swap engine, regenerate, verify, report. It:

  1. Acquires and verifies the signed engine bundle through the shared provider before touching the current engine, and refuses a bundle whose declared version disagrees with --engine-version. It then confirms the current engine.files hash set in .substrate/manifest.yaml still matches the vendored engine on disk. If it does not, the command stops before swapping and requires an explicit three-door decision.
  2. Runs the versioned codemods the new engine's registry selects for this version crossing (e.g. the 1.10 platform-config split) on a staged copy of the project. A codemod that exits 1 needs a person's decision: the upgrade stops, prints what to change, and has changed nothing.
  3. Migrates the staged configs to the new engine's schema with its own migrate, then runs its check, the way setup --apply does. A migration that needs your decision or a check finding stops the upgrade with its fix, and nothing has changed. It also plans the AI-host refresh from the aiHostInstall record in .substrate/state.yaml: the skills and Bloom copies setup recorded, and any links an earlier install made. A folder Substrate did not install, or a recorded copy you edited, stops the upgrade until you move it or pass --adopt <folder>. On a project setup never ran on (init's state.yaml), it also stages what setup would have wired: the host files, every check gate and the substrate:check and substrate:generate package scripts, from setup's own code.
  4. Swaps everything at once: the engine root from the verified bytes, the migrated config files, the AI-host copies and the managed Substrate section of the instruction file (links become copies, skills the engine dropped go), the onboarding files from step 3, and the content-root links into the engine, which the sealed engine does not need. state.yaml records the new install the way setup does, in setup's format, so substrate doctor passes after one upgrade.
  5. Runs the caller-provided regenerate command.
  6. Runs the caller-provided verify command, then refreshes the manifest engine version and hash set while preserving catalog fetch records, and brings the installed check gates to this CLI's version.
  7. Writes Markdown and JSON reports under .substrate/reports/ and prints the same renderer's summary to the terminal.

A failure from step 4 on rolls back the engine, the configs, the AI-host folders and everything else the upgrade touched. A step onboarding couldn't take, such as a client script under a Substrate script name, prints as Remaining step: with its fix after the upgrade completes. A runtime stop prints the failure and its fix; the usage text prints only for a malformed command.

Getting started §10 shows the command. --engine-version must match the bundle; --regenerate-command and --verify-command run in steps 5 and 6; --report-id names the report; --modified-engine picks a door (below); --adopt <folder> hands an edited AI-host folder to the upgrade.

The .attestation.json, .bundle.sha256, and .bundle.sig sidecars are resolved next to the bundle, exactly as setup resolves them, so a client passes the release handoff files unchanged.

Migrating from the directory contract

--engine-artifact no longer accepts an unpacked engine directory; passing one fails with a migration message before any engine mutation. --artifact-sha256 and --artifact-signature are likewise retired — the bundle carries its own checksum and signature sidecars, verified against the attestation, so those values are no longer passed by hand. If you only have a source checkout, produce a bundle with substrate artifact assemble and pass that.

The signature payload is deterministic and covers artifact kind, target version, and checksum:

substrate-artifact-signature-v1
kind=engine
version=0.37.0
sha256=<sha256>

--signing-public-key accepts a PEM string, a file path, or env:NAME. The release workflow assembles the signed engine bundle through the exact-byte path above and signs catalog attestations with SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEY; clients receive the public key through the engagement handoff and the updater refuses unsigned or tampered artifacts before the engine swap.

Release signing public key

The current release signing public key is recorded in docs/release/substrate-release-signing-public-key.pem and distributed with signed release assets as substrate-release-signing-public-key.pem. Its SHA-256 fingerprint over the SPKI DER public key is:

b61d2c945cd9fad091d9ec2fbd1c5e4ec1fa6097e294b00803744aa472363a6e

Release handoffs should include the public key, this fingerprint, and the matching *.attestation.json, *.sha256, and *.sig files for the delivered engine or catalog artifact. The private key remains only in owner-controlled storage and the GitHub Actions secret named SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEY; it must never be committed, attached to a release, or pasted into handoff notes. If the key is rotated, publish the new public key and fingerprint before signing the first release with it, then call out the rotation in release notes and engagement handoff material.

When the current engine has local edits, --modified-engine refuse (the default) prints the modified paths and the three available doors without touching disk: discard replaces the edited engine with the new artifact, freeze exits successfully while staying on the current version, and engage-uc exits successfully with an upstream/white-glove handoff prompt. Only discard may overwrite engine edits, and it must be selected explicitly.

Engine artifacts may ship upgrade codemods in codemods/registry.yaml. This framework is specific to deterministic engine upgrades and is deliberately separate from the guided src/migration/ domain. Entries run in target-version order during the post-swap-pre-regenerate phase, which now runs on the staged copy of the project with the new engine, before anything in the project changes:

version: 1
codemods:
  - id: split-platform-config
    from: 1.9.2
    to: 1.10.0
    phase: post-swap-pre-regenerate
    always: true
    script: compiler.mjs
    args: [codemod, split-platform-config]
    command: node "$SUBSTRATE_ENGINE_ROOT/compiler.mjs" codemod split-platform-config

An entry runs when its to version is above the client's previous engine version and at or below the new one. always: true drops the lower bound: an idempotent codemod then reruns on every upgrade whose target is at or above to (CLIs that predate the field run it only on the crossing). script is an engine-relative file run with the upgrading CLI's own Node and no shell, with args after it; command is a shell command, required when there is no script and kept alongside it for older CLIs. Either way the codemod runs from the root of the staged project copy with SUBSTRATE_PROJECT_ROOT, SUBSTRATE_ENGINE_ROOT, SUBSTRATE_CONTENT_ROOT, SUBSTRATE_GENERATED_ROOT, and SUBSTRATE_CODEMOD_* environment variables, and its output is printed under the 2/7 codemods step. Exit 1 means a person has to decide: the upgrade stops, prints the codemod's output, and changes nothing. Any other non-zero exit fails the upgrade the same way. The phase field defaults to post-swap-pre-regenerate; the explicit field is kept so a future post-regenerate phase can be added without changing the registry shape.

The engine's registry lives in scripts/codemods/registry.yaml and is sealed into the bundle as codemods/registry.yaml. Engine codemods ship inside compiler.mjs (node compiler.mjs codemod <id> [--check]), so they run with nothing but the bundle. split-platform-config shares its implementation with scripts/split-platform-config.ts (src/kernel/system/config-layer-migration.ts) and reads the new engine's property schema. The upgrade protects the client's components/, types/ and brands/ trees and every config file the migration writes or moves, so codemod and migration edits are restored if regenerate or verify fails.

The report lists refreshed pristine engine files, modified delivered catalog files, path-collision counts, and knowledge/reference overlay conflicts. Overlay conflicts cover same-path overlays plus conservative semantic duplicates: Markdown/frontmatter titles provide the semantic key, token overlap provides a deterministic confidence score, and each finding carries a stable id plus a cleanup recommendation for Bloom. Its JSON sidecar is the durable input for the later substrate adopt flow (P3·f).

Checking configs

substrate check runs every config rule the engine enforces and reports each problem with its file, line, key path, message and fix. It writes nothing and says nothing about generated output.

substrate check                                  # the whole content root
substrate check substrate/components/button      # what these paths touch
substrate check components/button/config.yaml    # paths may be content-root relative
substrate check --json                           # the findings as a JSON array

A finding prints as file:line path [rule], then the message, then fix:. --json gives each finding file, line, path, rule, severity, message and fix. The rule is a stable id from the engine's registry (src/kernel/schema/finding-rules.ts), and every severity is error. A fix followed as written gives the config its author meant; where Substrate has no form for what was written, the fix says to report the gap instead. An absolute path through a linked folder (macOS /tmp is /private/tmp) checks the file it links to. A path that is missing, outside the content root, or names no config is a finding too, never skipped. Exit codes: 0 no problems, 1 findings, 2 the check could not run.

substrate generate writes the generated output. substrate generate --check writes nothing and exits 1 when output is stale or missing; when a config is invalid it says so and names substrate check. Both run the engine's compiler.mjs (check, or no command), or scripts/engine-compiler.ts with the engine's tsx in a source checkout.

Config migrations

substrate migrate runs the engine's config migrations over the project's content root. They are versioned by each file's schema: key, not by engine version: a migration rewrites files at one schema version to the next, and a file without the key is at version 1. Rewrites edit the parsed YAML, so comments and key order survive.

substrate migrate --check                # list files that need migrating; exit 1 if any
substrate migrate                        # rewrite them
substrate migrate --content-root content # a content root other than the project's
substrate migrate --json                 # one JSON object: files, changed, decide, errors

Migrate makes every rewrite it can make exactly, including the values substrate check computes (a CSS length in the brand's units, a quoted zero). It then reports two kinds of line. changed: lines (stdout) tell you what it moved or removed, for information. decide: lines (stderr) name what only you can settle, each with its fix. After a migrate, substrate check reports only those decisions and gaps in the grammar.

The CLI runs node <engine>/compiler.mjs migrate, or the engine's scripts/engine-compiler.ts for a source checkout. The registry is src/kernel/schema/migrations.ts. Exit codes: 0 done or nothing to do (with or without changed: lines), 1 work left under --check or a decide: line, 2 the command could not run.

Project health

substrate doctor runs a fixed list of checks on a project setup installed and names the fix for each one that fails. It changes nothing. Run it when setup, substrate check or generation behaves unexpectedly, and after an upgrade.

substrate doctor          # failures first, each with its fix, then what passed
substrate doctor --json   # { status, checks: [{ id, status, message, fix }] }

| Check | Passes when | |---|---| | engine | .substrate/state.yaml was written by setup, the engine has compiler.mjs, and its version matches the CLI's | | check | substrate check finds no problems; a failure lists the first three findings with their fixes. Leftover files the engine rejects (a client properties.yaml, config.global.yaml, config.<brand>.yaml, components: in project.yaml) fail here | | generated | substrate generate --check finds the output up to date | | schema | substrate migrate --check finds nothing to migrate | | gate-script | .substrate/hooks/check-gate.mjs matches the gate this CLI installs | | claude-hook | .claude/settings.json has the current check-gate hook (skipped when Claude Code isn't wired) | | pre-commit | the managed block in the git hook, or the lefthook command, runs the current check (skipped when setup can't own the hooks) | | ci-workflow | .github/workflows/substrate-check.yml matches what this CLI writes | | ai-host-skills | every wired host's skill copies match the engine's skills: none missing, edited, older than the engine's, or dropped by the engine | | bloom | agents/bloom matches the engine's Bloom agent (skipped without --bloom) | | instructions | each wired host's instruction file holds the current Substrate section | | package-scripts | package.json has substrate:check and substrate:generate running the installed engine | | bundler | the host adapter's files (alias manifest, Vite config, tsconfig paths, setup module and entry import) match what setup writes |

A check that doesn't apply is skipped, and a missing engine skips the checks that need it. Each fix names the command to run, usually substrate setup again, which repairs everything it installs. check, generate --check and migrate --check run side by side, so doctor takes about as long as the slowest of them. In --json, a failed check may also carry details: the findings or paths behind it. Exit codes: 0 every check passed or was skipped, 1 any failed, 2 doctor could not run.

Telemetry

Telemetry is off, and stays off unless you turn it on. When it's on, Substrate keeps a local log of which check rules your project trips, so you can share it with the Substrate maintainers and help Bloom learn from real projects. Nothing is ever sent for you.

substrate telemetry on       # sets telemetry: true in project.yaml
substrate telemetry status   # on or off, and how many records there are
substrate telemetry report   # writes .substrate/telemetry/report.md and prints an issue link
substrate telemetry off      # sets telemetry: false; old records stay until you delete them

You can also set telemetry: true in project.yaml yourself.

While it's on, the check gate (the Claude Code hook and the pre-commit hook) and substrate doctor each append one line to .substrate/telemetry/events.jsonl. A line holds:

  • the day (no time), and whether the check gate or doctor wrote it
  • the engine version, and the version of each skill that declares one
  • how many findings there were, counted by rule id
  • for doctor, each check id with pass, fail or skip

It never holds config contents, file names or paths, brand or project names, messages, or anything that identifies you or the repo. An id that doesn't look like a rule id is counted as other. The CI gate records nothing. The folder holds a .gitignore, so records are never committed.

To share, run substrate telemetry report and read report.md: it's exactly what you'd submit. The command prints a link to the telemetry issue form, with the report filled in when it fits in a link. Otherwise paste it. Issues are public. To stop, run substrate telemetry off and delete .substrate/telemetry/.

Bloom reconciliation

substrate adopt consumes an upgrade report JSON sidecar and turns flagged findings into stable, per-finding actions. Listing mode never mutates disk:

substrate adopt --report .substrate/reports/upgrade-2026-06-13-client-upgrade.json

Apply mode requires one explicit finding id. The first implementation only performs deterministic file-level moves: file path collisions copy the engine file into the client path, while exact byte-identical knowledge/reference overlays are removed because the engine now carries the same content. Shadow overlays, semantic duplicates, directory collisions, and modified delivered catalog files remain manual/Bloom-reconciliation plans.

substrate adopt \
  --report .substrate/reports/upgrade-2026-06-13-client-upgrade.json \
  --finding path-collision:0 \
  --apply

Every applied finding writes a rollback record under .substrate/adopt/ before changing files and prints the rollback id:

substrate adopt --rollback adopt-2026-06-13t18.20.00.000z-12345-path-collision-0

A wrong merge is any applied finding that fails validation or user review after adoption. The rollback record restores the exact previous file bytes (or absence) for that finding, giving the client a reversible default while larger semantic choices stay in manual Bloom review.

Substrate location detection

detectSubstrate() (bin/lib/detect.js) probes in priority order; the resolved path is always the directory containing skills/ and agents/:

| Probe | Model | Path | |---|---|---| | $SUBSTRATE_PATH set | manual | env var value (must stay inside the project) | | substrate/engine/skills/substrate-config/ | client | substrate/engine | | engine/skills/substrate-config/ | client | engine (substrate root remapped onto the repo root) | | node_modules/@unknown-ui/substrate/skills/substrate-config/ | install (legacy) | node_modules/@unknown-ui/substrate | | substrate/skills/substrate-config/ | clone-subdir (legacy) | substrate | | skills/substrate-config/ | clone-root | . |

The client model is the v2 client layout (spec §4): a vendored engine at {substrate root}/engine/, the sealed unit UC replaces wholesale. It is probed before every legacy model so a v2 client repo is never misdetected as clone-subdir and a leftover npm install never shadows the vendored engine.

The npm install model is superseded

The npm-symlink install model (npm install @unknown-ui/substrate + symlinks into node_modules/) is formally superseded by vendored delivery — decision D10 of the 2026-06-12 distribution architecture v2 (§10). Detection of node_modules/@unknown-ui/substrate stays so existing installs keep working, but no new capability targets it.

To be precise about what is and isn't published: what was superseded is engine-in-node_modules; what is published today is the CLI only (@unknown-creatives/substrate — the bin/ + assets/ surface of this package). The engine is never published; it arrives through the signed bundle (supervised engagement) or a vendored checkout, and P4·h's engine-packaging question is unchanged by CLI publishing.

The packaging research behind it (npm files manifest, symlink-through- node_modules resolution, update flow) is preserved in the 2026-05-18 companion spec and re-enters as the npm-engine packaging option (P4·h) if self-serve productization wants it. That trigger — not general demand — is what would revive the model.