@neocompose/cli
v0.31.1
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.
Format-4 working copy
neo/
neo.json # formatVersion: 4
Project.neo # project defaults/settings
Root.neo # protected root shape; editable root values
Classes/
Interfaces/
Enums/
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.
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(localizable: true, searchKey: true)
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, @locked, @hidden,
and @relations carry cross-cutting behavior. Never author @system; it is
reserved for platform-owned system records. @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, unique: true)
@column(member: Slug, width: 158, frozen: true)
@column(member: Notes, hidden: true)
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 recompiles changed NeoScript and schema-affected callers at the
trusted server boundary and atomically commits semantic record changes,
localized main-locale edits, and staged files under CAS. Normal push sends the
semantic diff plus a deterministic source hash; neo push --dry-run performs
the complete source-to-record rehearsal. Client AST, compiled IR, placement
stamps, and derived links are not trusted.
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 --reporter=json --outputFile=.neo/test-results.jsonThe JSON output file is written atomically. Human and JSON reports break timing into total, test-code, and framework-startup durations; human output also gives shared startup its own row instead of charging it to the first test.
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 | Authenticate the selected profile. |
| whoami | 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, names, 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.
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
Normal pull preserves source filenames and symbols by stable record ID. Force and reset pulls always derive readable dialogue names from authoritative records. To deliberately refresh those names during a normal merge, run:
neo pull --regenerate-source-names
neo status
neo push --dry-runCustom linked values prefer a non-empty string member named Name, using
PascalCase at dialogue scope and camelCase inside nodes.
The reset 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.
