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 inspectSix commands. Diagnostics go to standard error, results go to standard output, and a non-zero exit code means it failed. Nothing surprising.
| Global option | Meaning |
|---|---|
--root <dir> | project root (default: the current directory) |
--config <file> | path to psy.config.ts |
-h, --help | show help |
-v, --version | show 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 askedWith 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.
| Option | Meaning |
|---|---|
--template <name> | choose without prompting |
--list | print the available templates and exit |
--yes | accept the default (minimal) |
--force | write 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.
| Template | What you get |
|---|---|
minimal | a single Psy module; no config, no framework |
config | layered configuration: inheritance, composition, references |
framework | your own declaration keywords, defined with abstract specs |
morphic | Psy-defined AI skills and agents, generated into .claude/ |
claude-plugin | a Claude Code plugin: agents, skills and slash commands |
language-spec | describe 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 specRuns 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 --checkCompiles 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.psybuild: 2 artifacts across 3 targets (0 updated, 2 unchanged)
note: scoped to build/agents/BuildLexer.psy; nothing was pruned, and 2 combined
documents left untouchedTwo 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 propertyBuildLexer (build/agents/BuildLexer.psy)
abstract: false
keyword: agent
precedence: ...#BuildLexer > ...#BuildPsy > ...#Agent| Flag | Meaning |
|---|---|
--ast | print the AST as JSON |
--ir | print the resolved IR as JSON |
--json | machine-readable output, including source ranges |
psy inspect --ast spec/framework.psy
psy inspect --ir > ir.json
psy inspect BuildPsy --jsonFormatting
psy format
psy format --checkThe 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 lintLint 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.
| Rule | Code | Detects |
|---|---|---|
noUnusedImports | PSY5001 | a name is imported but never used |
noUnusedSpecs | PSY5002 | a spec is neither exported nor referenced |
noUnusedConsts | PSY5003 | a constant is neither exported nor referenced |
noUnusedUse | PSY5007 | a use activates keywords the module never writes |
specNaming | PSY5004 | a spec or constant name is not PascalCase |
propertyNaming | PSY5005 | a property name is not camelCase |
keywordNaming | PSY5006 | a 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 hereThey 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.
| Range | Area |
|---|---|
PSY1xxx | lexical |
PSY2xxx | syntactic |
PSY3xxx | semantic |
PSY4xxx | types and composition |
PSY5xxx | lint |
PSY6xxx | frameworks |
PSY9xxx | tooling |
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.jsIn 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 buildA 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