Using Psy

Day To Day

The commands you actually run, the formatter, the linter, and the config file that ties them together.

Nine chapters of language. This one is the part you type.

psy init
psy check
psy lint
psy format
psy build
psy inspect

Six commands. Diagnostics go to standard error, results go to standard output, and a non-zero exit code means it failed. Nothing surprising.

Global optionMeaning
--root <dir>project root (default: the current directory)
--config <file>path to psy.config.ts
-h, --helpshow help
-v, --versionshow the version

psy init

psy init                          # scaffold into the current directory
psy init my-project               # scaffold into ./my-project
psy init --list                   # list the templates and exit
psy init . --template config      # choose without being asked

With no --template it prompts, when there is a terminal to prompt into. Without one — in a script, in CI — it fails and tells you to pass --template or --yes rather than silently picking something.

OptionMeaning
--template <name>choose without prompting
--listprint the available templates and exit
--yesaccept the default (minimal)
--forcewrite into a directory that is not empty

Nothing is written unless the target directory is empty. A non-empty one fails with PSY9006 until you pass --force. An unknown template fails with PSY9005 and prints the real list, because "unknown template: confg" without the list is a small daily cruelty.

TemplateWhat you get
minimala single Psy module; no config, no framework
configlayered configuration: inheritance, composition, references
frameworkyour own declaration keywords, defined with abstract specs
morphicPsy-defined AI skills and agents, generated into .claude/
claude-plugina Claude Code plugin: agents, skills and slash commands
language-specdescribe a language in Psy, the way Psy describes itself

Every .psy file a template emits is compiled, linted and format-checked by the test suite through the real compiler. So a freshly scaffolded project passes psy check and psy format --check on the first run, and if it ever stops doing that, a test goes red before you find out.

psy init runs before a project exists, so it never loads a psy.config.ts — not even one sitting in the directory you are scaffolding into.

psy check

psy check
psy check spec

Runs the lexer, parser, semantic analysis and framework validation over your configured sources, or over paths given on the command line.

check, lint and format all report how many files they read, and name any .psy files under the project root that source does not cover:

lint: no problems found in 74 file(s)
note: 17 .psy file(s) under the project root are outside `source` and were not
read: fixtures/invalid/...

That note exists because silence about an unread file is indistinguishable from having checked it. I have been burned by exactly that in three other tools. (Nested checkouts — a vendored grammar, a submodule — are not counted.)

psy build

psy build
psy build --check

Compiles every configured source, runs framework validation, and writes each target's files. Running it twice produces no diff. --check verifies without writing, and is what belongs in CI.

The report says how many artifacts exist, not only how many changed:

build: 54 artifacts across 3 targets (0 updated, 54 unchanged)
  claude: 27 artifacts in .claude
  json: 1 artifact in generated
  yaml: 26 artifacts in generated/morphic

"0 updated" on its own tells you nothing about whether the build did anything. "0 updated, 54 unchanged" tells you it did.

Scoping a build

Any path argument scopes generation to the declarations of the files it covers:

psy build build/agents/BuildLexer.psy
build: 2 artifacts across 3 targets (0 updated, 2 unchanged)
note: scoped to build/agents/BuildLexer.psy; nothing was pruned, and 2 combined
documents left untouched

Two things are deliberately suspended while a build is scoped:

  • Nothing is pruned. Every other file's artifacts were not regenerated, so they must not be mistaken for stale.
  • Combined documents are left untouched. An index, or a mode: "single" document, summarises the whole program. Regenerating one from a subset would silently discard everything else.

Only a bare psy build may prune. A scoped build compiles a subset of the program, so it fundamentally cannot tell a stale artifact from one it simply did not produce this time, and a tool that deletes files on a guess is a tool you stop trusting.

psy inspect

The one you will use most once things get deep.

psy inspect                        # every module, keyword, constant and spec
psy inspect BuildLexer             # one spec, resolved, with provenance
psy inspect BuildLexer.prompt      # one property
BuildLexer (build/agents/BuildLexer.psy)
  abstract: false
  keyword: agent
  precedence: ...#BuildLexer > ...#BuildPsy > ...#Agent
FlagMeaning
--astprint the AST as JSON
--irprint the resolved IR as JSON
--jsonmachine-readable output, including source ranges
psy inspect --ast spec/framework.psy
psy inspect --ir > ir.json
psy inspect BuildPsy --json

Formatting

psy format
psy format --check

The formatter prints from the AST, which means its output is by construction syntactically valid Psy with the same meaning. A file that fails to parse is left alone rather than mangled.

Three guarantees:

  • Canonical. One input meaning, one output form.
  • Deterministic. No dependence on iteration order, paths, or the clock.
  • Idempotent. Formatting formatted output changes nothing. The test suite asserts this over every fixture, over the whole language specification, and over the project's own build definitions.

