OptiCli 0.8.0

dotnet tool install --global OptiCli --version 0.8.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local OptiCli --version 0.8.0
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=OptiCli&version=0.8.0
                    
nuke :add-package OptiCli --version 0.8.0
                    

opticli

A command-line tool for reading and changing the content of an Optimizely CMS 12 site you develop on your own machine. It is built for AI coding agents (Claude Code and similar) as much as for people. It answers the questions you would otherwise answer with hand-written SQL or by clicking through the edit UI: what content exists, what a page contains, which C# class and view render it, where a block is used, what is unpublished. It can also make changes (set properties, add blocks to a ContentArea, create, translate, publish), as drafts by default.

opticli is an independent open-source project. It is not affiliated with or endorsed by Optimizely.

How it works

reads:   opticli ──fixed read-only SQL──▶ the site's CMS database
writes:  opticli ──HTTP on 127.0.0.1 + per-run token──▶ site agent inside your running site ──IContentRepository──▶ database
  • Reads query the site's SQL Server database directly with fixed queries. Nothing needs to be running. Content comes back with every property decoded (ContentAreas, local blocks, links, rich text). Content types come with their C# class files and Razor views, found by scanning the site's source.
  • Writes go through the CMS's own API, so versioning, validation, caches and events behave as they do in the edit UI. opticli serve starts the site's existing build output with the site agent injected at startup: a small assembly that adds a few HTTP endpoints under /_opticli/. The site's code and files are not changed.
  • Output is compact JSON when stdout is redirected (agents, pipes) and tables on a terminal. Every item has a ref you can pass back to another command.

In this README, "site agent" means that injected assembly. "Coding agent" means an AI assistant that runs opticli.

Requirements

  • The .NET 8 SDK or newer to install opticli. It runs on the newest .NET runtime installed.
  • An Optimizely CMS 12 site (EPiServer.CMS.AspNetCore 12.x) whose repository you have checked out.
  • Its database on SQL Server: a local instance, LocalDB, a container, or a remote development database such as Azure SQL. For Microsoft Entra ID authentication (Authentication=Active Directory Default), sign in with az login first.
  • For writes only:
    • The site must build and start locally in the Development environment, with whatever it needs to start (its own secrets and services).
    • The site must run on .NET 8 or newer, since the site agent is built for .NET 8. Reads work for any CMS 12 site.

opticli is developed on Linux. Paths and process handling for Windows and macOS are covered, but get less testing.

Install

Install opticli from NuGet as a global .NET tool:

dotnet tool install -g OptiCli
opticli --version

To update, run dotnet tool update -g OptiCli. CHANGELOG.md lists what changed in each release.

To install from source instead, build the package and install it from the output folder:

git clone https://github.com/epinova/opticli.git && cd opticli
dotnet pack src/OptiCli -c Release -o artifacts
dotnet tool install -g OptiCli --add-source ./artifacts

To update, run dotnet tool update -g OptiCli --add-source ./artifacts after a new pack. To run from source without installing: dotnet run --project src/OptiCli -- <command>.

Quick start

Run opticli from anywhere inside the site's repository. It walks up to the solution and picks the web project that references EPiServer.CMS.AspNetCore. It then finds that project's development database, as described under Which database.

opticli doctor                                      # what was found and where; does the database answer?
opticli types --kind page                           # page types and how many pages use each
opticli type ArticlePage                            # properties, C# class file, views
opticli resolve https://www.example.com/en/news/    # which content a URL shows
opticli get 123 --fields Heading,MainArea           # one item, decoded
opticli where-used 456                              # every page and block that references content 456

A change, end to end:

opticli serve                                       # start the site with the site agent (background, 30-60 s)
opticli set 123 Heading="New title" --dry-run       # validate; show before and after
opticli set 123 Heading="New title"                 # save a new draft version
opticli get 123 --version latest --fields Heading   # check the draft
opticli serve --stop

When stdout is redirected, every command prints a single JSON line:

{"ok":true,"data":{"ref":"123","type":"ArticlePage","name":"News","status":"published","url":"/en/news/",
 "properties":{"Heading":{"type":"String","value":"Hello"}}},"meta":{"source":"db","version":"0.8.0"}}

