diff --git a/.vscode/settings.json b/.vscode/settings.json index ef70830..ba6c66b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,12 +58,16 @@ "Autofac", "automap", "Bevhavior", + "Bookshop", "Brotli", "Bson", "Buildx", "camelcase", "causations", "changesets", + "CLAUDECODE", + "compdef", + "compinit", "Cratis", "Cupertino", "datatable", @@ -85,6 +89,7 @@ "façade", "Finalizers", "fluentui", + "Forsgren", "Froms", "Gridlines", "Grpc", @@ -97,6 +102,7 @@ "Insurtech", "Intrinsics", "IPII", + "Kleppmann", "maxcpucount", "mermaidchart", "Meziantou", @@ -107,12 +113,15 @@ "Newtonsoft", "notnull", "nupkg", + "Nygard", "Offboarded", "Onboarded", "openiddict", "OTEL", "Otlp", "overlaypanel", + "palettegen", + "paletteuse", "PII", "Pkcs", "primeicons", @@ -142,6 +151,7 @@ "treetable", "tseslint", "tsyringe", + "ttyd", "typeparam", "typewind", "Unmap", diff --git a/CHRONICLE.md b/CHRONICLE.md index fb5efa9..33239bf 100644 --- a/CHRONICLE.md +++ b/CHRONICLE.md @@ -1,6 +1,6 @@ # Chronicle CLI Reference -Generated by `cratis init` (CLI v1.0.0.0). For the full machine-readable capability descriptor, run `cratis llm-context`. +Generated by `cratis init` (CLI v1.0.0.0). Run `cratis init --refresh` to update. ## What is Chronicle? @@ -13,73 +13,44 @@ The `cratis` CLI connects to a Chronicle server to inspect and manage event stor The CLI resolves the server connection in this order: 1. `--server` flag 2. `CHRONICLE_CONNECTION_STRING` environment variable -3. Active context in `~/.cratis/config.json` +3. Active context in `/.cratis/config.json` — run `cratis context path` to see the resolved path on your system 4. Default: `chronicle://localhost:35000` ```bash -# Set up a named context +# Create a named context cratis context create dev --server chronicle://localhost:35000 cratis context set dev ``` -## Command Overview - -| Command Group | Key Commands | -|---|---| -| `event-stores` | list | -| `namespaces` | list | -| `event-types` | list, show | -| `events` | get, count (tail), has | -| `observers` | list, show, replay, replay-partition, retry-partition, clear-quarantine | -| `failed-partitions` | list, show | -| `recommendations` | list, perform, ignore | -| `identities` | list | -| `projections` | list, show | -| `read-models` | list, instances, get, occurrences, snapshots | -| `auth` | status, login, logout | -| `users` | list, add, remove | -| `applications` | list, add, remove, rotate-secret | -| `context` | list, create, set, show, delete, rename | -| `config` | show, set, path | -| (top-level) | version, update, llm-context, init | - -## Global Flags - -| Flag | Description | -|---|---| -| `--server ` | Chronicle server connection string | -| `-o, --output ` | Output format: table (rich terminal), plain (tab-separated), json, json-compact | -| `-q, --quiet` | Output only key identifiers, one per line | -| `-y, --yes` | Skip confirmation prompts | -| `-e, --event-store ` | Event store name (default: default) | -| `-n, --namespace ` | Namespace (default: Default) | - ## Output Format Guidance -- Use `--output plain` for list commands (up to 34x fewer tokens than JSON). +- Use `--output plain` for list commands that return large payloads — roughly 4-5x fewer tokens than `json`, and the gap widens with row count because JSON repeats every field name on every row. - Use `--output json-compact` when you need structured data with fewer tokens than `json`. -- Use `--quiet` to get only IDs for piping: `cratis observers list -q | xargs -I {} cratis observers replay {} -y` -- JSON errors include a machine-parseable `error` code alongside the `message`. +- Use `--quiet` (`-q`) to get only IDs for piping: `cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y` +- Use `--yes` (`-y`) to skip confirmation prompts in scripts and automation. +- JSON errors include a machine-parseable `error` code alongside the human-readable `message` field. ## Troubleshooting Decision Tree ### Cannot connect to server 1. Run `cratis version -o json` — check if server version appears. -2. Check connection string: `cratis config show -o json`. -3. Verify server is running and reachable on the configured port. +2. Run `cratis chronicle diagnose -o json` — one round trip covering connectivity, observers, failed partitions and recommendations. +3. Check the resolved connection: `cratis context show -o json`. +4. Verify the server is running and reachable on the configured port. ### Observer stuck or behind -1. Run `cratis observers list -o plain` — check observer state and sequence numbers. -2. Run `cratis observers show -o json` — inspect detailed state. -3. Check failed partitions: `cratis failed-partitions list -o plain`. -4. If a partition is failing, inspect: `cratis failed-partitions show `. -5. Fix the underlying issue, then retry: `cratis observers retry-partition -y`. -6. To replay from scratch: `cratis observers replay -y`. +1. Run `cratis chronicle observers list -o plain` — check observer state and sequence numbers. + A `Next#` behind the log tail is not by itself a problem: an observer only advances over event types it subscribes to. +2. Run `cratis chronicle observers show -o json` — inspect detailed state. +3. Check failed partitions: `cratis chronicle failed-partitions list -o plain`. +4. If a partition is failing, inspect: `cratis chronicle failed-partitions show `. +5. Fix the underlying issue, then retry: `cratis chronicle observers retry-partition -y`. +6. To replay from scratch: `cratis chronicle observers replay -y`. ### Data not appearing in read model -1. Verify events exist: `cratis events has `. -2. Check the projection observer is running: `cratis observers list --type projection -o plain`. -3. Check read model instances: `cratis read-models instances `. +1. Verify events exist: `cratis chronicle events get --event-source-id -o plain`. +2. Check the projection observer is running: `cratis chronicle observers list --type projection -o plain`. +3. Check read model instances: `cratis chronicle read-models instances `. 4. Check for failed partitions on the projection observer. ## Safety Notes @@ -88,7 +59,10 @@ cratis context set dev - Use `--yes` only in automation with proper safeguards. - Remove commands (users, applications) are irreversible. -## Further Reading +## Command Reference + +The full command catalog is available as an AI skill — load it on demand when you need exact command names, options, or arguments: -- Full machine-readable descriptor: `cratis llm-context` -- CLI version and compatibility: `cratis version -o json` +- **GitHub Copilot**: invoke the `chronicle-cli` skill +- **Claude Code**: run the `/chronicle-cli` slash command +- **Any tool**: run `cratis llm-context` for a live JSON dump, or `cratis llm-context --schema` for the JSON Schema diff --git a/README.md b/README.md index adca920..0c37ed8 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,516 @@
- Cratis + Cratis

Cratis CLI

-

- The official command-line tool for managing and exploring Chronicle event stores. -
- Explore the docs » -
-
- Report a Bug -  ·  - Request a Feature -  ·  - Join the Discord -

+

A terminal window into a running Chronicle event store — and into why its read models are wrong.

- - Discord - - - NuGet - + NuGet + Release + Platform + Discord + License

