@neocompose/cli
v0.67.4
Published
Neo Compose native project-source CLI with bidirectional sync.
Readme
neo — Neo Compose project-source CLI
neo synchronizes a Git-friendly Neo Compose project working copy with the
web editor. Working-copy format 4 is Neo-native: .neo defines the project
model and authored values, .neoflow defines dialogue flows, and managed image
and audio files stay as ordinary binaries.
The current agent authoring guide is in
cli/skills/neocompose-cli/SKILL.md.
Feature specs in
neo-compose-specs/new-features
document design intent and edge cases; the released compiler, contract, and
tests are the executable source of truth.
Source architecture
The CLI source separates authoring, document projection, and synchronization:
project-source/ parse, lower, and emit .neo/.neoflow plus managed binaries
project-manifest/ semantic bridge between authored source and project documents
project-sync/ diff, merge, conflict, migration, and CAS state handlingThe flow is project source -> ProjectSchemaManifest -> project documents ->
sync. ProjectSchemaManifest carries an independently versioned serialized
contract so source syntax and server documents can evolve without coupling
their version numbers. The current workspace format is still format 4; the
manifest's numeric contract version is not a second working-copy format.
Dependencies point downward: project-source may use both lower layers, while
project-sync never imports source-authoring or manifest code.
Run
npm i -g @neocompose/cli # published package; installs `neo` on PATH
node cli/bin/neo.mjs <command> # repository developmentSet up a working copy with explicit IDs in automation:
neo login --profile editor
neo init --project <id> --version <id> --dir neoInteractive terminals may select the project, version, and directory. Neo
does not install, discover, or require .NET. neo doctor validates the
format-4 browser-safe compiler, VS Code analysis contract, tracked source, and
managed-file capabilities. It also fails on malformed records in the
last-pulled document.
Format-4 working copy
neo/
neo.json # formatVersion: 4
Project.neo # project defaults/settings
Root.neo # protected root shape; editable root values
Classes/{System/, ...}
Interfaces/{System/, ...}
Enums/{System/, ...}
Relations.neo # generic project relations, when present
Templates/
Localization.neo
LocalizationStatuses/
Files/
Images.neo # ImageRegistry Images
AudioClips.neo # AudioClipRegistry AudioClips
Images/ # tracked image bytes
AudioClips/ # tracked audio bytes
DialogueGroups/
Dialogues/*.neoflow
Migrations/*.neo
**/*.spec.neo # colocated local-only NeoScript tests
.neo/
state.json # private merge/CAS bases; never hand-edit
conflicts/
test-build/ # local test compilation metadataProduction .neo, .neoflow, and supported managed binaries are tracked.
Colocated .spec.neo files are test-only: they never enter status, source
identity, pull/reset, or push. .neo/ is ignored private state. There is no schema .csproj, C# authoring
tree, Roslyn compiler, generated authoring DLL, per-function sidecar tree, or
checked-in dialogue JSON.
Only formatVersion: 4 is accepted. Format 4 is a clean break; it does not
read or convert a format-3 C# checkout. Push intended changes with the older
CLI first, preserve any local source you need, then reconstruct from the
authoritative server:
neo init --project <id> --version <id> --dir neoFor an existing format-4 workspace, neo pull --reset regenerates canonical
source and managed binaries. Normal neo pull performs a stable-ID-aware
three-way merge, preserves source placement when possible, and uses a durable
transaction cursor to fetch only records changed since the previous sync.
Undo/reset metadata invalidates that cursor and safely falls back to a full
snapshot.
In Unity projects, neo.json may use unityConfigPath instead of storing
projectId and versionId. The referenced NeoComposeConfig.asset then owns
those IDs and receives branch/version switches.
Organize source locally
Move .neo and .neoflow files into any project folder, and group declarations
in files as you prefer. Keep existing IDs. Pull, force, and reset read your
current placement before applying server state. New server declarations use
the default folders. System declarations use a System subfolder, such as
Classes/System, Enums/System, or Interfaces/System. Ownership comes from
the reserved system identity, so project types keep their normal defaults.
Managed images and audio can also move. Update their typed registry paths.
Extract large Root or static initializers into typed declarations without imports:
// Configuration/ProjectSortingLayers.neo
List<NeoSortingLayer> SORTING_LAYERS = [
new NeoSortingLayer() { Name = "Default" }
];
// Inside the Root Assets initializer
SortingLayers = SORTING_LAYERSExisting nested @id annotations stay with the extracted initializer. The
consuming member keeps its value ID; no ID is required at the use. An aggregate
has one owning assignment. Additional references use Reference(NAME) where
the member supports references. Independent copies need fresh nested IDs;
the editor's duplicate action removes the old IDs. Scalars may have several
owners. If web edits make those values differ, pull inlines the differing uses.
The shared language service offers extract-to-file for a selected complete
initializer, inline for a sole use, and duplicate. These declarations are local
project setup and do not create runtime globals. Use them as complete value
initializers; using their names inside executable expressions such as
BASE + 1 is rejected. Initializer cycles and ambiguous
aggregate ownership fail validation before synchronization. Fix source errors
before resetting a checkout with extracted initializers so their layout can be
recovered safely.
Migrations can move too. Keep their /// @migration header and /// @order N
directive, or retain the numeric filename prefix for ordering.
Native Neo authoring
Classes, interfaces, and enums are top-level declarations. Schema functions, getters, and setters live inline and compile as NeoScript; they do not have separate source files.
@id("enum-item-rarity-id")
enum ItemRarity {
@id("rarity-common-id")
Common = "Common text",
}
@id("class-inventory-item-id")
@storage(allowed: .Immutable)
abstract class InventoryItem<
@id("inventory-context-generic-id")
TContext extends SomeClass
> {
@id("item-name-id")
@settings(searchBy: .MemberKey)
virtual string Name = "";
@id("item-rarity-id")
abstract ItemRarity Rarity;
@id("item-stack-size-id")
@settings(min: 1, max: 999)
int StackSize = 1;
@id("item-display-name-id")
string DisplayName {
get {
return $"{Name} ({Rarity})";
}
}
@id("item-use-id")
abstract void Use(TContext context);
}The source identifier is the persisted name. @settings and @storage are
typed by their declaration context; invalid fields and enum cases are
diagnostics. Swift-like .EnumCase syntax works whenever the expected enum
type is known. Focused annotations such as @id, @visibility, and
@relations carry cross-cutting behavior. Never author @system; it is
reserved for platform-owned system records. @visibility(.Hidden) marks a
class the member selector and classes tree leave out; absent means visible.
Lists take repeatable @index and @column annotations naming a field on the
entry class. An index requires a non-localizable string or a single-select
enum; a column may also target the reserved __other__ key, which carries the
default layout for every field a subclass adds.
@id("outpost-list-id")
@settings(kind: .Unordered)
@index(member: Slug, kind: .Unique)
@column(member: Slug, width: 158, pin: .Leading)
@column(member: Notes, visibility: .Hidden)
public List<Outpost> Outposts = [];Stable IDs preserve identity across rename, reorder, and file moves. Omit an
@id only to create something. A successful real push assigns the ID and
rewrites source; dry-run, validation failure, upload failure, and CAS failure
leave source byte-for-byte unchanged. Collection item identity uses the same
native annotation:
Tags = [
@id("story-tag-item-id")
"story",
];Root values and class-owned values
Root.neo contains the compiler-owned Root root = new() { ... } envelope.
Its member names, types, IDs, lock annotations, and storage settings are
protected, while the root.Assets, root.Save, and root.Session value
initializers are editable.
Reusable values are stored static members on ordinary project classes. There
is no ValueRegistry or generated Values namespace.
@id("assets-class-id")
@storage(allowed: .Immutable)
class Assets {
@id("capitol-member-id")
static Outpost Capitol = new {
Name = "Capitol",
Image = Images.Capitol.Slice(0),
};
@id("home-getter-id")
static Outpost Home {
get {
return Assets.Capitol;
}
}
}The member ID anchors the stored binding; do not add a second ID to the static initializer. Nested class and collection rows remain independently identified.
Defaults materialize once when a value is created. Existing instances do not stay linked to later class-default changes. Logical lookup/dialogue references use the native typed intrinsic:
Reference(Assets.Capitol)
Reference(root.Assets.Cosmetics.Pants)
Reference<PantsAsset>(key: "pants.long")
Reference<Dialogue>(id: "dialogue-id")Project.neo, typed texture/audio templates, localization configuration and
statuses, priority groups/options, dialogue groups, and direct relations use
the same top-level typed declaration grammar. The compiler derives structural
row identities, storage placement, generic stamps, localized-text links, and
other persistence details that are not authorable source.
Files
Images and audio are declared in typed registries and referenced directly:
ImageRegistry Images = new() {
@id("sword-image-file-id")
@settings(template: PixelArt)
NeoImage Sword = new("Files/Images/Sword.png");
}
AudioClipRegistry AudioClips = new() {
@id("sword-hit-audio-file-id")
@settings(template: SoundEffect)
NeoAudioClip SwordHit = new("Files/AudioClips/SwordHit.wav");
}Use Images.Sword.Slice(0) for a sprite and AudioClips.SwordHit for an audio
member. Dropping a supported binary into Files/Images/ or
Files/AudioClips/ creates a provisional symbol; status/diff report it, and a
successful push writes the explicit declaration and assigned ID. neo files
add can create that pending declaration before push.
Pull and push compare server-verified SHA-256 digests. Divergent local/remote
bytes keep the local file and place the verified remote side under
.neo/conflicts/files/<file-id>/ until explicitly resolved.
NeoFlow dialogue authoring
Each .neoflow file contains exactly one sealed, non-generic class directly
derived from the system Dialogue type and exactly one required trigger
override. Dialogue functions and lexical value bindings are ordinary typed
Neo code. Main-locale prose is inline; other locales and workflow metadata
remain in localized-text records.
Graph constructors use NeoFlow-only trailing bodies. => Node and a final
return Node; are equivalent destinations; there is no authorable to:
argument. Terminal text/actions nodes may omit a destination, while triggers,
options, and outcomes require one.
@id("capitol-dialogue-id")
@settings(name: "Capitol: cold boot", saveOptionChoices: true)
sealed class CapitolColdBoot : Dialogue {
@primary Outpost capitol = Assets.Capitol;
@id("can-start-function-id")
bool CanStart() {
return capitol.Name == "Capitol";
}
@id("capitol-trigger-id")
override Trigger Trigger = new(
group: CapitolDialogues.High,
when: [
@id("can-start-condition-use-id")
CanStart,
]
) => Welcome;
@id("welcome-node-id")
Text Welcome = new(name: "Welcome!") {
"""
Welcome to {capitol.Name}.
"""
@id("continue-option-id")
Option Continue = new() {
"""
Tell me more.
"""
return Finish;
}
}
@id("finish-node-id")
Text Finish = new() {
"""
Until next time.
"""
}
}Persisted condition uses, action invocations, mutations, pauses, options, and
outcomes each have their own stable @id. References made through lexical
bindings and transitive function calls derive the same linked values as the
web editor. Run neo dialogue dryrun <ref> after dialogue edits to traverse
option paths against a fresh save and catch runtime storage-ownership errors.
Synchronization workflow
neo pull
# edit .neo, .neoflow, and managed binaries
neo status
neo diff
neo dialogue dryrun <ref>
neo push --dry-run
neo pushA pull followed immediately by status or push dry-run must be a semantic no-op. If it reports authoring changes, stop and report a round-trip bug rather than pushing.
Pull merges source concepts and collections by stable identity, never by
index. Conflicts are explicit and block compilation. Edit source to the
desired result, or use neo resolve --mine|--theirs as a whole-side
convenience. There is no force-CAS bypass. Accept a required compatibility
bump only after reviewing it with neo push --accept-bump.
One push prepares its commit locally: it recompiles changed NeoScript and
schema-affected callers, materializes values, and validates the whole graph
against the pulled head. The server checks that head, each change's CAS base,
authorization, and row shapes, then atomically commits semantic record changes,
localized main-locale edits, and staged files. It accepts preparation only from
a CLI on its own NeoScript compiler revision. neo push --dry-run runs the
same preparation plus the complete source-to-record rehearsal.
Create classes, stored values, file declarations, and dialogues by editing their tracked .neo or .neoflow source directly. Add managed binaries under Files/Images/ or Files/AudioClips/; neo push discovers and stages them with the source transaction. The CLI intentionally has no parallel scaffold or low-level content-write commands.
NeoScript unit tests
Place *.spec.neo files anywhere beside project source. They see the complete
locally prepared production graph—including unpushed declarations, values, and
function bodies—plus a test-only typed prelude. Production source never sees
test globals.
describe("Player.TakeDamage", () => {
test("rejects negative damage", () => {
var player = new Player();
expect(() => { player.TakeDamage(-1); }).toThrow("negative");
});
});Run all tests, narrow by file/directory, or filter the full test name with a JavaScript regular expression:
neo test
neo test Classes/Player.spec.neo
neo test -t "TakeDamage"
neo test --maxWorkers=4
neo test --runInBand
neo test --reporter=json --outputFile=.neo/test-results.jsonSpec files run in parallel with up to four available CPU workers by default.
Set test.maxWorkers in neo.json or override it with --maxWorkers <positive
integer>. --runInBand overrides both and runs serially. A single selected
file runs in the main process. Hooks and tests within each file remain ordered;
fixtures and mocks stay isolated. Reports keep file and test registration order.
The JSON output file is written atomically. Reports distinguish elapsed startup
and execution from summed test work, which can exceed elapsed time in parallel
runs. JSON timings.workerStartupMs measures worker initialization and
timings.executeMs measures elapsed execution.
Tests compile and validate the local candidate once. They omit the extra source
serialization and re-lowering rehearsal used by neo push --dry-run; production
syntax, type checks, and whole-graph validation remain required. Spec compilation
shares one immutable compiler context and schema index across cache misses.
Test compilation metadata and the experimental verified prepared-project cache
live in .neo/test-build/; neither they nor test files are server records.
Production source, managed files, configuration, workspace state, CLI version,
and compiler revision invalidate the prepared project, while editing only a
.spec.neo file reuses it. Missing, corrupt, or checksum-mismatched cache
entries are rebuilt. A workspace must have been initialized or pulled once so
.neo/state.json contains the offline project facts, but running tests
performs no login or network request.
Native functions cannot execute in the local evaluator and therefore fail
closed unless mocked. Use mock.native(Member), mock.function(Member), or
spyOn(Member), then configure returns,
returnsOnce, throws, throwsOnce, doesNothing, or implementation.
Mocks and call history are isolated per test.
To gate pushes, add either or both commands to neo.json:
{
"prePushHook": "neo test",
"prePushDryRunHook": "neo test --reporter=json --outputFile=.neo/test-results.json"
}The real-push hook is intentionally not reused for dry runs: arbitrary hook
commands may have side effects. Each applicable hook runs only after local
candidate validation and before authentication/network activity; a non-zero
exit aborts. neo push --no-verify is an explicit local bypass, not a server
authorization boundary.
Commands
| Command | Purpose |
| ----------------------------------------------------- | ------------------------------------------------------------------------- |
| --version | Print the installed CLI package version. |
| login [--api <url>] | Authenticate the selected profile. |
| whoami [--api <url>] | Inspect the selected profile. |
| logout [--api <url>] | Delete the stored credential for one API origin and revoke its session. |
| init --project <id> [--version <id>] | Create a format-4 working copy and perform its first reset pull. |
| doctor | Validate format/compiler/editor/source/file contracts; no .NET discovery. |
| pull [--force\|--reset] [--regenerate-source-names] | Merge remote changes or regenerate canonical source and binaries. |
| status [--json] | Compile source and summarize semantic records and upload intent. |
| diff [--json] | Show semantic records, spans, digests, and upload intent. |
| push [--dry-run] [--accept-bump] [--no-verify] | Validate or commit one atomic compare-and-swap transaction. |
| test [file-or-dir ...] [-t pattern] | Compile the unpushed local candidate and run *.spec.neo tests. |
| dev [--push] | Watch remote/local changes; pushing remains opt-in. |
| resolve --mine\|--theirs | Resolve all current source/binary conflicts with one side. |
| script check\|compile\|eval | Compile or preview NeoScript with project context; never commit writes. |
| dialogue dryrun <ref> | Simulate a locally authored dialogue candidate. |
| loc | Inspect or edit localization data not represented in main-locale source. |
| migrate new\|list\|check\|run\|prune | Author and manage tracked NeoScript migrations. |
| branch | Manage copy-on-write branches. |
| merge | Merge branch changes. |
| release | Cut, publish, archive, or restore releases. |
| channel | Manage release channels. |
| history | Inspect and deliberately rewrite retained history. |
| export unity | Export the runtime Unity projection. |
With the deliberate exception of neo loc, content authoring goes through
tracked native source and neo push. Localization commands can mutate
authoritative localization records that .neo does not fully represent; run
neo pull immediately afterward before further source edits or push.
login, whoami, and logout use the current workspace's apiBaseUrl when
run beneath a neo.json. An explicit --api takes priority. Outside a
workspace, they use the CLI build's default API origin. Login prints the
selected origin before it requests or stores a credential.
Tokens use the OS credential store when available and a protected local file
only as fallback. CI can use NEO_COMPOSE_TOKEN or --token-stdin. The
editor profile cannot publish releases; server authorization remains the
security boundary.
Regenerating dialogue source names
Every pull, including --force and --reset, keeps the dialogue source names
the server stores. To replace generated defaults such as Value_1a2b3c4d5e6f,
NeoFunction_…, or Conditions1 with names derived from the editor, run:
neo pull --regenerate-source-names
neo status
neo push --dry-runNames you chose are kept. When a binding is renamed, code that refers to it is
rewritten to match. Custom linked values prefer a non-empty string member named
Name, using PascalCase at dialogue scope and camelCase inside nodes.
The pull leaves the regenerated names as local changes. IDs are unchanged, and
nothing is written to the server until neo push succeeds.
Packaging
node cli/build.mjs bundles the Node orchestrator and browser-safe Neo
language/compiler/editor assets. The npm package contains no C# SDK, Roslyn
application, DLL, or .NET runtime metadata. Package checks reject those
artifacts and verify that the shared compiler assets and skill are present.
Useful checks while developing:
npm run typecheck
npx vitest run cli
npm --prefix cli run test:packageRun the repository's full verification suite before release, package and
exercise the VS Code extension, verify the Hello World and live neowyn
format-4 no-op workflows, and run npm run doctor last.