Using opticli with coding agents

opticli ships a Claude Code skill that tells an agent when to use opticli and how to use it safely. skill/reference.md has the details it needs for writing content.

opticli skill install          # into ~/.claude/skills/opticli/
opticli skill install --repo   # into <repository>/.claude/skills/opticli/, to share it through git

For other agents, add the skill text to their instruction file, e.g. opticli skill print >> AGENTS.md (and opticli skill print reference.md for the details). opticli doctor warns when an installed skill is older than the opticli you run. Run skill install again after updating opticli. It replaces a copy it installed itself, and asks for --force only when the files were edited since (or were installed by an opticli older than 0.4, which kept no record of them).

The skill sets these rules for agents:

  • Never publish or delete unless the user asked for it.
  • Dry-run first.
  • Start serve only when writing.
  • Never choose the development database for the user.

opticli enforces its own safety rules either way (see Safety model).

Which database

opticli works with the project's development database: the one the site uses when you run it locally in Development.

  1. opticli reads the connection string (EPiServerDB unless --connection-name says otherwise) from the site's Development configuration, in ASP.NET Core's order. The first source that has it wins:

    • launch profiles;
    • ConnectionStrings__EPiServerDB (or ConnectionStrings:EPiServerDB) exported in the shell, shown as source environment. A launch profile that sets the same variable replaces it when the site runs with that profile, so profiles come first;
    • user secrets;
    • appsettings.Development.json;
    • appsettings.json.

    If that database is local, opticli uses it without asking.

    User secrets are found by the project's UserSecretsId. opticli reads it, like TargetFramework and AssemblyName, from the project file and the nearest Directory.Build.props (and the files that one imports). The project file wins. $(Name) references to properties set earlier are expanded. Conditions are evaluated when they are simple comparisons or Exists(...), with Configuration taken as Debug. A value opticli can't expand is left as written (such a UserSecretsId reads no user secrets), a condition it can't evaluate is skipped, and doctor warns about both.

  2. Otherwise opticli asks once. That happens when:

    • the connection string points at a remote server such as Azure SQL;
    • several launch profiles (or exported variables) disagree;
    • only other environments' appsettings.{Env}.json files have one.

    On a terminal it shows a numbered list. Anywhere else, commands fail with needs_selection (exit 6), and error.details.choices lists the options, so a coding agent can ask the user and run opticli db use <id>.

  3. The choice is saved in the user config file (see Files) and holds until that setting changes. If the server or database at that source changes, opticli asks again.

opticli db list               # every connection string: id, server, database, local?, where it was found
opticli db use a71c3f         # make it the development database (no id on a terminal: pick from a list)
opticli db forget             # back to the default
opticli get 123 --db e02d9b   # another database, for one run (a remote one is flagged in meta.warnings)

When the database in use is remote, every response carries meta.database (server, name, local, development). A remote database that isn't the development one also adds a warning.

serve runs the site only against a local database or the chosen development database. It sets ConnectionStrings__<Name> to that database and removes other spellings of it inherited from the shell, so an exported one can't win over the pin. Against a remote one it turns off, for that run:

  • the site's scheduler;
  • automatic schema updates;
  • content type sync;
  • the remapping of Dynamic Data Store types whose properties changed.