What it keeps

  • Comments — including comments written inside multiline string blocks.
  • String structure: relative indentation inside a block, and blank lines within it.
  • Logical blank lines between declarations and between block operations. Runs of blank lines collapse to one.
  • Your choice between inline and block collections. tags: [a, b] stays inline; an indented list stays indented.

That last one was not free to implement and I would do it again. A formatter that reflows ports: [80, 443] into four lines is a formatter people turn off.

What it normalizes

  • Indentation, to the configured width.
  • One space after : and after a list -.
  • Inline lists as [a, b]; inline objects as { strict: true, retries: 3 }.
  • Import shape: specifiers sorted by imported name, kept on one line when they fit, otherwise one per line with a trailing comma.
  • Exactly one trailing newline at end of file.
format: {
    indent: 4,          // spaces per level
    printWidth: 100,    // width at which import lists break
    trailingCommas: true,
}

psy format --check writes nothing, lists every file that is not already canonical, and exits non-zero.

Linting

psy lint

Lint findings are deliberately a different species from semantic errors:

  • A semantic error means the program has no meaning. It always fails the build, carries no rule name, and cannot be configured away.
  • A lint finding is a style or hygiene observation. It always carries a rule name, defaults to warning, and is yours to configure.

I keep those apart because a linter you can silence is useful, and a type checker you can silence is a lie.

RuleCodeDetects
noUnusedImportsPSY5001a name is imported but never used
noUnusedSpecsPSY5002a spec is neither exported nor referenced
noUnusedConstsPSY5003a constant is neither exported nor referenced
noUnusedUsePSY5007a use activates keywords the module never writes
specNamingPSY5004a spec or constant name is not PascalCase
propertyNamingPSY5005a property name is not camelCase
keywordNamingPSY5006a declaration keyword is not lowercase kebab-case

A name counts as used when it appears in a value, an extends clause, a type expression, an interpolation, or a re-export. Exporting a declaration is enough to keep the unused rules quiet — noUnusedSpecs and noUnusedConsts only fire for things that are neither exported nor referenced.

lint: {
    noUnusedImports: true,     // on, at its default severity (warning)
    noUnusedSpecs: "error",    // promoted
    propertyNaming: "off",     // disabled
    keywordNaming: false,      // also disabled
}

Accepted: true, false, "off", "info", "warning", "error".

diagnostics: {
    warningsAsErrors: true,
    maxWarnings: 0,
}

psy lint exits non-zero when any finding is an error, or when that policy makes warnings fail.

When something goes wrong

Every diagnostic has a stable code, a severity, a message, a file, line, column and range, and related locations where they help:

spec/syntax/values.psy:26:9 error PSY4001: `ScalarValues.syntax` must be `string`
but resolved to an object.
    spec/framework.psy:21:5: `syntax` is declared here

They are sorted by file, then source position, then code, so output is stable across runs — which matters more than it sounds like, because it means you can diff two runs.

RangeArea
PSY1xxxlexical
PSY2xxxsyntactic
PSY3xxxsemantic
PSY4xxxtypes and composition
PSY5xxxlint
PSY6xxxframeworks
PSY9xxxtooling

Codes are stable across versions. Search one and you will find the same thing next year. The full table is in the reference.

psy.config.ts

import { defineConfig } from "@psy/config";
import { morphic } from "@psy/morphic";
import { claude } from "@psy/adapter-claude";
import { json } from "@psy/adapter-data";

export default defineConfig({
    source: ["./src"],

    frameworks: [morphic()],

    targets: [
        claude({ output: ".claude" }),
        json({ output: "generated", mode: "single", file: "spec.json" }),
    ],

    format: { indent: 4, printWidth: 100, trailingCommas: true },

    lint: { noUnusedImports: true, specNaming: true },

    diagnostics: { warningsAsErrors: true },
});

It controls sources, outputs, frameworks, adapters, formatting, linting, and diagnostic policy.

It deliberately cannot: inject symbols, activate declaration keywords, modify language semantics, or change the grammar. Those come from Psy source alone.

Which is the point. A .psy file means the same thing regardless of what the config file says. You can read one without first auditing a TypeScript file to find out what has been done to the language. psy.config.ts chooses what to compile and where to put it, and that is the whole of its authority.

Every field is in the reference.

Where it looks

psy.config.ts
psy.config.mts
psy.config.mjs
psy.config.js

In the project root, or at --config. TypeScript config files are imported directly; modern Node strips the types. With no config file at all, Psy compiles . with no frameworks and no targets — which is enough to check and format.

Overriding sources for one command

psy check spec
psy format --check build

A positional path overrides source for that command only. A positional argument that does not name an existing file or directory is treated as a command target instead — which is how psy inspect BuildPsy manages to take a spec name in the same position.

Next: Under The Hood

On this page