+ + cratis diagnose reporting one failed partition, the exception that caused it, then the workbench opening on the same event store + + One command for the verdict, one for the exception behind it, then the live view.
+ The reactor could not reach its mail server; the CLI names the observer, the partition and the event.
--- -## Installation +## When the read model is wrong + +An event-sourced system splits two things that used to live together: **what the state is**, +and **why it is that way**. Events append to a log. Observers — projections, reducers, reactors +— consume that log and derive the read models your application queries. + +That split is the point. It is also what makes debugging strange, because when a read model +looks wrong the answer is in neither place on its own. The database holds the derived state but +not the reason. The log holds the reason but not the derived state. And in between sits an +observer that may be working, may be behind, or may have stopped four events ago — and **an +observer that has stopped consuming looks exactly like an observer with nothing to do.** -### macOS (Homebrew) +Chronicle ships a browser Workbench that answers this. But it is served by the server, behind +the server's auth, and you are on a box you reached over SSH. -```shell +`cratis` is that view from a terminal: + +``` +❯ cratis chronicle diagnose + +── Chronicle Diagnostics 10:15:16 ───────────────────────────────────────────── + server: chronicle://chronicle-dev-client:***@localhost:35100/ + event store: Bookshop / Default + + ✓ Connection connected + ✓ Server version 16.7.0 + ✓ Event stores 2 stores: System, Bookshop + ✓ Observers 6 active 3 disconnected + ✗ Failed partitions 1 need attention → cratis chronicle failed-partitions list + ✓ Recommendations none + ✓ Event sequence tail: 22 + + ✗ Issues detected — review items above +``` + +Every failing line prints the command that investigates it. `diagnose` exits non-zero when it +finds something, so it also works as a health check in a script. + +Chronicle's default port is 35000; the throwaway server these recordings run against sits +on 35100 so it cannot collide with a real one. + +## Install + + + + + + +
Homebrew
macOS · Linux
+ +```bash brew tap cratis/cratis brew install cratis ``` -To upgrade: +
Binary
no toolchain
-```shell -brew upgrade cratis +```bash +V=$(curl -s https://api.github.com/repos/Cratis/cli/releases/latest | grep -m1 '"tag_name"' | cut -d'"' -f4) +curl -sSLo cratis.tar.gz "https://github.com/Cratis/cli/releases/download/$V/cratis-${V#v}-osx-arm64.tar.gz" +tar -xzf cratis.tar.gz && sudo mv cratis /usr/local/bin/ ``` -### Linux +Release assets are named `cratis--.tar.gz` for `osx-arm64`, `osx-x64`, +`linux-arm64` and `linux-x64` — swap the last part. The version is in the filename, which is +why the URL is built from the tag rather than pointing at `latest/download`. -Download and install the pre-built native binary from the [latest release](https://github.com/Cratis/cli/releases/latest): +
.NET tool
any platform, .NET 10+
-```shell -# x64 (Intel/AMD) -curl -Lo cratis.tar.gz https://github.com/Cratis/cli/releases/latest/download/cratis-linux-x64.tar.gz -# arm64 -# curl -Lo cratis.tar.gz https://github.com/Cratis/cli/releases/latest/download/cratis-linux-arm64.tar.gz -tar -xzf cratis.tar.gz -sudo mv cratis /usr/local/bin/cratis +```bash +dotnet tool install -g Cratis.Cli ``` -To upgrade, repeat the steps above with the new release. +
Completions
after installing
-Native release artifact names: +```bash +cratis completions install # detects bash, zsh, fish or powershell +``` -| Platform | x64 | arm64 | -| --- | --- | --- | -| macOS | `cratis--osx-x64.tar.gz` | `cratis--osx-arm64.tar.gz` | -| Linux | `cratis--linux-x64.tar.gz` | `cratis--linux-arm64.tar.gz` | +
-### .NET Global Tool +> [!NOTE] +> The native binaries are self-contained — about 38 MB compressed, 104 MB on disk — so they +> need no .NET installed. The `dotnet tool` package is a fraction of that and needs the +> runtime you already have. -The CLI is also distributed as a [.NET global tool](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) and requires [.NET 10+](https://dotnet.microsoft.com/download). +Then point it at a server and check: -```shell -dotnet tool install -g Cratis.Cli +```bash +cratis context create dev --server chronicle://localhost:35000 +cratis context set dev +cratis chronicle diagnose ``` -Verify the installation: +Against a local Chronicle there is nothing to configure. The first run writes a `default` +context pointing at `chronicle://localhost:35000`, and the first `chronicle` command asks +which event store to make the default and remembers the answer. + +## Use + +```bash +cratis chronicle diagnose # whole-server verdict, non-zero exit when unhealthy +cratis chronicle diagnose --watch # the same report, refreshing +cratis chronicle workbench # full-screen live dashboard + +cratis chronicle event-stores list # what stores exist on this server +cratis chronicle namespaces list # namespaces inside the active store +cratis chronicle events get --from 100 # raw events off an event sequence +cratis chronicle events tail # the highest sequence number in use +cratis chronicle event-types list # registered types, with generations +cratis chronicle event-types show # the JSON Schema for one + +cratis chronicle observers list # every observer, its state and its position +cratis chronicle observers show # one observer in detail +cratis chronicle observers replay # reprocess from sequence zero +cratis chronicle failed-partitions list # partitions that have stopped +cratis chronicle failed-partitions show # the exception, per attempt +cratis chronicle observers retry-partition + +cratis chronicle projections list # projection declarations +cratis chronicle read-models list # read model definitions +cratis chronicle read-models instances # the projected state itself +cratis chronicle jobs list # replays, migrations, retries + +cratis context list # configured servers +cratis init # teach this project's AI tools about the store +``` -```shell -cratis --version +`--help` works on every group and every command. `cratis llm-context` prints the whole +surface as JSON. + +### Output formats + +`-o` takes `table`, `plain`, `json` or `json-compact`, and `-q` prints identifiers only. +Measured on one store of 23 events and 9 observers: + +| | `events get` | `observers list` | `event-types list` | +|---|---|---|---| +| `-o plain` | 2,133 B | 794 B | 561 B | +| `-o json-compact` | 8,250 B | 1,701 B | 1,656 B | +| `-o json` | 9,966 B | 2,110 B | 2,505 B | +| `-q` | 59 B | 403 B | 310 B | + +`plain` is tab-separated and about 4.5× smaller than `json` here — the gap widens with row +count, because JSON repeats every field name on every row. `-q` exists to be piped: + +```bash +cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y ``` -### Shell Completions +## Reading an observer row -Install tab completions for your shell: +This is the table you will spend the most time in, and two of its columns are easy to +misread: -```shell -cratis completions install # auto-detects bash / zsh / fish +``` +Id Type State Quarantined Next# LastHandled# Subscribed +Bookshop.Members Reducer Disconnected False 3 2 False +Bookshop.Books Reducer Disconnected False 11 10 False +Bookshop.OverdueNotices Reactor Disconnected False 22 21 False +Bookshop.BorrowedBooks Projection Active False 19 18 False +Bookshop.OverdueBooks Projection Active False 23 22 False ``` -Restart your shell or source your profile to activate completions. +| Column | What it means | +|---|---| +| `LastHandled#` | the last event this observer finished processing | +| `Next#` | the next sequence number it will look at | +| `State` | `Active`, `Replaying`, `Suspended`, `Disconnected`, `Quarantined` or `Unknown` | +| `Subscribed` | whether a client is currently attached to it | ---- +> [!NOTE] +> **A `Next#` behind the log tail is not by itself a problem.** An observer only advances over +> events it subscribes to. Above, the tail is 22 and `BorrowedBooks` sits at 19 — events 19 +> through 22 are reservations and overdue markings, none of which it observes. It is fully +> caught up. This is why `diagnose` reports failed partitions rather than sequence lag: lag is +> ambiguous, a failed partition is not. -## Getting Started +`Disconnected` means no client is attached — usually the application is not running. +It is the normal state for a store whose application is stopped, and it is not an error. -The `get-started` command is your entry point. Run it right after installation — it checks your configuration, tests the server connection, and shows you the most useful commands to explore. +## Following a failure to the event that caused it -```shell -cratis get-started +A partition is one event source's slice of an observer. When processing throws, Chronicle +stops **that partition** and leaves the rest of the observer running, so one bad entity does +not halt everything. `failed-partitions show` prints what happened, per attempt: + +``` +FailedPartition: 3447a2eb-5dc8-41d5-ab80-30948951dd44 +Observer: Bookshop.OverdueNotices +Partition: 00000014-1111-4222-8333-444444444444 +Attempts: 4 + + --- Attempt at 2026-07-27T23:46:06.9840000+00:00 (Seq# 22) --- + Exception has been thrown by the target of an invocation. + smtp.bookshop.local: connection refused + ... + --- Attempt at 2026-07-27T23:46:21.4580000+00:00 (Seq# 22) --- ``` -If no context is configured yet, the command will walk you through the setup: +Four attempts, and the gaps between them widen — 2 seconds, then 4, then 8. Chronicle backs +off and keeps retrying on its own, so a partition that failed on something transient +recovers without you. `retry-partition` exists for the other case: you have fixed the cause +and do not want to wait for the next attempt. -``` -╭─ Getting Started ──────────────────────────────────────────╮ -│ │ -│ No configuration found. Set up a context to get started: │ -│ │ -│ 1. Create a context pointing at your server: │ -│ cratis context create dev \ │ -│ --server chronicle://localhost:35000 │ -│ │ -│ 2. Verify the connection: │ -│ cratis get-started │ -│ │ -│ 3. Start exploring: │ -│ cratis chronicle event-stores list │ -│ │ -╰────────────────────────────────────────────────────────────╯ +```bash +cratis chronicle observers retry-partition Bookshop.OverdueNotices 00000014-…-444444444444 -y ``` -Once a context is configured, `get-started` shows your active context, connection status, and a curated list of commands to explore and debug your event store. +> [!WARNING] +> `observers replay ` is the bigger hammer: it reprocesses that observer from sequence +> zero and rebuilds its read model. Nothing is lost — events are immutable and replay is what +> they are for — but on a large store it is neither instant nor free. Reach for +> `retry-partition` first; it reprocesses one partition rather than the whole log. -### Setting Up a Context +## The workbench -A context stores a named connection to a Chronicle server: +`cratis chronicle workbench` opens a full-screen dashboard over the same connection: fifteen +views, refreshing on an interval, with the actions available in place — `R` replays the +selected observer, `T` retries a failed partition, `S` and `U` stop and resume jobs. -```shell -# Create a context for local development -cratis context create dev \ - --server chronicle://localhost:35000 +`F` filters whatever view you are on, and it reopens on the view you left it on. -# Make it the active context -cratis context set dev +`Ctrl+P` is the part worth knowing about. It searches **every kind of artifact at once**: + +
+typing one word into the workbench command palette and matching an observer, an event type, a projection, a read model and a failure at the same time +
+ +One word, six matches, five kinds — the reactor and the projection's observer, the event +type they both read, the projection declaration, the read model it writes, and the failure the +reactor left behind. Picking one jumps to its view with the filter already applied. -# Confirm everything is wired up -cratis get-started +That breadth is the reason it is a palette and not a search box. "Overdue" is not a name you +look up in one list; it is a thread running through five of them, and following it is what +you were actually doing. + +| Key | | +|---|---| +| `F` | filter the current view | +| `Ctrl+P` | search every artifact kind | +| `↑ ↓` | move within the sidebar or the table | +| `← →` | put focus on the sidebar / on the content | +| `Home` / `Shift+G` | first row / last row | +| `[` `]` | previous / next page | +| `R` | replay the selected observer | +| `T` / `P` | retry / replay the selected failed partition | +| `S` / `U` | stop / resume the selected job | +| `A` / `I` | apply / ignore the selected recommendation | +| `D` / `V` | event type definition / the observers that read it | +| `Enter` | open the read model detail (Read Models view) | +| `Ctrl+B` | collapse the sidebar | +| `Ctrl+\` | toggle the detail pane | +| `Ctrl+E` / `Ctrl+N` | switch event store / namespace | +| `Ctrl+C` | copy the open detail | +| `F9` `F10` `F11` | themes | +| `?` | help | +| `Q` | quit | + +Destructive actions go through a confirmation dialog rather than a status-bar prompt, so a +refresh landing mid-question cannot leave you answering something that is no longer on +screen. + +## The interesting part: it knows when a machine is reading it + +Run `cratis chronicle observers list` in a terminal and you get a bordered table. Pipe it into +a file and you get JSON. Run it from inside Claude Code, Cursor or Windsurf and you get +compact JSON, with no banner and no update notice. + +Nothing about the command changed. The CLI resolved the format from its surroundings: + +| Surroundings | Format | Why | +|---|---|---| +| a terminal | `table` | a person is reading it | +| output redirected | `json` | something is parsing it | +| `NO_COLOR` set | `plain` | [an explicit request](https://no-color.org/) for no decoration | +| an agent environment | `json-compact` | a model is reading it, and pays per token | + +The last row is the one that needed a decision. Agents are a real caller now, and they had +been getting the human output — tables drawn with box-drawing characters, spent on a reader +that cannot see them. Detection is a handful of environment variables (`CLAUDECODE`, +`CURSOR_TRACE_DIR`, `WINDSURF_SESSION_ID` and friends), and it suppresses the banner and the +update hint too, because a startup notice in an agent's transcript is noise it has to reason +about. `CRATIS_NO_UPDATE_CHECK=1` turns that check off for everyone else — a cron job's log is +not the place to be told about a new release either. + +Compact JSON rather than `plain` is deliberate even though `plain` is smaller. Tab-separated +output loses nesting and asks the reader to remember what column four was; JSON names every +field. Where `plain` wins by a lot, the machine-readable command catalog says so explicitly +rather than making it the default. + +That catalog is the other half: + +```bash +cratis init # writes CHRONICLE.md, wires up Claude / Copilot / Cursor / Windsurf +cratis llm-context # the whole command surface as JSON, ~50 KB ``` ---- +`init` detects which tools a project already uses rather than assuming, and `llm-context` +carries per-command descriptions, options, examples and output-format advice — so an agent +can work out that a stuck observer means `failed-partitions show` without being told. -## Common Commands +## Tab completion asks the server -```shell -# Explore the event store -cratis chronicle event-stores list -cratis chronicle namespaces list -cratis chronicle events get +`cratis completions install` writes a completion script for bash, zsh, fish or PowerShell. +It is not a static word list: -# Inspect observers and read models -cratis chronicle observers list -cratis chronicle projections list -cratis chronicle read-models instances +
+pressing tab after cratis chronicle observers replay and getting the nine observer ids registered on the live server +
-# Diagnose problems -cratis chronicle diagnose -cratis chronicle failed-partitions list -cratis chronicle observers replay +Completing an observer id shells back into the CLI, which connects and returns what that +server has registered right now. Typing narrows to the two that match. + +Observers, event stores, event types, projections, read models, jobs, recommendations, +subscriptions, applications and users all complete this way — and context names, which come +from your config rather than the server. Completion failures are swallowed and return nothing, +so a server that is down costs you a tab press rather than a broken shell. + +## Contexts + +A context is a named server profile. `cratis context create staging --server …` then +`cratis context set staging`, and every subsequent command follows it. + +The connection string is resolved in a fixed order, first match winning: + +| | | +|---|---| +| 1 | `--server` on the command | +| 2 | `CHRONICLE_CONNECTION_STRING` | +| 3 | the active context in `~/.cratis/config.json` | +| 4 | `chronicle://localhost:35000` | + +
+Credentials, and why they are composed in separately + +
+ +Credentials are not part of that chain. Whatever connection string wins, the CLI then checks +whether it already carries authentication — embedded `user:pass@`, or an `apiKey=` parameter +— and leaves it alone if so. Otherwise it composes in, in order: a cached token from +`cratis chronicle login`, then the context's client id and secret, then Chronicle's +well-known development credentials. + +Keeping the two separate is what makes `--server` useful. You can point a command at a +different host for one invocation without restating the credentials, and a connection string +you paste from somewhere else is used exactly as given rather than being quietly rewritten. + +Connection strings are redacted to `user:***@host` wherever the CLI prints them. + +
+ +
+Event store and namespace + +
+ +`-e/--event-store` and `-n/--namespace` follow the same shape, defaulting to the context and +then to `default` / `Default`. The first `chronicle` command against a server whose event +store is unknown asks which one to use and remembers the answer; `cratis context set-value +event-store ` changes it later. + +
+ +## Beyond Chronicle + +
+cratis arc — introspect a running Cratis Arc application + +
+ +```bash +cratis arc commands list --url http://localhost:5000 +cratis arc queries list +``` -# Manage contexts -cratis context list -cratis context show +Lists the command and query endpoints an Arc application has registered, with routes, +namespaces and documentation. Talks HTTP to the application rather than gRPC to Chronicle, so +`--url` and `ARC_URL` apply instead of `--server`. + +
+ +
+cratis prologue — turn a running system into a Screenplay + +
+ +```bash +cratis prologue start # wizard, writes cratis-prologue.json +cratis prologue interpret # reads the captures, writes a .play ``` -Use `--help` on any command or subgroup for the full option reference: +[Prologue](https://github.com/Cratis/Prologue) observes an ordinary system — HTTP commands, +database changes, OpenTelemetry — and `interpret` turns those captures into a Cratis +Screenplay. Heuristics build the structure; a language model configured through +`cratis llm use` refines the naming and asks about the decisions it is unsure of. + +
+ +
+cratis run — boot a Screenplay in a local sandbox -```shell -cratis --help -cratis chronicle observers --help +
+ +```bash +cratis run # every .play in this folder +cratis run ./screenplays --port 9191 ``` -### Output Formats +Runs the `.play` files in a folder in a local Stage container. Needs Docker on the `PATH`. -Every command supports `-o` / `--output` to control the format: +
-| Flag | Output | -|---|---| -| `-o table` | Rich terminal table (default) | -| `-o plain` | Tab-separated — great for scripting | -| `-o json` | Pretty-printed JSON | -| `-o json-compact` | Compact JSON — fewer tokens for AI tools | +
+cratis llm — configure the model those tools use -```shell -# Pipe quiet output for scripting -cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y +
+ +```bash +cratis llm use anthropic --api-key … +cratis llm show # api keys are masked +cratis llm clear ``` ---- +Stored in the `llm` section of `~/.cratis/config.json`. `anthropic`, `openai` and `local` +(any OpenAI-compatible endpoint) are supported. -## AI Tool Integration +
-Run `cratis init` in your project root to generate a `CHRONICLE.md` file that gives Claude, Copilot, and Cursor a machine-readable overview of your Chronicle setup: +## Platforms -```shell -cratis init +| | macOS | Linux | Windows | +|---|---|---|---| +| Homebrew | ✅ | ✅ | — | +| Native binary | `osx-arm64`, `osx-x64` | `linux-arm64`, `linux-x64` | — | +| .NET global tool | ✅ | ✅ | ✅ | +| Completion script | bash, zsh, fish | bash, zsh, fish | PowerShell | + +> [!NOTE] +> **There is no native Windows binary.** A release publishes four: macOS and Linux, arm64 and +> x64. Windows goes through `dotnet tool install -g Cratis.Cli`, which needs .NET 10 — +> nothing in the CLI is platform-specific, but CI builds and tests on Ubuntu and macOS only, +> so Windows is untested rather than unsupported. + +## Development + +```bash +dotnet build -c Release # 5 projects, zero warnings +dotnet test -c Release # 237 unit specs, 161 integration specs +./install.sh # pack and install the local build as a global tool ``` -For a full machine-readable capability descriptor, run: +Integration specs run the CLI against a real Chronicle server in a container, so Docker has to +be running for those. + +Specs follow the convention used across the Cratis codebases — `for_` names what is +under specification, `when_` names the situation, and each `should_` +observes one thing, so a failure reads as a sentence. + +### The GIFs + +They are scripted, not screen-captured, and re-render from a clean checkout: -```shell -cratis llm-context +```bash +dotnet build -c Release +assets/demo-store/reset.sh # a throwaway Chronicle server with a story seeded into it +vhs assets/demo.tape # or palette, completions ``` ---- +[`assets/RECORDING.md`](assets/RECORDING.md) covers how they were made, what the fixture +contains and why each clip earns its place. + +### Releasing + +There is no version to bump. Label a pull request `major`, `minor` or `patch` and merging it +cuts the release: [cratis/release-action](https://github.com/cratis/release-action) works out +the next semantic version and tags it, and the workflows publish the NuGet package, build the +four native binaries and push the Homebrew formula. A merge with none of those labels releases +nothing. ## Support -| Channel | Details | +| | | |---|---| -| 💬 **Discord** | Join the community on [Discord](https://discord.gg/kt4AMpV8WV) | -| 🐛 **GitHub Issues** | [Report bugs or request features](https://github.com/cratis/chronicle/issues) | -| 📚 **Documentation** | [https://cratis.io](https://cratis.io) | - ---- +| 💬 | [Discord](https://discord.gg/kt4AMpV8WV) | +| 🐛 | [Issues](https://github.com/Cratis/cli/issues) | +| 📚 | [cratis.io](https://cratis.io) | ## License -Distributed under the **MIT License**. See [`LICENSE`](./LICENSE) for full details. +MIT. See [`LICENSE`](./LICENSE). diff --git a/Source/Cli.Specs/for_InitCommand/when_generating_chronicle_md/and_referencing_chronicle_commands.cs b/Source/Cli.Specs/for_InitCommand/when_generating_chronicle_md/and_referencing_chronicle_commands.cs new file mode 100644 index 0000000..68998f5 --- /dev/null +++ b/Source/Cli.Specs/for_InitCommand/when_generating_chronicle_md/and_referencing_chronicle_commands.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.RegularExpressions; + +namespace Cratis.Cli.for_InitCommand.when_generating_chronicle_md; + +/// +/// The generated document is read by AI agents as ground truth, so a command written without +/// its branch is worse than no guidance at all — the agent runs it and gets "Unknown command". +/// Every Chronicle sub-branch lives under `cratis chronicle`, and this catches any that lose it. +/// +public partial class and_referencing_chronicle_commands : Specification +{ + static readonly string[] _chronicleBranches = + [ + "event-stores", "namespaces", "event-types", "events", "observers", "subscriptions", + "failed-partitions", "recommendations", "jobs", "identities", "projections", + "read-models", "users", "applications", "diagnose", "workbench" + ]; + + string _content; + List _unbranched; + + [GeneratedRegex("cratis (?[a-z][a-z-]*)", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex InvocationRegex { get; } + + void Because() + { + _content = ChronicleDocGenerator.Generate(); + _unbranched = [.. InvocationRegex + .Matches(_content) + .Select(m => m.Groups["rest"].Value) + .Where(rest => _chronicleBranches.Contains(rest, StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal)]; + } + + [Fact] void should_not_reference_any_chronicle_command_without_its_branch() => _unbranched.ShouldBeEmpty(); + [Fact] void should_not_reference_a_config_command_group() => _content.ShouldNotContain("cratis config "); + [Fact] void should_point_at_diagnose_for_connection_problems() => _content.ShouldContain("cratis chronicle diagnose"); +} diff --git a/Source/Cli.Specs/for_UpdateChecker/when_checking_with_the_check_disabled.cs b/Source/Cli.Specs/for_UpdateChecker/when_checking_with_the_check_disabled.cs new file mode 100644 index 0000000..809c8cd --- /dev/null +++ b/Source/Cli.Specs/for_UpdateChecker/when_checking_with_the_check_disabled.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_UpdateChecker; + +public class when_checking_with_the_check_disabled : Specification +{ + string? _original; + string? _result; + + void Establish() + { + _original = Environment.GetEnvironmentVariable(UpdateChecker.DisableEnvVar); + Environment.SetEnvironmentVariable(UpdateChecker.DisableEnvVar, "1"); + } + + async Task Because() => _result = await UpdateChecker.CheckForUpdate("Cratis.Cli", "0.0.1"); + + void Destroy() => Environment.SetEnvironmentVariable(UpdateChecker.DisableEnvVar, _original); + + [Fact] void should_not_report_an_update_even_though_the_current_version_is_ancient() => _result.ShouldBeNull(); + [Fact] void should_report_itself_as_disabled() => UpdateChecker.IsDisabled().ShouldBeTrue(); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs index 5e5a350..c587e4d 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs @@ -7,7 +7,7 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// Interactive TUI workbench — a live-updating dashboard with full drill-down navigation and in-place /// actions for observers, failed partitions, jobs, recommendations, event log, event types, and projections. /// -[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with number keys 1–9 to switch tabs. Not suitable for scripting.")] +[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate the sidebar with arrow keys, F filters the current view, Ctrl+P searches every artifact kind, Q quits. Requires an interactive terminal and table output — not suitable for scripting.")] [CliCommand("workbench", "Open the interactive Chronicle workbench (live TUI dashboard)", Branch = typeof(ChronicleBranch))] [CliExample("chronicle", "workbench")] [CliExample("chronicle", "workbench", "--interval", "10")] diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs index 8e47178..e951406 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs @@ -145,7 +145,7 @@ public void UpdateData(WorkbenchData data) if (data.FailedPartitions.Count > 0) { - attentionLines.Add($"[{dan}]⚠[/] [bold]{data.FailedPartitions.Count}[/] failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")} [{mut}]→ press 3[/]"); + attentionLines.Add($"[{dan}]⚠[/] [bold]{data.FailedPartitions.Count}[/] failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")} [{mut}]→ Failures[/]"); } else { @@ -154,7 +154,7 @@ public void UpdateData(WorkbenchData data) if (data.Recommendations.Count > 0) { - attentionLines.Add($"[{war}]![/] [bold]{data.Recommendations.Count}[/] pending recommendation{(data.Recommendations.Count == 1 ? string.Empty : "s")} [{mut}]→ press 5[/]"); + attentionLines.Add($"[{war}]![/] [bold]{data.Recommendations.Count}[/] pending recommendation{(data.Recommendations.Count == 1 ? string.Empty : "s")} [{mut}]→ Recommendations[/]"); } else { diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs index e6da66b..0d93895 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs @@ -123,10 +123,13 @@ public Window Build() // Move focus to the nav view so arrow keys, action keys, and shortcuts work immediately. _window.FocusControl(navView); - if (state.LastNavIndex > 0 && state.LastNavIndex < _views.Length) - { - _navigation.NavigateTo(state.LastNavIndex); - } + // NavigateTo is what selects a nav item and renders its view into the content pane, so it + // has to run even when the restored index is 0. Guarding on `> 0` left a first-ever session + // — or any session last closed on Overview — showing an empty pane until the first keypress. + var initialIndex = state.LastNavIndex >= 0 && state.LastNavIndex < _views.Length + ? state.LastNavIndex + : 0; + _navigation.NavigateTo(initialIndex); return builtWindow; } diff --git a/Source/Cli/Commands/Init/ChronicleDocGenerator.cs b/Source/Cli/Commands/Init/ChronicleDocGenerator.cs index c62d141..aef27f9 100644 --- a/Source/Cli/Commands/Init/ChronicleDocGenerator.cs +++ b/Source/Cli/Commands/Init/ChronicleDocGenerator.cs @@ -45,9 +45,9 @@ public static string Generate() .AppendLine() .AppendLine("## Output Format Guidance") .AppendLine() - .AppendLine("- Use `--output plain` for list commands that return large payloads (up to 34x fewer tokens than JSON).") + .AppendLine("- Use `--output plain` for list commands that return large payloads — roughly 4-5x fewer tokens than `json`, and the gap widens with row count because JSON repeats every field name on every row.") .AppendLine("- Use `--output json-compact` when you need structured data with fewer tokens than `json`.") - .AppendLine("- Use `--quiet` (`-q`) to get only IDs for piping: `cratis observers list -q | xargs -I {} cratis observers replay {} -y`") + .AppendLine("- Use `--quiet` (`-q`) to get only IDs for piping: `cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y`") .AppendLine("- Use `--yes` (`-y`) to skip confirmation prompts in scripts and automation.") .AppendLine("- JSON errors include a machine-parseable `error` code alongside the human-readable `message` field.") .AppendLine() @@ -55,21 +55,23 @@ public static string Generate() .AppendLine() .AppendLine("### Cannot connect to server") .AppendLine("1. Run `cratis version -o json` — check if server version appears.") - .AppendLine("2. Check connection string: `cratis config show -o json`.") - .AppendLine("3. Verify server is running and reachable on the configured port.") + .AppendLine("2. Run `cratis chronicle diagnose -o json` — one round trip covering connectivity, observers, failed partitions and recommendations.") + .AppendLine("3. Check the resolved connection: `cratis context show -o json`.") + .AppendLine("4. Verify the server is running and reachable on the configured port.") .AppendLine() .AppendLine("### Observer stuck or behind") - .AppendLine("1. Run `cratis observers list -o plain` — check observer state and sequence numbers.") - .AppendLine("2. Run `cratis observers show -o json` — inspect detailed state.") - .AppendLine("3. Check failed partitions: `cratis failed-partitions list -o plain`.") - .AppendLine("4. If a partition is failing, inspect: `cratis failed-partitions show `.") - .AppendLine("5. Fix the underlying issue, then retry: `cratis observers retry-partition -y`.") - .AppendLine("6. To replay from scratch: `cratis observers replay -y`.") + .AppendLine("1. Run `cratis chronicle observers list -o plain` — check observer state and sequence numbers.") + .AppendLine(" A `Next#` behind the log tail is not by itself a problem: an observer only advances over event types it subscribes to.") + .AppendLine("2. Run `cratis chronicle observers show -o json` — inspect detailed state.") + .AppendLine("3. Check failed partitions: `cratis chronicle failed-partitions list -o plain`.") + .AppendLine("4. If a partition is failing, inspect: `cratis chronicle failed-partitions show `.") + .AppendLine("5. Fix the underlying issue, then retry: `cratis chronicle observers retry-partition -y`.") + .AppendLine("6. To replay from scratch: `cratis chronicle observers replay -y`.") .AppendLine() .AppendLine("### Data not appearing in read model") - .AppendLine("1. Verify events exist: `cratis events has `.") - .AppendLine("2. Check the projection observer is running: `cratis observers list --type projection -o plain`.") - .AppendLine("3. Check read model instances: `cratis read-models instances `.") + .AppendLine("1. Verify events exist: `cratis chronicle events get --event-source-id -o plain`.") + .AppendLine("2. Check the projection observer is running: `cratis chronicle observers list --type projection -o plain`.") + .AppendLine("3. Check read model instances: `cratis chronicle read-models instances `.") .AppendLine("4. Check for failed partitions on the projection observer.") .AppendLine() .AppendLine("## Safety Notes") diff --git a/Source/Cli/UpdateChecker.cs b/Source/Cli/UpdateChecker.cs index 609b268..44a13e5 100644 --- a/Source/Cli/UpdateChecker.cs +++ b/Source/Cli/UpdateChecker.cs @@ -20,9 +20,21 @@ public static class UpdateChecker /// public const string ServerPackageId = "Cratis.Chronicle"; + /// + /// Environment variable that disables the update check entirely when set to any non-empty value. + /// + public const string DisableEnvVar = "CRATIS_NO_UPDATE_CHECK"; + static readonly TimeSpan _checkInterval = TimeSpan.FromHours(24); static readonly JsonSerializerOptions _cacheJsonOptions = new() { WriteIndented = true }; + /// + /// Returns true when the update check has been switched off through . + /// + /// True when the check should be skipped. + public static bool IsDisabled() => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DisableEnvVar)); + /// /// Gets the path to the cached version check file. /// @@ -51,6 +63,11 @@ public static string GetCachePath() => /// The latest version string if newer, otherwise null. public static async Task CheckForUpdate(string packageId, string currentVersion, CancellationToken cancellationToken = default) { + if (IsDisabled()) + { + return null; + } + var cache = ReadCache(); if (cache?.Packages.TryGetValue(packageId, out var entry) == true && DateTime.UtcNow - entry.CheckedAt < _checkInterval) diff --git a/assets/.gitignore b/assets/.gitignore new file mode 100644 index 0000000..957c823 --- /dev/null +++ b/assets/.gitignore @@ -0,0 +1,3 @@ +# Generated by prepare-env.sh at record time. .recording-home holds a cached auth token. +.recording-home/ +.recording-bin/ diff --git a/assets/RECORDING.md b/assets/RECORDING.md new file mode 100644 index 0000000..abb1aab --- /dev/null +++ b/assets/RECORDING.md @@ -0,0 +1,218 @@ +# Recording the README GIFs + +Everything in this directory that ends in `.tape` is a script. Nothing was performed by hand, +nothing was screen-captured, and any of it re-renders from a clean checkout: + +```bash +dotnet build -c Release +assets/demo-store/reset.sh +vhs assets/demo.tape # or palette, completions +``` + +That is the point. A hand-recorded screencast is a one-off you cannot fix a typo in; a tape is +source you can edit and re-run. + +## The tools + +[**vhs**](https://github.com/charmbracelet/vhs) turns a declarative `.tape` file into a GIF. It +spawns a real terminal, sends real keystrokes and records what actually happened, so the +numbers in these GIFs are the numbers that server really had. + +```bash +brew install vhs # pulls in ffmpeg and ttyd +``` + +**ffmpeg** comes along and does double duty: pulling frames out for inspection, and re-timing a +finished GIF if one ever needs it. None of the three currently do — what is committed is +exactly what vhs produced, so a re-render is byte-comparable. + +## The fixture + +A CLI that reads a server is only as interesting as the server. An empty Chronicle has nothing +to show, and the only populated one on the machine these were recorded on belonged to a real +customer system — so [`demo-store/`](demo-store/) builds a third option: a throwaway Chronicle +in Docker with a small bookshop seeded into it. + +```bash +assets/demo-store/reset.sh # fresh server, seed, exit +assets/demo-store/reset.sh drip 40 # append a trickle against a running one +``` + +It registers eight books, three members, two projections, two reducers and one reactor, then +appends a story: borrowings, two returns, two reservations, two books going overdue. The +reactor that sends overdue notices **throws for exactly one book**, which is what leaves a +failed partition behind for the CLI to find. + +Two properties matter: + +- **Deterministic identifiers.** Event source ids are generated from a fixed pattern rather + than `Guid.NewGuid()`, so `00000014-1111-4222-8333-444444444444` is the same book on every + re-render and the tapes can name it directly. +- **The failure survives.** Chronicle retries a failed partition on its own with a widening + backoff, and it *succeeds* the moment the client reconnects without the fault. So the seeder + exits after seeding and the partition stays failed. An earlier version of the hero tape ran a + background writer during the workbench segment to make the dashboard move on camera — it + reconnected the client, Chronicle retried, and the failure the GIF was about quietly + disappeared. Frames caught it. + +The project sits outside the CLI solution on purpose and carries its own `Directory.Build.props` +so MSBuild does not walk up into the repository's analyzers and central package versions. + +## The loop + +The order matters more than any individual setting. + +**1. Read the key handling before writing a line of tape.** For the workbench that meant +`WorkbenchKeyDispatcher.Dispatch`. It is also how two stale claims turned up before they could +be filmed: the workbench's own `[LlmDescription]` and its Overview panel both told you to press +number keys to switch views, and no number key was bound anywhere in the dispatcher. Both are +fixed — but only because writing the tape meant reading the dispatcher first. + +**2. Find the environment's opt-out switches.** This CLI has an unusually strong one: it +detects agent environments through `CLAUDECODE`, `CURSOR_TRACE_DIR`, `WINDSURF_SESSION_ID` and +friends, and silently switches to compact JSON with no banner. Recording from inside an agent +session without unsetting those produces a GIF of machine output. + +`CRATIS_NO_UPDATE_CHECK=1` silences the update hint, which would otherwise close every command +in every clip with `↑ update available`. That switch did not exist when these were first +recorded — the tapes pre-dated the cache the check consults instead, which worked but was a +hack aimed at the wrong layer. Needing it here was the argument for adding it. + +[`prepare-env.sh`](prepare-env.sh) handles the rest, and builds an isolated `$HOME` because +`~/.cratis/config.json` on a real machine holds real server addresses and client secrets. + +**3. Rehearse, and look at the frames. Actually look at them.** + +```bash +ffmpeg -i out.gif -vf fps=1 frames/%03d.png +``` + +Every problem in this set was found by looking, never by reasoning about what the tape should +have done: + +- A `\\` line continuation that vhs typed literally, so the command ran as three broken + fragments. +- The workbench opening with an **empty content pane** on a cold start, because the call that + paints the content was guarded behind a restored view index greater than zero. Fixed rather + than worked around. +- The generated bash completion script failing on `_init_completion`, which comes from the + bash-completion package that the macOS system bash 3.2 does not have. +- Two clips rendered against a server that had died between renders, showing `0 items` where + the content should be. + +To find *where* something changed without scrubbing by eye, hash the frames and print only the +ones that differ from their predecessor: + +```bash +ffmpeg -v error -i out.gif -vf fps=1 seq/%03d.png +python3 -c " +import hashlib, glob +prev = None +for f in sorted(glob.glob('seq/*.png')): + h = hashlib.md5(open(f,'rb').read()).hexdigest() + if h != prev: print(f'{int(f[-7:-4])-1:3d}s {f}') + prev = h +" +``` + +**4. Verify the server afterwards.** Capture the tail sequence number, the failed-partition +count and every observer's position before and after a render, and diff them. For this set both +sides were identical, which is how "nothing was changed" became a checked fact rather than a +hope. + +## Arrow keys do not work here + +On the machine these were recorded on, **vhs cannot deliver arrow keys to the application at +all.** This is not a workbench problem. The decisive test was bash itself: + +```tape +Type "echo FIRST" Enter +Type "echo SECOND" Enter +Up +Up +``` + +Two `Up` presses recalled zero history. Readline is the most reliable arrow consumer there is, +so the keys are being lost below the application. + +Everything else arrives: printable characters, `Enter`, `Escape`, `Tab` and `Ctrl+`. So +the workbench clips are choreographed entirely out of those — `Ctrl+P` to open the palette, +typing to drive it, `F` to filter, `Escape` and `q` to leave. `Tab` is avoided inside the +workbench specifically, because it moves focus somewhere that blanks the content pane. + +Two consequences worth knowing before editing a tape: + +- The keys table in the README documents arrows because they work for real users. They are not + demonstrable here. +- The workbench opens on the view it was last left on, so `prepare-env.sh` writes a + `workbench-state.json` and the clips start where they want to be rather than navigating on + camera. `RECORDING_NAV_INDEX` overrides which view that is. + +## The settings, and what each is for + +All of them live in [`_style.tape`](_style.tape), which every other tape pulls in with +`Source assets/_style.tape`. Three recordings that drift apart in font size or theme read as +three screencasts; one shared file makes them read as one set. + +| Setting | Why | +|---|---| +| `Set FontSize 14` + `1400x800` | 143 columns by 42 rows. Enough for the workbench's sidebar, table and detail pane side by side without the table truncating everything; GitHub scales a README image to about 880px, and this is still legible there. | +| `Set Theme {…}` | Hand-matched to `WorkbenchColors.cs` and `OutputFormatter.cs` so the terminal chrome and the application agree. | +| `Set WindowBar Colorful`, `BorderRadius 10`, `Margin 24`, `MarginFill` | The polish. Costs nothing, and a bare rectangle of terminal looks unfinished next to it. | +| `Set Framerate 24` | 50 is the default and roughly doubles the file for no visible gain. | +| `Set TypingSpeed` | 55ms globally, dropped to 18ms for a 36-character partition id nobody reads, raised to 160ms where the typing *is* the content. | + +`_style.tape` deliberately does **not** set the shell and does **not** close its `Hide` block. +Each tape picks its own shell and writes its own prompt, because a bash prompt and a zsh prompt +are not written the same way — and `completions.tape` has to be zsh. + +## vhs gotchas + +- `Output /abs/path.gif` **fails to parse**. Quote it, or use a relative path. +- `Escape`, not `Esc`. `Ctrl+P`, not `C-p`. +- `Set` works mid-tape, so `Set TypingSpeed 18ms` can speed up one stretch. +- A `\\` inside `Type "…"` is typed literally — it is not a shell line continuation. Put long + commands on one line. +- `vhs validate file.tape` catches the syntax in a second. Run it before every render. + +## What earns a GIF + +The most useful discipline here was editorial, not technical. + +**One hero, then short single-purpose clips.** The top of the README gets the full arc — the +verdict, the exception behind it, the live view — at about 35 seconds. Everything after is +15-20 seconds and shows exactly one thing, sitting next to the prose that explains it. + +**A GIF has to show something text cannot.** That is the whole test. + +- ✅ `demo.gif` — `diagnose` printing a verdict and naming the next command, then a full-screen + dashboard painting itself over the same terminal and narrowing live as a filter is typed. +- ✅ `palette.gif` — one word matching an observer, an event type, a projection, a read model + and a failure *at the same time*. A screenshot shows six rows; it cannot show them arriving + as the query resolves. +- ✅ `completions.gif` — a tab press that makes a network call. There is no way to convey that + the list came from the server rather than from the script, except by watching it happen. +- ❌ **The failed-partition trail on its own.** Four attempts of the same stack trace is a wall + of output. It is in the hero, where it lasts seven seconds and carries the narrative, and the + README quotes it as text where it is searchable and loads instantly. +- ❌ **The output formats.** The same command four ways is a table with byte counts, not a + recording. + +**Watch the total weight.** Three GIFs, 1.1 MB. + +## Recording something destructive + +`completions.tape` types `cratis chronicle observers replay ` on camera — a command that +reprocesses an observer from sequence zero. It exists in the clip because an observer id is the +best demonstration of completion hitting the server, and it is safe because **the tape never +sends `Enter`**. It ends on `Ctrl+C`, which abandons the line. + +Audit before rendering: + +```bash +awk '/^Show/{s=1} s' assets/completions.tape | grep -n "Enter" +``` + +That should return nothing but comment lines. Then verify the server afterwards, as above. + +No README asset justifies a tape typo replaying somebody's observer. diff --git a/assets/_style.tape b/assets/_style.tape new file mode 100644 index 0000000..f0398e0 --- /dev/null +++ b/assets/_style.tape @@ -0,0 +1,40 @@ +# Shared by every tape in this directory via `Source assets/_style.tape`. +# One file so the set reads as one recording rather than three screencasts. +# +# This file does NOT pick the shell and does NOT close the Hide block. Each tape sets its own +# `Set Shell` before sourcing this, then finishes the hidden setup with a prompt and `Show` — +# because a bash prompt and a zsh prompt are not written the same way, and completions.tape +# needs zsh. + +Set FontSize 14 +Set Width 1400 +Set Height 800 +Set Framerate 24 +Set TypingSpeed 55ms +Set Padding 20 +Set Margin 24 +Set MarginFill "#05070f" +Set BorderRadius 10 +Set WindowBar Colorful + +# Hand-matched to the application's own palette — WorkbenchColors.cs for the workbench and +# OutputFormatter.cs for everything else — so the terminal chrome and the CLI agree. +Set Theme { "background": "#080c1c", "foreground": "#dce1fa", "cursor": "#6387ff", "selection": "#234696", "black": "#080c1c", "red": "#ff5555", "green": "#50c878", "yellow": "#ffb74d", "blue": "#6387ff", "magenta": "#c89bff", "cyan": "#3cdcc8", "white": "#dce1fa", "brightBlack": "#5a6ea0", "brightRed": "#ff6482", "brightGreen": "#64dc82", "brightYellow": "#f0be50", "brightBlue": "#64b4ff", "brightMagenta": "#c89bff", "brightCyan": "#3cdcc8", "brightWhite": "#ffffff" } + +Hide + +# The CLI detects agent environments and silently switches to compact JSON, drops the banner +# and drops the update hint. Recording from inside an agent session without this produces a +# GIF of machine output. See GlobalSettings.IsAiAgentEnvironment(). +Type "unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_SSE_PORT CURSOR_TRACE_DIR WINDSURF_SESSION_ID" Enter + +# Without this every command in every clip would end in `↑ update available`. +Type "export CRATIS_NO_UPDATE_CHECK=1" Enter + +# An isolated home, because the CLI reads $HOME/.cratis/config.json and a real one holds real +# server addresses and client secrets. +Type "export CRATIS_REPO=$PWD" Enter +Type "export HOME=$CRATIS_REPO/assets/.recording-home" Enter +Type "export PATH=$CRATIS_REPO/assets/.recording-bin:$PATH" Enter +Type "bash $CRATIS_REPO/assets/prepare-env.sh" Enter +Type "cd /tmp/bookshop" Enter diff --git a/assets/completions.gif b/assets/completions.gif new file mode 100644 index 0000000..ab029a8 Binary files /dev/null and b/assets/completions.gif differ diff --git a/assets/completions.tape b/assets/completions.tape new file mode 100644 index 0000000..edd61f1 --- /dev/null +++ b/assets/completions.tape @@ -0,0 +1,41 @@ +# Tab completion that goes and asks the server. +# +# assets/demo-store/reset.sh +# vhs assets/completions.tape +# +# The observer ids offered here are not a static word list baked into the completion script. +# The script shells back into `cratis _complete observers`, which opens a connection and +# returns what that server has registered at that moment. +# +# Recorded under zsh rather than bash: the generated bash script calls `_init_completion`, +# which comes from the bash-completion package, and the macOS system bash is 3.2 without it. + +Output assets/completions.gif + +Set Shell zsh +Source assets/_style.tape + +Type "autoload -Uz compinit && compinit -u" Enter +Type "cratis completions zsh > /tmp/_cratis && source /tmp/_cratis" Enter +Type "compdef _cratis cratis" Enter +Type "export PROMPT='%F{75}❯%f '" Enter +Type "clear" Enter +Sleep 1s +Show + +# `replay` reprocesses an observer from sequence zero. This tape never sends Enter — it ends +# on Ctrl+C, which abandons the line. Audit before rendering: grep -n 'Enter' completions.tape +Type "cratis chronicle observers replay " +Sleep 1500ms +Tab +Sleep 4s + +Type "Bookshop.O" +Sleep 1s +Tab +Sleep 1500ms +Tab +Sleep 4s + +Ctrl+C +Sleep 2s diff --git a/assets/demo-store/DemoStore.csproj b/assets/demo-store/DemoStore.csproj new file mode 100644 index 0000000..2be2132 --- /dev/null +++ b/assets/demo-store/DemoStore.csproj @@ -0,0 +1,23 @@ + + + + + Exe + net10.0 + enable + enable + Bookshop + Bookshop + $(NoWarn);CS1591 + false + + + + + + + diff --git a/assets/demo-store/Directory.Build.props b/assets/demo-store/Directory.Build.props new file mode 100644 index 0000000..1eb962a --- /dev/null +++ b/assets/demo-store/Directory.Build.props @@ -0,0 +1,8 @@ + + + diff --git a/assets/demo-store/Directory.Build.targets b/assets/demo-store/Directory.Build.targets new file mode 100644 index 0000000..8c119d5 --- /dev/null +++ b/assets/demo-store/Directory.Build.targets @@ -0,0 +1,2 @@ + + diff --git a/assets/demo-store/Directory.Packages.props b/assets/demo-store/Directory.Packages.props new file mode 100644 index 0000000..5f9708a --- /dev/null +++ b/assets/demo-store/Directory.Packages.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/assets/demo-store/Domain.cs b/assets/demo-store/Domain.cs new file mode 100644 index 0000000..7a84c1d --- /dev/null +++ b/assets/demo-store/Domain.cs @@ -0,0 +1,99 @@ +using Cratis.Chronicle.Events; +using Cratis.Chronicle.Projections; +using Cratis.Chronicle.Reactors; +using Cratis.Chronicle.Reducers; + +namespace Bookshop; + +[EventType] +public record MemberRegistered(string Name, string Email); + +[EventType] +public record BookAddedToInventory(string Title, string Author, string Isbn); + +[EventType] +public record BookBorrowed(Guid MemberId, DateTimeOffset DueBy); + +[EventType] +public record BookReturned(); + +[EventType] +public record BookMarkedOverdue(int DaysLate); + +[EventType] +public record BookReservationPlaced(Guid MemberId); + +public record Book(Guid Id, string Title, string Author, string Isbn); + +public record Member(Guid Id, string Name, string Email); + +public record BorrowedBook(Guid Id) +{ + public string Title { get; set; } = string.Empty; + public string Member { get; set; } = string.Empty; + public DateTimeOffset Borrowed { get; set; } + public DateTimeOffset DueBy { get; set; } +} + +public record OverdueBook(Guid Id) +{ + public string Title { get; set; } = string.Empty; + public string Member { get; set; } = string.Empty; + public int DaysLate { get; set; } +} + +public class Books : IReducerFor +{ + public Task Added(BookAddedToInventory @event, Book? initialState, EventContext context) => + Task.FromResult(new Book(Guid.Parse(context.EventSourceId), @event.Title, @event.Author, @event.Isbn)); +} + +public class Members : IReducerFor +{ + public Task Registered(MemberRegistered @event, Member? initialState, EventContext context) => + Task.FromResult(new Member(Guid.Parse(context.EventSourceId), @event.Name, @event.Email)); +} + +public class BorrowedBooks : IProjectionFor +{ + public void Define(IProjectionBuilderFor builder) => builder + .From(from => from + .Set(m => m.DueBy).To(e => e.DueBy) + .Set(m => m.Borrowed).ToEventContextProperty(c => c.Occurred)) + .Join(join => join + .On(m => m.Id) + .Set(m => m.Title).To(e => e.Title)) + .RemovedWith(); +} + +public class OverdueBooks : IProjectionFor +{ + public void Define(IProjectionBuilderFor builder) => builder + .From(from => from + .Set(m => m.DaysLate).To(e => e.DaysLate)) + .Join(join => join + .On(m => m.Id) + .Set(m => m.Title).To(e => e.Title)) + .RemovedWith(); +} + +/// +/// Sends the overdue notice. Deliberately fails for one book so the demo server has a +/// failed partition to inspect and retry. +/// +public class OverdueNotices : IReactor +{ + public static string? FailForEventSourceId { get; set; } + + public Task Overdue(BookMarkedOverdue @event, EventContext context) + { + if (FailForEventSourceId is not null && string.Equals(context.EventSourceId, FailForEventSourceId, StringComparison.Ordinal)) + { + throw new SmtpUnavailable("smtp.bookshop.local: connection refused"); + } + + return Task.CompletedTask; + } +} + +public class SmtpUnavailable(string message) : Exception(message); diff --git a/assets/demo-store/Program.cs b/assets/demo-store/Program.cs new file mode 100644 index 0000000..0953efa --- /dev/null +++ b/assets/demo-store/Program.cs @@ -0,0 +1,98 @@ +using Bookshop; +using Cratis.Chronicle; +using Cratis.Chronicle.Connections; + +// Modes: +// seed — register artifacts, append the baseline story, leave one failing partition behind +// drip — stay connected and append a trickle of events so a live dashboard visibly moves +var server = args.Length > 0 ? args[0] : "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35100/"; +var storeName = args.Length > 1 ? args[1] : "Bookshop"; +var seconds = args.Length > 2 ? int.Parse(args[2]) : 25; +var mode = args.Length > 3 ? args[3] : "seed"; + +var options = new ChronicleOptions(new ChronicleConnectionString(server)); +using var client = new ChronicleClient(options); +var eventStore = await client.GetEventStore(storeName); + +// Give the client time to register artifacts with the server. +await Task.Delay(TimeSpan.FromSeconds(5)); + +// Deterministic ids so every re-render of the tapes shows the same identifiers. +static Guid Id(int n) => new($"0000{n:0000}-1111-4222-8333-444444444444"); + +var members = new[] +{ + (Id(1), "Ada Wong", "ada@example.com"), + (Id(2), "Grace Miller", "grace@example.com"), + (Id(3), "Kaito Mori", "kaito@example.com"), +}; + +var books = new[] +{ + (Id(10), "The Pragmatic Programmer", "Hunt & Thomas", "978-0135957059"), + (Id(11), "Domain-Driven Design", "Eric Evans", "978-0321125217"), + (Id(12), "Designing Data-Intensive Applications", "Martin Kleppmann", "978-1449373320"), + (Id(13), "Refactoring", "Martin Fowler", "978-0134757599"), + (Id(14), "Working Effectively with Legacy Code", "Michael Feathers", "978-0131177055"), + (Id(15), "Release It!", "Michael Nygard", "978-1680502398"), + (Id(16), "Accelerate", "Forsgren, Humble & Kim", "978-1942788331"), + (Id(17), "The Mythical Man-Month", "Fred Brooks", "978-0201835953"), +}; + +if (string.Equals(mode, "drip", StringComparison.Ordinal)) +{ + // The overdue notice succeeds here — this mode exists to make a live dashboard move, + // not to create failures. + Console.WriteLine($"drip: appending for {seconds}s"); + var deadline = DateTimeOffset.UtcNow.AddSeconds(seconds); + var n = 0; + while (DateTimeOffset.UtcNow < deadline) + { + var book = books[(n + 6) % books.Length]; + var member = members[n % members.Length]; + await eventStore.EventLog.Append(book.Item1.ToString(), new BookReservationPlaced(member.Item1)); + await Task.Delay(TimeSpan.FromMilliseconds(1500)); + await eventStore.EventLog.Append(book.Item1.ToString(), new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14))); + await Task.Delay(TimeSpan.FromMilliseconds(1500)); + n++; + } + + Console.WriteLine("drip done"); + return; +} + +foreach (var (id, name, email) in members) +{ + await eventStore.EventLog.Append(id.ToString(), new MemberRegistered(name, email)); +} + +foreach (var (id, title, author, isbn) in books) +{ + await eventStore.EventLog.Append(id.ToString(), new BookAddedToInventory(title, author, isbn)); +} + +var dueIn = new[] { 9, 12, 6, -3, -11, 4 }; +for (var i = 0; i < 6; i++) +{ + var member = members[i % members.Length]; + await eventStore.EventLog.Append( + books[i].Item1.ToString(), + new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(dueIn[i]))); +} + +await eventStore.EventLog.Append(books[0].Item1.ToString(), new BookReturned()); +await eventStore.EventLog.Append(books[1].Item1.ToString(), new BookReturned()); + +await eventStore.EventLog.Append(books[2].Item1.ToString(), new BookReservationPlaced(members[0].Item1)); +await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookReservationPlaced(members[2].Item1)); + +// Two books go overdue. The notice for the second one cannot be sent, which leaves a +// failed partition behind for the CLI to find. +OverdueNotices.FailForEventSourceId = books[4].Item1.ToString(); +await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookMarkedOverdue(3)); +await eventStore.EventLog.Append(books[4].Item1.ToString(), new BookMarkedOverdue(11)); + +Console.WriteLine($"seeded. failing partition = {books[4].Item1} ({books[4].Item2})"); + +await Task.Delay(TimeSpan.FromSeconds(seconds)); +Console.WriteLine("done"); diff --git a/assets/demo-store/reset.sh b/assets/demo-store/reset.sh new file mode 100755 index 0000000..435e3e7 --- /dev/null +++ b/assets/demo-store/reset.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Rebuild the event store the README GIFs are recorded against. +# +# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed +# story that ends with one reactor partition failing. Identifiers are deterministic, so +# every re-render of the tapes shows the same ids as the committed GIFs. +# +# ./reset.sh # fresh server, seed, exit +# ./reset.sh drip 40 # append a trickle for 40s against the running server +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${CHRONICLE_PORT:-35100}" +CONNECTION="chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:$PORT/" +CONTAINER="chronicle-readme" +IMAGE="cratis/chronicle:16.7.0-development" +BOOKSHOP="$HERE/bin/Release/net10.0/Bookshop" + +[ -x "$BOOKSHOP" ] || dotnet build "$HERE/DemoStore.csproj" -c Release + +if [ "$1" = "drip" ]; then + exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-40}" drip +fi + +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +docker run -d --name "$CONTAINER" -p "$PORT":35000 -p 30100:30000 -p 11100:11111 "$IMAGE" >/dev/null + +printf 'waiting for the server' +for _ in $(seq 1 60); do + if [ "$(curl -sk -o /dev/null -w '%{http_code}' https://localhost:$PORT/health 2>/dev/null)" = "200" ]; then + break + fi + printf '.' + sleep 1 +done +echo + +sleep 2 +"$BOOKSHOP" "$CONNECTION" Bookshop 20 seed diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..eae8895 Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/demo.tape b/assets/demo.tape new file mode 100644 index 0000000..928d1ab --- /dev/null +++ b/assets/demo.tape @@ -0,0 +1,48 @@ +# Hero. The arc this tool exists for: a verdict, the event that caused it, then the live view. +# +# assets/demo-store/reset.sh # seed the store and leave one partition failing +# vhs assets/demo.tape +# +# Nothing here is driven by arrow keys. vhs cannot deliver them to the application on the +# recording machine — bash's own history does not respond to Up either — so the workbench +# segment uses only keys that verifiably arrive: F, printable characters, Escape and q. + +Output assets/demo.gif + +Set Shell bash +Source assets/_style.tape + +Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter +Type "clear" Enter +Sleep 1s +Show + +Type "cratis chronicle diagnose" Enter +Sleep 6s + +# A partition id is 36 characters of nothing happening at reading speed, so this one line +# is typed fast. Everything the viewer is meant to read is at the default speed. +Set TypingSpeed 18ms +Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 00000014-1111-4222-8333-444444444444" Enter +Set TypingSpeed 55ms +Sleep 7s + +Type "clear" Enter +Sleep 500ms + +Type "cratis chronicle workbench" Enter +Sleep 9s + +# F opens the filter on whichever view is showing. Typed slowly here because the narrowing +# is the content. +Type "F" +Sleep 1500ms +Set TypingSpeed 140ms +Type "Bookshop" +Set TypingSpeed 55ms +Sleep 4s + +Escape +Sleep 1500ms +Type "q" +Sleep 2s diff --git a/assets/palette.gif b/assets/palette.gif new file mode 100644 index 0000000..23a670c Binary files /dev/null and b/assets/palette.gif differ diff --git a/assets/palette.tape b/assets/palette.tape new file mode 100644 index 0000000..2eb8a45 --- /dev/null +++ b/assets/palette.tape @@ -0,0 +1,31 @@ +# One query, every kind of artifact that touches it. +# +# assets/demo-store/reset.sh +# vhs assets/palette.tape + +Output assets/palette.gif + +Set Shell bash +Source assets/_style.tape + +Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter +Type "clear" Enter +Sleep 1s +Show + +Type "cratis chronicle workbench" Enter +Sleep 9s + +Ctrl+P +Sleep 2s + +# Typed slowly: the results resolving as the query narrows is the whole point of the clip. +Set TypingSpeed 160ms +Type "Overdue" +Set TypingSpeed 55ms +Sleep 5s + +Escape +Sleep 1s +Type "q" +Sleep 1500ms diff --git a/assets/prepare-env.sh b/assets/prepare-env.sh new file mode 100755 index 0000000..b84a3dd --- /dev/null +++ b/assets/prepare-env.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Prepares the recording environment. Called from inside the Hide block of _style.tape, +# so nothing here appears on camera. +# +# - a `cratis` on PATH that runs the local Release build +# - an isolated $HOME/.cratis pointing at the demo server +# - an empty working directory, so the prompt shows a neutral path +# +# The update hint is silenced with CRATIS_NO_UPDATE_CHECK, exported by _style.tape. +set -e + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$REPO/assets/.recording-bin" +CRATIS_HOME="${HOME:?}/.cratis" + +mkdir -p "$BIN" "$CRATIS_HOME" /tmp/bookshop + +cat > "$BIN/cratis" < "$CRATIS_HOME/config.json" <<'EOF' +{ + "activeContext": "local", + "contexts": { + "local": { + "server": "chronicle://localhost:35100/", + "eventStore": "Bookshop", + "namespace": null, + "clientId": null, + "clientSecret": null, + "accessToken": null, + "tokenExpiry": null, + "loggedInUser": null + } + } +} +EOF + +# The workbench reopens on the view it was last left on. vhs cannot deliver arrow keys on this +# machine, so rather than navigating on camera the clips start where they want to be: Observers, +# view index 1. RECORDING_NAV_INDEX picks a different one. +cat > "$CRATIS_HOME/workbench-state.json" <