So the CMS doesn't change a shared database just because a local build starts. serve also checks, before it starts the site:

  • EF Core migrations. A migration in the build that the database's __EFMigrationsHistory lacks would be applied by a site that migrates at startup, so serve refuses (exit 3) unless --allow-pending-migrations.
  • The CMS schema version. When the database's doesn't fit the build's EPiServer packages, the CMS won't start, and serve says which side to update. EPiServer.Framework 12.17 and later accept a schema one version newer; earlier ones (and a version opticli can't read) are taken not to.

Once the site answers, serve reports drift: what differs between the build and the database (see Shared databases).

Safety model

Rule Enforced by
A remote database is used only when the user chose it as the development database, or for one run with --db or --connection, with a warning on every response. Only literal loopback names (localhost, 127.0.0.1, ::1, ., (local)) and LocalDB count as local. A host name that resolves to 127.0.0.1 counts as remote. CLI, before connecting
The site started by serve is pinned to the database the CLI reads. It refuses to start against any remote database except the approved development one. There it runs without scheduler, schema updates, content type sync or store remapping, and serve refuses a build with EF Core migrations the database lacks. If the site turns hosting startups off in its code, so the pin can't run, it refuses to start. CLI + site agent at startup
The site agent answers only loopback callers that send the per-run token, and only in Development. The token is kept in a state file only your user can read. Site agent, per request
Writes create drafts; publishing needs --publish (or publish). Saves are attributed to the user opticli. CLI + site agent
A publish that would also put live changes someone else saved after the published version stops: on a terminal it shows who saved what and asks, elsewhere it fails with conflict (exit 5) listing them. --include-draft (a plan step's "includeDraft": true) confirms; publish --version <id> publishes that version as it is; --from published bases the change on the published version, leaving them out. Site agent
Against a shared database, writes stop while the build and the database differ (drift): on a terminal it shows the differences and asks, elsewhere it fails with drift (exit 5). --accept-drift <fingerprint> confirms; the fingerprint stops counting when the differences change. Site agent, CLI first
delete moves content to the recycle bin; nothing empties it. Site roots, start pages, asset roots and anything above them can't be moved or deleted. Site agent
Reads use fixed queries. sql accepts a single SELECT, refuses anything that writes, runs code, reaches another database or reads server-wide views, logs and traces (in sys, only the views that describe the database's own schema), and always runs in a rolled-back transaction. Personal-data tables (form submissions, users) need --include-personal-data. CLI
Passwords are never printed; doctor redacts connection strings. The exception is opticli env: it prints the per-run token, and with --include-connection the connection string too. CLI

Things these rules can't see:

  • A port-forward or tunnel on localhost,<port> to a remote server looks local to opticli.
  • So does a hosts-file entry or SQL client alias that maps a loopback name elsewhere.

Don't point opticli at such a connection unless you mean to write to what is behind it.

Shared databases

A remote development database is often shared: with other developers, and with the environment it belongs to. The database doesn't say which build or commit is deployed against it. So opticli checks whether the database matches what the local build expects, which is what matters before a write:

  • content types and properties in the code but not in the database, and the reverse (properties added in admin mode don't count);
  • a property's type or culture-specific setting, and renames a migration step hasn't applied yet;
  • EF Core migrations, Dynamic Data Store types and the CMS schema version.

Each difference says which side is ahead. local: your branch has changes that aren't deployed there (check out what is deployed, or deploy first). database: the environment runs newer code than your checkout (pull and build). unknown: they differ, and the database doesn't say which side changed. Required, display names, sort order, tabs and [AllowedTypes] aren't stored for a model: the running code decides them, so they never show as drift.

opticli drift lists the differences and doctor shows them. Once serve has reported drift, a short warning comes with every command that uses the database, and with serve --status. A site you start yourself with opticli env reports drift only through opticli drift and the write errors. While there are any differences, every write stops, not only those to a type that differs: the risk is the build as a whole (its event handlers and validators), not only its models. Reads keep working, since they don't run the site's code. Restart serve to compare again after a deploy or a pull.

What opticli can't turn off is the site's own startup code. Against a shared database, a local build still:

  • runs Database.Migrate() if the site calls it at startup (the reason for the migration check);
  • registers its scheduled jobs (new jobs get rows, changed ones are updated), even with the scheduler off;
  • creates the content root folders that add-ons register at startup (IContentRootService), and Dynamic Data Store stores when they are first used, if they don't exist yet;
  • runs initialization modules and other startup code that writes, such as a site that creates its own content.

So use a SQL login without DDL rights (no db_ddladmin or db_owner) for a shared database where you can. Schema changes then fail instead of reaching everyone. Some add-ons create or alter their own tables, views or stored procedures every time the site starts, and a site with those may not start with such a login. Check what your add-ons need first.

Commands

opticli <command> --help lists every option with an example, and a bare opticli shows an overview.

Reading

Command Answers
doctor project, connection string candidates, database, schema version, site agent, drift (against a shared database), installed skill
db list, db use, db forget the development database (see Which database)
sites, languages site definitions and hosts; language branches
types [--kind] [--unused] [--sort] content types with instance counts
type <name> properties (type, culture-specific, required, tab, order, source line, [AllowedTypes]), C# class file, views
allowed-in <type> which ContentArea/reference properties accept a type, from [AllowedTypes] in code
get <ref> [--lang] [--version] [--fields] [--expand] one item, typed and decoded (ContentAreas, local blocks, rich-text links)
tree, children, ancestors the content tree
find --type T [--where Prop=value] [--under] [--status] items of a type, filtered
search <text> [--in names\|strings\|all] names and text properties containing a string
where-used <ref> [--pages], where-used --type T ContentAreas, references, links and rich text pointing at an item (--pages: through nested blocks up to pages); --type: for every instance of a type
resolve <url>, url <ref> URL to content, and content to URL per language
versions <ref>, drafts [--since] [--by] [--kind] [--type] version history; unpublished changes
projects [<id>] projects, and the versions in one
blob <ref> where a media file lives on disk
drift what differs between the build and a shared database (needs serve; see Shared databases)
access <ref> who may read and edit an item: its access rights, and the ancestor they are inherited from
sql "<SELECT …>" anything else, read-only

A <ref> is a content id (123), a version (123_456), a content GUID, or a URL or path (/en/about/, https://host/en/about/). Content from a content provider, such as images from a DAM, shows up as 63__provider. You can pass that form back in property values, ContentArea items and links. The id before __ is local to one database; the GUID is the same in every environment. --lang <code> picks the language branch. The default is the master language, or the language the URL selects.

Writing (needs opticli serve)

These commands write:

  • set, create, area (add, remove or move ContentArea items), block create, upload and translate save a draft unless --publish is given. upload <file> adds a PDF, image or other file (up to 50 MB) as media, as the type the site maps its extension to, and prints where the file was stored. create doesn't take media types (that would be media without a file).
  • publish, unpublish (takes a published branch offline, as the edit UI's expiry does), discard (deletes one unpublished version; it can't be undone), move and delete (to the recycle bin). delete stops when other content references what it deletes, unless --ignore-references.
  • create, block create, upload and move put content only where it can go: pages below pages, blocks, media and folders in asset folders, and only where the parent type allows the type ([AvailableContentTypes] and admin mode's settings, as the CMS answers it).
  • access <ref> with --grant Role=Levels, --user Name=Levels, --revoke Name, --break-inheritance or --inherit changes one item's access rights. Children that inherit follow; nothing is applied to descendants. Access rights aren't versioned, so the output shows them before and after. The root, the recycle bin, start pages and asset roots are refused, and so is a change that leaves no role with Administer.
  • apply plan.json runs several operations validated together. Later operations can refer to an item an earlier one created as $id. A property value "@texts/body.html" is that file's text, as Prop=@file is on the command line. Files in a plan are relative to the plan file and must stay inside its folder. With "guidNamespace", what a plan creates gets the same GUIDs in every database, and apply --update-existing runs it again: existing content is updated (and moved back out of the recycle bin), and steps that are already done change nothing. A plan that stops halfway (a failure, Ctrl+C) still reports what it saved, with undo hints and details.partial: true.

Every write command takes --dry-run. Structured values use --values, e.g. --values '{"MainArea":[{"ref":"456"}]}'. set and area check that nobody saved a newer version in the meantime (exit 5 on a conflict). --base-version <id> pins the version the change is based on, and --force skips the check. --from published (or --from <version>) bases the change on that version instead of the latest, and the check stays: newer drafts are left out and stay as they are, and the output lists them (leftOut). A publish puts the whole version live. When someone else saved unpublished changes in it, opticli shows them and asks on a terminal; elsewhere it fails with conflict and details.reason: "pendingDraft", and --include-draft confirms. set ... --from published --publish publishes a change without them. Output after a publish names previouslyPublished, the version to publish again to go back. Content with an approval sequence isn't published directly (exit 3); --request-approval sends it for review instead, as the edit UI does. Against a shared database that differs from the build, writes stop with drift (exit 5) until --accept-drift <fingerprint> (apply --accept-drift for a whole plan); dry runs don't stop. skill/reference.md documents value syntax and the plan format.

serve and env

opticli serve runs the site's existing build output in Development on http://127.0.0.1:<port>, with the site agent injected, and returns once the site agent answers.

Option Default
--output <dll> the newest bin/{Debug,Release}/<tfm>/<AssemblyName>.dll (also below a <rid>/ folder, under the project's OutputPath/BaseOutputPath, or under artifacts/ with UseArtifactsOutput), or output from the user config
--port <n> port from the user config, else 5199, else the first free port up to 5299
--build off: opticli warns when sources (the site's or a referenced project's) are newer than the build, and --build runs dotnet build first
--timeout <s> 180 seconds to wait for the site to answer
--foreground off: the site runs in the background; with it, the site's output streams until Ctrl+C (to stderr when stdout is redirected, so stdout stays one JSON envelope)
--https off (or "https": true in the user config, which --https false overrides): also listen on https://localhost:<next free port> with the development certificate, printed as browseUrl, for sites that redirect to HTTPS. serve warns when a site does
--allow-pending-migrations off: against a shared database, serve refuses a build with EF Core migrations the database lacks

serve --status, serve --logs [--tail N] and serve --stop manage the running site:

  • --stop asks the site to shut down through the site agent, on every OS, and falls back to SIGTERM on Linux and macOS. A site that hasn't exited after 20 s is killed. On Windows that kill is all there is when the agent doesn't answer.
  • Each start writes a new log. --logs reads the latest from its end, and data.previous lists the two before it.
  • A state file opticli can't read is treated as stale: --status and --stop remove it, doctor reports it.
  • Two serve runs for the same project don't start two sites: the second waits for the first and reports its site.
  • Ctrl+C while serve waits for the site stops the site again.
  • A site that configures Kestrel:Endpoints ignores ASPNETCORE_URLS. serve and env then add opticli's address as one more endpoint (Kestrel__Endpoints__OptiCli__Url) and warn. If the site sets its addresses in code, a timeout names the addresses it listens on instead.

To run the site yourself (IDE, dotnet run, hot reload), opticli env prints the variables serve would set:

  • --format shell|powershell|dotenv|json|launchSettings picks the format. --json is the same as --format json.
  • --port picks the port.
  • OPTICLI_* variables that this run leaves out but the shell still exports (from an earlier opticli env) are set to empty, so they can't pin or approve another database.

The connection string is left out unless --include-connection is given. Without it the site uses its own configuration, which is still checked to be local (or the approved development database) and the same database the CLI reads.

Set the variables for the site process only, e.g. in a subshell:

(eval "$(opticli env)" && dotnet run --no-launch-profile)

DOTNET_STARTUP_HOOKS makes every .NET process started from a shell that exports it load the hook. Launch profiles are often committed, so keep the token out of git.

Variable Set by serve/env for the site Meaning
DOTNET_STARTUP_HOOKS always path to OptiCli.Agent.dll, which loads the site agent
OPTICLI_TOKEN always the per-run token the site agent requires
OPTICLI_DB serve, env --include-connection the connection string to pin the site to (read by the CLI, it is the same as --connection)
OPTICLI_CONNECTION_NAME when not EPiServerDB which connection string to pin
ConnectionStrings__<Name> serve, env --include-connection the same connection string, for code that reads it before the agent's pin; serve also removes other spellings of it (ConnectionStrings:<Name>, another case)
OPTICLI_REMOTE_DB against a remote development database the one remote database the site agent accepts; turns on shared-database mode
OPTICLI_DRIFT_FILE serve, against a remote development database what serve compared before the start (EF Core migrations, CMS schema version), for the site agent's drift report

How the injection works

serve starts dotnet <Site>.dll with DOTNET_STARTUP_HOOKS pointing at OptiCli.Agent.dll, which ships in the tool package under agent/. The startup hook makes the site agent's assembly resolvable and appends it to ASPNETCORE_HOSTINGSTARTUPASSEMBLIES. The hosting startup then:

  1. pins the connection string, added as the last configuration source and as a PostConfigure of the CMS's data access options;
  2. fails the start if the effective connection string is neither local nor the approved development database;
  3. maps /_opticli/v1/* ahead of the site's own middleware.

The site agent is compiled against EPiServer.CMS.Core 12.0 and binds to the site's own, newer CMS assemblies at runtime. It ships no copies of them.

Output and exit codes

  • Redirected stdout gets compact JSON: {"ok": true, "data": …, "meta": {"source", "version", "next", "warnings", "database"}}.
    • source is db for reads, agent for writes and cli for local commands.
    • database is present only when the database is remote.
  • A terminal gets tables. --json and --text force either.
  • --jsonl on list commands prints one item per line. A final {"meta": {…}} line follows only when there is a next page or warnings.
  • Lists return 50 items by default (sql: 100 rows). Pass meta.next as --cursor for the next page, or raise --limit. There is no total count.
  • Strings over 300 characters are cut (truncated: true, length: N); --full or --fields gives them whole. Dates are UTC (…Z).
  • Errors look like {"ok": false, "error": {"code", "message", "hint", "details"}}. The hint says what to do next.
Exit Code Meaning
0 ok
1 usage / internal bad arguments (or a bug in opticli)
2 not_found project, connection string, content, type or version not found
3 refused a safety rule blocked it
4 unreachable database or site agent not reachable, or a query failed on the server (a write that timed out may still have been saved)
5 conflict / validation / drift a newer version exists, a publish would include someone else's unpublished changes (details.reason: "pendingDraft"), the CMS rejected the values, or a write against a shared database that differs from the build wasn't confirmed (details is the drift report)
6 needs_selection the user must choose the development database first (error.details.choices)
130 cancelled interrupted (Ctrl+C); an apply reports what it saved until then

Files

What Linux / macOS Windows
User config $XDG_CONFIG_HOME/opticli/config.json (default ~/.config/opticli/config.json) %APPDATA%\opticli\config.json
serve state, start lock and logs (the last 3 runs) $XDG_STATE_HOME/opticli/ (default ~/.local/state/opticli/) %LOCALAPPDATA%\opticli\

You can set per-project defaults in the user config. opticli db use adds the chosen database there. opticli keeps the file's permissions when it rewrites it, and creates it readable by you only on Linux and macOS.

{"projects": {"/abs/path/to/Site": {"connection": "...", "output": "bin/Debug/net8.0/Site.dll", "port": 5199, "https": true}}}

Supported versions

  • Optimizely CMS 12 (EPiServer.CMS.AspNetCore 12.x): reads on any runtime; writes on a site running .NET 8 or newer.
  • SQL Server on the local machine (including LocalDB and containers), or a remote SQL Server / Azure SQL development database.
  • Not supported:
    • CMS 11, Commerce, Forms data and search indexes;
    • writes to environments other than development (test, staging, production).

Known limitations

  • Rules that live only in code are read from the C# sources, not evaluated:
    • [AllowedTypes] is parsed. Editor descriptors and metadata extenders that change allowed types at runtime are not; a uiHint flags the property.
    • Validation attributes are only pointed at (file and line).
    • URLs a site rewrites in code (custom segments, partial routing) show as the plain content path.
    • type lists views, but not controllers.
  • Content types are matched to C# classes by GUID or name with a lightweight source scan, not a compiler. Unusual declarations may be missed.
  • where-used works per item: there is no type-wide usage in one call.
  • serve runs the existing build output; it doesn't build unless --build is given.
  • Output field names may still change before 1.0.

Development

dotnet build opticli.slnx       # warnings are errors
dotnet test opticli.slnx

The EPiServer packages come from Optimizely's public NuGet feed, which nuget.config already lists.

Project What it is
src/OptiCli the opticli command line: commands, options, help text
src/OptiCli.Core everything the CLI does: discovery, connection resolution and safety, SQL readers, decoders, output, serve
src/OptiCli.Agent the site agent, loaded into the site process; referenced by nothing, copied into the tool package
src/OptiCli.Protocol request and response types shared by the CLI and the site agent (source-linked into both)
skill/ the coding-agent skill, embedded in opticli.dll
tests/ unit tests for Core and the site agent; OptiCli.Integration, a test against a real site

The unit tests use generic fixtures and need no database. The integration test is an oracle. It samples content across types, kinds and languages from a real site, reads each item both from the database and through the CMS (via the site agent), and compares them property by property. It is skipped unless a site is configured:

cd path/to/Site && opticli serve        # the site agent must be running
OPTICLI_IT_PROJECT=path/to/Site \
OPTICLI_IT_SAMPLE=200 \
OPTICLI_IT_REPORT=/tmp/oracle-report.md \
dotnet test tests/OptiCli.Integration
Variable Meaning
OPTICLI_IT_PROJECT site project directory (required; the test is skipped without it)
OPTICLI_IT_SAMPLE content branches to compare (default 200)
OPTICLI_IT_DRAFTS most recent drafts to compare as well (default 10)
OPTICLI_IT_SEED sampling seed (fixed by default, so runs repeat)
OPTICLI_IT_REPORT write the mismatch report here, with every mismatch as .jsonl next to it
OPTICLI_IT_PLAN a plan whose content is always compared, on top of the sample (the edge-case plan below)

Some differences the database can't reproduce by design, such as URL segments a site drops in code. Those are listed with the reason in tests/OptiCli.Integration/Comparison/KnownDifferences.cs; any other mismatch fails the run.

The edge-case site

A sample site lacks much of what real sites have: fetch-data pages, a site whose start page is under another site's, simple addresses on several sites, culture-specific properties in shared and local blocks, personalized ContentAreas, an approval sequence, language fallback settings, and a media type for PDF files. tests/fixtures/edge-cases/ builds them from an Alloy site (dotnet new epi-alloy-mvc) without changing it. setup.sh copies the site and its database, adds EdgeCasesFixture.cs (the extra content types, plus a startup module for what a plan can't create), and applies edge-cases.plan.json. Run it again to update the content: the plan is applied with --update-existing, and the database copy is kept unless FRESH=1.

SQLCMDPASSWORD=... tests/fixtures/edge-cases/setup.sh path/to/Alloy path/to/AlloyEdge alloy alloy-edge
OPTICLI_IT_PROJECT=path/to/AlloyEdge \
OPTICLI_IT_PLAN=tests/fixtures/edge-cases/edge-cases.plan.json \
dotnet test tests/OptiCli.Integration

The integration tests run one at a time, since the write tests make and remove scratch content on the same site.

drift.sh in the same folder runs the shared-database scenario on copies of the edge-case site and its database. It reaches the copy through a host name instead of a loopback name (<hostname>.localhost resolves to the loopback address, but counts as remote), so serve runs in shared mode. It then changes the copy's code and checks each step: nothing differs, local ahead, the database ahead, and an EF Core migration that serve refuses. Against that copy, DriftTests checks that writes stop on drift until its fingerprint confirms it.

SQLCMDPASSWORD=... tests/fixtures/edge-cases/drift.sh path/to/AlloyEdge path/to/AlloyDrift

CI runs the unit tests on Linux, Windows and macOS for every push and pull request. Issues and pull requests are welcome. Please run the unit tests before sending a change. When a change touches reads, also run the integration test against a site you have.

Releasing

Set the new version as <Version> in Directory.Build.props and as opticli-version in skill/SKILL.md, and add the release to CHANGELOG.md. Commit, then push a matching tag:

git tag v0.8.0 && git push origin v0.8.0

The release workflow checks that the tag matches both versions and is on main, runs the unit tests, checks that the package holds the site agent and starts, and publishes it to nuget.org.

Licence

Mozilla Public License 2.0.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.8.0 48 10/2/2026
0.7.1 39 10/2/2026
0.7.0 39 10/2/2026
0.6.0 46 10/1/2026
0.5.0 43 10/1/2026
0.4.1 55 9/30/2026
0.4.0 35 9/30/2026
0.3.0 44 9/30/2026