Shows the Mendix app's project structure and the MDL source of its documents in a pane, from mxcli.

WARNING: Alpha-quality software. This project is in early stages and has been largely vibe-engineered with AI coding assistants. Expect bugs, missing features, and rough edges. mxcli can corrupt your Mendix project files — always work on a copy or use version control. Use at your own risk. This has been developed and tested against Mendix 11.6, other versions are currently not validated.
Do not edit a project with mxcli while it is open in Studio Pro. Studio Pro maintains in-memory caches that cannot be updated externally. Close the project in Studio Pro first, run mxcli, then re-open the project.
A command-line tool that enables AI coding assistants (Claude Code, GitHub Copilot, OpenCode, Cursor, Continue.dev, Windsurf, Aider, and others) to read, understand, and modify Mendix application projects.
Read the documentation | Use the bootstrap prompt -- the fastest way to spin up a Mendix project: paste it into a Claude Code cloud session on an empty repo, no local install needed
Click the poster to open the interactive version.
Mendix projects are stored in binary .mpr files that AI agents can't read directly. mxcli bridges this gap by providing:
mxcli is built on a single bet: the agent writes a compact, human-readable DSL, and mxcli's deterministic layer turns it into the many low-level mutations, validations, and tool calls a real model change requires. The differentiators that follow:
mxcli init provisions a Dev Container that scopes exactly what the agent can see, do, and modify.mxcli check and mxcli lint verify generated projects against deterministic rules -- no LLM judgement -- keeping the feedback loop fast and reliable.See Design Principles in the documentation for the full rationale.
Mxcli is a tool that enables some of the following use cases.
MDL, Mendix Definition Language, is a DSL that provides textual models at the same abstraction level as the visual models in Studio Pro.

Mxcli command line tool allows you to run commands against your project to investigate your project and make changes.

In repl mode mxcli allows you to interactively work with a Mendix project. This is similar to psql or sqlplus when working with databases. You can list the available Mendix documents, view the MDL source, and make changes.

Running mxcli init will install configuration files for agentic coding tools like AGENTS.md, CLAUDE.md, and Mendix specific skills. It will also configure a devcontainer that you can use when opening the project in Vscode, so you limit what your agentic coder can impact and see.

This screenshot shows how Claude uses mxcli command to do agentic search on your Mendix project to understand what is available. It gets a list of pages that are in the specified module, it uses structure to get an overview of all the documents in the module, and then it describes the soure of a specifc page. Based on this info it can make a plan how to modify your project.

The skills documents teach agentic coding tools how to build Mendix projects. You can add your own skills with design patterns and best practices. Using MDL you can be very specific how the agent should generate the required Mendix documents.

Mxcli builds up a set of database tables with information about your project. This allows for flexible agentic search on your project documents.

The catalog tables are exposed as Starlark APIs so you can use the available data in custom Mendix linter rules.

The easiest way to use mxcli is in vscode. You can run Claude Code inside vscode, mxcli installs a Mendix vscode extension that helps you review and understand your Mendix project.

The project structure shows you all modules with document, similar to the app explorer in Mendix Studio Pro. The VSCode extension also provides visualizations for some Mendix document types, ensuring you can review the generated documents without leaving VSCode.

Claude code can start your Mendix project using PAD (portable application distribution). This will run the Mendix runtime in a docker container, and postgres in another docker container. This allows you to test your Mendix project without leaving vscode.

The devcontainer is configured for use with playwright-cli so Claude Code can test your running application.
Claude code can migrate existing data, or generate demo data in the postgres container when you run your application.

Create a new Mendix project with everything configured in one command:
mxcli new MyApp --version 11.8.0
This downloads MxBuild, creates a blank Mendix project, sets up AI tooling and a Dev Container, and installs the correct mxcli binary. Open the resulting folder in VS Code and reopen in the Dev Container — you're ready to go.
No machine with a CLI? Build a Mendix app entirely in the browser with Claude Code on the web (works on an iPad) — mxcli provisions the whole toolchain in the cloud container and commits it so future sessions self-bootstrap. You don't need to pick a GitHub template. The full workflow:
MXCLI_HUB_KEY=<key> (from step 2) and allow outbound HTTPS to GitHub Releases, Mendix's CDN, and hub.mxcli.org.mxcli new → mxcli init, starts a FINDINGS.md, commits the config, and boots the app with mxcli run --local --setup --ensure-db so it comes up testable.--hub https://hub.mxcli.org for a shareable browser preview.Developing on the Web (Claude Code) walks the whole workflow, and Bootstrap Prompt has the exact copy-paste text.
Then iterate with the warm local dev loop — a Docker-free ~1-second edit→test cycle:
mxcli run --local -p app.mpr --watch --screenshot # hot-reload + auto screenshots
mxcli run --local keeps mxbuild --serve and a standalone runtime hot: a page/microflow edit is hot-applied in seconds (a hot reload_model, or a restart + DDL for entity changes), and --screenshot captures each page with Playwright. See Local Dev Loop.
Add --hub to preview the running app in a browser at a public URL — the app stays local and reverse-tunnels out over a single 443 connection, so it works even from egress-only environments (e.g. Claude Code on the web):
mxcli run --hub https://hub.example.com -p app.mpr # -> a shareable preview URL
mxcli tunnel-hub --domain example.com is the static relay you run once on a small VPS; it can front many previews at per-subdomain hosts across projects, solutions, branches, and worktrees, with a sortable availability overview at hub.example.com/. See Local Dev Loop → External browser preview.
--hubandtunnel-hubare Linux-only. The tunnel exists to get a preview out of a Linux container — the only place it ever ran — so it is built for Linux alone. Embedding a general-purpose tunnelling tool in the Windows and macOS binaries, which can never use it, got them flagged by Microsoft Defender and enterprise EDR, blocking mxcli on the managed corporate laptops most Mendix developers work on. On those platforms the commands still exist and show help, but fail with an explanatory message; run mxcli inside the project's devcontainer to use--hub. Everything else inmxcli run --localis unaffected. We did not obfuscate the dependency to dodge the scanners — the fix is not shipping the capability where it is unused. Rationale: ADR-0009.
For an existing Mendix project, use mxcli init to add AI tooling and a Dev Container:
mxcli init /path/to/my-mendix-project
# or specify your tool(s)
mxcli init --tool cursor /path/to/my-mendix-project
mxcli init --tool claude --tool cursor /path/to/my-mendix-project
Both approaches create AGENTS.md, .ai-context/ with skills, .devcontainer/ for sandboxed development, and tool-specific config files. Open the project in VS Code / Cursor and reopen in Dev Container, then start your AI assistant:
claude # or use Cursor, Continue.dev, etc.
| Tool | Config File | Description |
|---|---|---|
| Claude Code | .claude/, CLAUDE.md | Full integration with skills and commands |
| OpenCode | .opencode/, opencode.json | Skills, commands, and lint rules |
| Cursor | .cursorrules | Compact MDL reference and command guide |
| Continue.dev | .continue/config.json | Custom commands and slash commands |
| Windsurf | .windsurfrules | Codeium's AI with MDL rules |
| Aider | .aider.conf.yml | Terminal-based AI pair programming |
| Universal | AGENTS.md | Works with all tools |
# list supported tools
mxcli init --list-tools
# add tool to existing project
mxcli add-tool cursor
Download a pre-built binary from the releases page — the assets are raw binaries named mxcli-<os>-<arch> (nothing to extract). While mxcli is fast-moving alpha, the rolling nightly build is recommended (new features land there first); pin a vX.Y.Z release for reproducibility:
# Linux/macOS — nightly
curl -fsSL -o mxcli \
https://github.com/mendixlabs/mxcli/releases/download/nightly/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/
Or build from source (Go + Make — make build runs the ANTLR parser generation that go install can't):
git clone https://github.com/mendixlabs/mxcli.git
cd mxcli
make -C mdl/grammar bootstrap # one-time: installs the pinned ANTLR generator
export ANTLR4_TOOLS_ANTLR_VERSION=4.13.2
make build
# binary is at ./bin/mxcli
The generator version is pinned and load-bearing: it must match the ANTLR runtime in go.mod, or the generated parser will not compile. antlr4-tools downloads the ANTLR jar on first run, so the build needs network and a JVM, not only Go. Skip the bootstrap if you already have an antlr4 launcher on PATH (e.g. brew install antlr4).
go install …@latestis not supported: the generated ANTLR parser isn't committed, so a module-source build fails. Use a pre-built binary ormake build.
# list all modules
mxcli -p app.mpr -c "list modules"
# list entities in a module
mxcli -p app.mpr -c "list entities in MyModule"
# describe any element (module, entity, microflow, nanoflow, page, etc.)
mxcli describe -p app.mpr module MyModule
mxcli describe -p app.mpr entity MyModule.Customer
mxcli describe -p app.mpr microflow MyModule.ProcessOrder
mxcli describe -p app.mpr nanoflow MyModule.ValidateInput
mxcli describe -p app.mpr page MyModule.CustomerOverview
mxcli describe -p app.mpr json structure MyModule.CustomerResponse
Search across validation messages, log messages, captions, labels, and MDL source:
# search for validation-related content
mxcli search -p app.mpr "validation"
# Pipe-friendly output (type<TAB>name per line)
mxcli search -p app.mpr "error" -q --format names
# json output for processing with jq
mxcli search -p app.mpr "Customer" -q --format json
Pipe to describe:
# describe the first matching microflow
mxcli search -p app.mpr "validation" -q --format names | head -1 | awk '{print $2}' | \
xargs mxcli describe -p app.mpr microflow
# Process all matches
mxcli search -p app.mpr "error" -q --format names > results.txt
while IFS=$'\t' read -r type name; do
mxcli describe -p app.mpr "$type" "$name"
done < results.txt
# find what calls a microflow
mxcli callers -p app.mpr MyModule.ProcessOrder
mxcli callers -p app.mpr MyModule.ProcessOrder --transitive
# find what a microflow calls
mxcli callees -p app.mpr MyModule.ProcessOrder
# find all references to an element
mxcli refs -p app.mpr MyModule.Customer
# Analyze impact of changing an element
mxcli impact -p app.mpr MyModule.Customer
# Assemble context for understanding code
mxcli context -p app.mpr MyModule.ProcessOrder --depth 3
EXPERIMENTAL: These commands are an untested proof-of-concept. Always use
dry runfirst and backup your project before applying changes.
Find and update widget properties across pages and snippets:
# Discover widgets by type
mxcli -p app.mpr -c "list widgets where widgettype like '%combobox%'"
# filter by module
mxcli -p app.mpr -c "list widgets in MyModule"
# Preview changes (dry run)
mxcli -p app.mpr -c "update widgets set 'showLabel' = false where widgettype like '%DataGrid%' dry run"
# apply changes
mxcli -p app.mpr -c "update widgets set 'showLabel' = false, 'labelWidth' = 4 where widgettype like '%combobox%' in MyModule"
Requires refresh catalog full to populate the widgets table.
SQL-based querying of project metadata:
# find microflows with many activities
mxcli -p app.mpr -c "select Name, ActivityCount from CATALOG.MICROFLOWS where ActivityCount > 10 ORDER by ActivityCount desc"
# find all entity usages
mxcli -p app.mpr -c "refresh catalog full; select SourceName, RefKind, TargetName from CATALOG.REFS where TargetName = 'MyModule.Customer'"
# search strings table directly
mxcli -p app.mpr -c "select * from CATALOG.STRINGS where strings match 'error' limit 10"
Available tables: modules, entities, microflows, nanoflows, pages, snippets, enumerations, workflows, ACTIVITIES, widgets, REFS, PERMISSIONS, STRINGS, source
# lint a project
mxcli lint -p app.mpr
# sarif output for CI/GitHub integration
mxcli lint -p app.mpr --format sarif > results.sarif
# list available rules
mxcli lint -p app.mpr --list-rules
# Exclude modules
mxcli lint -p app.mpr --exclude System --exclude Administration
14 built-in Go rules (MPR001-MPR007, SEC001-SEC003, CONV011-CONV014) plus 29 bundled Starlark rules covering security (SEC004-SEC009), architecture (ARCH001-003), quality (QUAL001-004), design (DESIGN001), and Mendix best practice conventions (CONV001-CONV010, CONV015-CONV019). Custom .star rules in .claude/lint-rules/ are loaded automatically.
# generate a scored report (Markdown)
mxcli report -p app.mpr
# HTML report
mxcli report -p app.mpr --format html --output report.html
# json report for CI
mxcli report -p app.mpr --format json
The report evaluates the project across 6 categories (Security, Quality, Architecture, Performance, Naming, Design) with a 0-100 score per category and overall.
Test microflows using MDL syntax with javadoc-style annotations:
# run tests
mxcli test tests/microflows.test.mdl -p app.mpr
# list tests without executing
mxcli test tests/ --list
# JUnit xml output for CI
mxcli test tests/ -p app.mpr --junit results.xml
Tests use @test and @expect annotations in .test.mdl or .test.md files. See mxcli help test for full syntax.
# execute MDL commands
mxcli -p app.mpr -c "create entity MyModule.Product (Name: string(200) not null, Price: decimal)"
# execute an MDL script file
mxcli -p app.mpr -c "execute script 'setup.mdl'"
# check MDL syntax before executing
mxcli check script.mdl
# check syntax and validate references
mxcli check script.mdl -p app.mpr --references
# Preview changes (diff against current state)
mxcli diff -p app.mpr changes.mdl
MDL (Mendix Definition Language) is a SQL-like syntax for working with Mendix models:
-- Show project structure
list modules;
list entities in MyModule;
describe entity MyModule.Customer;
describe microflow MyModule.ProcessOrder;
describe page MyModule.CustomerOverview;
-- Create entities
create entity MyModule.Product (
Name: string(200) not null,
Price: decimal(10,2),
IsActive: boolean default true
);
-- Create associations
create association MyModule.Order_Product
from MyModule.Order to MyModule.Product
type ReferenceSet;
-- Create pages
create page MyModule.Product_Edit
(
params: ( $Product: MyModule.Product ),
title: 'Edit Product',
layout: Atlas_Core.PopupLayout
)
{
dataview dvProduct (datasource: $Product) {
textbox txtName (label: 'Name', attribute: Name)
textbox txtPrice (label: 'Price', attribute: Price)
checkbox cbActive (label: 'Active', attribute: IsActive)
footer {
actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary)
actionbutton btnCancel (caption: 'Cancel', action: cancel_changes)
}
}
}
-- Security management
create module role MyModule.Admin description 'Full access';
create module role MyModule.Viewer description 'Read-only';
grant execute on microflow MyModule.ProcessOrder to MyModule.Admin;
grant view on page MyModule.Product_Edit to MyModule.Admin, MyModule.Viewer;
grant create, delete, read *, write * on entity MyModule.Product to MyModule.Admin;
grant read * on entity MyModule.Product to MyModule.Viewer;
create user role AppAdmin (MyModule.Admin) manage all roles;
alter app security ( SecurityLevel: production );
show security matrix in MyModule;
-- Search
search 'validation';
-- Code navigation
list callers of MyModule.ProcessOrder transitive;
list references to MyModule.Customer;
list impact of MyModule.Customer;
show context of MyModule.ProcessOrder depth 3;
-- Widget discovery and bulk updates
list widgets where widgettype like '%combobox%';
update widgets set 'showLabel' = false where widgettype like '%DataGrid%' dry run;
Run mxcli syntax for MDL syntax reference, or mxcli syntax <topic> for specific topics:
mxcli syntax keywords - Reserved keywordsmxcli syntax entity - Entity creation syntaxmxcli syntax microflow - Microflow creation syntaxmxcli syntax page - Page creation syntaxmxcli syntax search - Full-text search syntaxThe mxcli init command sets up a Mendix project for AI-assisted development:
# default: Claude Code + universal docs
mxcli init /path/to/my-mendix-project
# Specify tool(s)
mxcli init --tool cursor /path/to/my-mendix-project
mxcli init --tool claude --tool cursor /path/to/my-mendix-project
# all tools
mxcli init --all-tools /path/to/my-mendix-project
# add tool to existing project
mxcli add-tool cursor
Universal (all tools):
AGENTS.md - Comprehensive guide for AI assistants.ai-context/skills/ - MDL pattern guides, one <name>/SKILL.md each (write-microflows, create-page, ...).ai-context/examples/ - Example MDL scriptsTool-Specific:
.claude/settings.json, CLAUDE.md, commands, lint-rules, lint-config.yaml (System module excluded from lint), skills.cursorrules - Compact MDL reference.continue/config.json - Custom commands and slash commands.windsurfrules - MDL rules for Codeium.aider.conf.yml - YAML config for AiderVS Code Extension (auto-installed with Claude):
mxcli init generates a .devcontainer/ configuration that provides a sandboxed development environment. This is the recommended way to run AI coding agents — it limits their access to just the project files.
What's installed in the dev container:
| Component | Purpose |
|---|---|
| mxcli | Mendix CLI for AI-assisted development (copied into project) |
| MxBuild / mx | Mendix project validation and building (~/.mxcli/mxbuild/) |
| JDK 21 (Adoptium) | Required by MxBuild |
| Docker-in-Docker | Ru
hooks/register.tsx 513 lines1// mxcli companion, slice 1: the Project view.
2//
3// A pane showing the app's structure from `mxcli project-tree`; selecting a
4// document shows its MDL from `mxcli describe --json`. The mod only draws:
5// every fact about the project comes from an mxcli command
6// (docs/11-proposals/proposal-mxcli-companion-mod.md, P1).
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface as Engine, FsEntry, Register } from 'claude-code'
10
11import type { Project, Source, TreeNode } from '../types'
12import {
13 assumeDescribable,
14 changesModel,
15 diagramView,
16 firstLine,
17 nodeId,
18 pageOf,
19 parseDescribe,
20 parseTree,
21 pickMpr,
22 touchesProjectFile,
23 visibleRows,
24} from './tree'
25
26const PLUGIN = 'mxcli-companion'
27const PANE = 'mxcli-project'
28
29const project = atom({ plugin: 'mxcli-companion', key: 'project' } as const, { state: 'unlocated' } as Project)
30const expanded = atom({ plugin: 'mxcli-companion', key: 'expanded' } as const, [] as string[])
31const selected = atom({ plugin: 'mxcli-companion', key: 'selected' } as const, '')
32const source = atom({ plugin: 'mxcli-companion', key: 'source' } as const, { state: 'none' } as Source)
33// The first visible row of each column: they page on their own.
34const treeTop = atom({ plugin: 'mxcli-companion', key: 'treeTop' } as const, 0)
35const sourceTop = atom({ plugin: 'mxcli-companion', key: 'sourceTop' } as const, 0)
36// Whether the band above the prompt offers the Project pane: once per project,
37// until the person opens the pane or says not now.
38const welcome = atom({ plugin: 'mxcli-companion', key: 'welcome' } as const, false)
39// How a diagram is shown, and whether the tree gives up its column to it.
40const diagram = atom({ plugin: 'mxcli-companion', key: 'diagram' } as const, { zoom: 'actual', x: 0 } as { zoom: 'fit' | 'actual'; x: number })
41const treeHidden = atom({ plugin: 'mxcli-companion', key: 'treeHidden' } as const, false)
42
43// project-tree reads the whole model; on a large app that is the slow call.
44const TREE_TIMEOUT_MS = 120_000
45const DESCRIBE_TIMEOUT_MS = 60_000
46// Below this many columns the tree and the source stack instead of sitting
47// side by side.
48const SIDE_BY_SIDE_MIN_COLUMNS = 90
49// Rows the pane's own chrome takes (header, the gap, a pager line per column).
50const CHROME_ROWS = 4
51// CSS pixels per cell on the surfaces that draw Svg (a monospace cell at the
52// desktop's font size); used to crop a diagram to the column at actual size.
53const CELL_PX = 8
54// A burst of agent writes reloads once, this long after the last one.
55const RELOAD_DEBOUNCE_MS = 1_500
56
57const basename = (path: string) => path.split('/').pop() ?? path
58
59let mprOption = ''
60let mxcliOption = ''
61let reloadGeneration = 0
62// The store key that remembers a project was welcomed; set at session start.
63let welcomedKey = ''
64// MDL already described since the tree last loaded, by qualified name. A
65// describe re-reads the whole model, seconds on a large app, so going back to a
66// document should not pay it again; a reload (the model changed) clears it.
67const described = new Map<string, string>()
68
69
70// The mxcli to run: the option, else ./mxcli (where the bootstrap script puts
71// it), else whatever is on PATH.
72const mxcli = async ($: Engine): Promise<string> => {
73 if (mxcliOption !== '') return mxcliOption
74 return (await $.fs.exists('mxcli')) ? './mxcli' : 'mxcli'
75}
76
77const run = async ($: Engine, args: string[], timeoutMs: number) =>
78 $.process.run([await mxcli($), ...args], { env: { MXCLI_QUIET: '1' }, timeoutMs })
79
80const mprFiles = async ($: Engine, dir: string): Promise<string[]> => {
81 const entries = await $.fs.list(dir).catch((): FsEntry[] => [])
82 return entries
83 .filter(e => e.kind === 'file' && e.name.endsWith('.mpr'))
84 .map(e => (dir === '.' ? e.name : `${dir}/${e.name}`))
85}
86
87const locate = async ($: Engine): Promise<Project> => {
88 const here = await mprFiles($, '.')
89 let below: string[] = []
90 if (mprOption === '' && here.length === 0) {
91 const dirs = (await $.fs.list('.').catch((): FsEntry[] => [])).filter(
92 e => e.kind === 'dir' && !e.name.startsWith('.') && e.name !== 'node_modules',
93 )
94 for (const d of dirs) below = below.concat(await mprFiles($, d.name))
95 }
96 const picked = pickMpr(mprOption, here, below)
97 if ('mpr' in picked) return { state: 'loading', mpr: picked.mpr }
98 if ('candidates' in picked) return { state: 'choose', candidates: picked.candidates }
99 return { state: 'none' }
100}
101
102const describeSelected = async ($: Engine, node: TreeNode) => {
103 const current = await read($, project)
104 if (current.state !== 'loaded' || !node.qualifiedName) return
105 const qn = node.qualifiedName
106 const cached = described.get(qn)
107 if (cached !== undefined) {
108 await update($, source, (): Source => ({ state: 'shown', label: node.label, qn, mdl: cached }))
109 return
110 }
111 await update($, source, (): Source => ({ state: 'loading', label: node.label, qn }))
112 try {
113 const out = await run($, ['describe', '-p', current.mpr, node.type, qn, '--json'], DESCRIBE_TIMEOUT_MS)
114 let next: Source
115 if (out.exitCode === 0) {
116 const mdl = parseDescribe(out.stdout)
117 described.set(qn, mdl)
118 next = { state: 'shown', label: node.label, qn, mdl }
119 } else {
120 next = { state: 'failed', label: node.label, qn, error: firstLine(out.stderr || out.stdout) }
121 }
122 // Only land the answer if the person has not moved on meanwhile.
123 await update($, source, (s): Source => (s.state === 'loading' && s.qn === qn ? next : s))
124 } catch (err) {
125 await update($, source, (s): Source =>
126 s.state === 'loading' && s.qn === qn
127 ? { state: 'failed', label: node.label, qn, error: String(err) }
128 : s,
129 )
130 }
131}
132
133const findNode = (nodes: readonly TreeNode[], id: string, parent = ''): TreeNode | undefined => {
134 for (const node of nodes) {
135 const nid = nodeId(parent, node)
136 if (nid === id) return node
137 if (id.startsWith(`${nid}/`) && node.children) {
138 const hit = findNode(node.children, id, nid)
139 if (hit) return hit
140 }
141 }
142 return undefined
143}
144
145const load = async ($: Engine, mpr?: string) => {
146 const target = mpr ? ({ state: 'loading', mpr } as Project) : await locate($)
147 await update($, project, () => target)
148 if (target.state !== 'loading') return
149 const startedMs = await $.clock.now()
150 try {
151 const out = await run($, ['project-tree', '-p', target.mpr], TREE_TIMEOUT_MS)
152 if (out.exitCode !== 0) {
153 await update($, project, (): Project => ({ state: 'failed', mpr: target.mpr, error: firstLine(out.stderr || out.stdout) }))
154 return
155 }
156 const tree = assumeDescribable(parseTree(out.stdout))
157 described.clear()
158 const loadedMs = await $.clock.now()
159 await update($, project, (): Project => ({ state: 'loaded', mpr: target.mpr, tree, loadedMs, tookMs: loadedMs - startedMs }))
160 // A reload keeps the selection; refresh its source, which may have changed.
161 const sel = await read($, selected)
162 const node = sel === '' ? undefined : findNode(tree, sel)
163 if (node?.describable) await describeSelected($, node)
164 } catch (err) {
165 await update($, project, (): Project => ({ state: 'failed', mpr: target.mpr, error: String(err) }))
166 }
167}
168
169const scheduleReload = async ($: Engine) => {
170 const current = await read($, project)
171 if (current.state !== 'loaded') return // never opened, or already busy
172 const mine = ++reloadGeneration
173 await $.clock.sleep(RELOAD_DEBOUNCE_MS)
174 if (mine === reloadGeneration) await load($, current.mpr)
175}
176
177// Surfaces whose element table has Svg; the terminal draws no pictures.
178const DRAWS_SVG = new Set(['desktop', 'vscode', 'mobile'])
179// The most an Svg element takes (its `source` cap).
180const SVG_MAX_CHARS = 131_072
181
182// drawDomainModel asks mxcli for the module's domain model as an SVG and shows
183// it; the tree's own list of the entities stays beside it.
184const drawDomainModel = async ($: Engine, node: TreeNode) => {
185 const current = await read($, project)
186 if (current.state !== 'loaded' || !node.qualifiedName) return
187 const qn = node.qualifiedName
188 const children = (node.children ?? []).map(c => c.label)
189 const key = `svg:${qn}`
190 const cached = described.get(key)
191 if (cached !== undefined) {
192 await update($, source, (): Source => ({ state: 'diagram', label: node.label, qn, svg: cached, children }))
193 return
194 }
195 await update($, diagram, d => ({ ...d, x: 0 }))
196 await update($, source, (): Source => ({ state: 'drawing', label: node.label, qn }))
197 const out = await run($, ['describe', '-p', current.mpr, '--format', 'svg', 'domainmodel', qn], DESCRIBE_TIMEOUT_MS)
198 let next: Source
199 if (out.exitCode !== 0) {
200 next = { state: 'failed', label: node.label, qn, error: firstLine(out.stderr || out.stdout) }
201 } else if (out.stdout.length > SVG_MAX_CHARS) {
202 next = { state: 'failed', label: node.label, qn, error: `the diagram is too large to draw here (${out.stdout.length} characters)` }
203 } else {
204 described.set(key, out.stdout)
205 next = { state: 'diagram', label: node.label, qn, svg: out.stdout, children }
206 }
207 await update($, source, (s): Source => (s.state === 'drawing' && s.qn === qn ? next : s))
208}
209
210const press = async ($: Engine, id: string, surface: string) => {
211 const current = await read($, project)
212 if (current.state !== 'loaded') return
213 const node = findNode(current.tree, id)
214 if (!node) return
215 if (node.children && node.children.length > 0) {
216 await update($, expanded, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
217 }
218 if ((await read($, selected)) !== id) await update($, sourceTop, () => 0)
219 await update($, selected, () => id)
220 if (node.describable) {
221 await describeSelected($, node)
222 } else if (node.type === 'domainmodel' && DRAWS_SVG.has(surface)) {
223 await drawDomainModel($, node)
224 } else {
225 await update($, source, (): Source => ({
226 state: 'container',
227 label: node.label,
228 type: node.type,
229 children: (node.children ?? []).map(c => c.label),
230 }))
231 }
232}
233
234// background runs work nobody awaits. Its failures have nowhere to go (the
235// pane shows load errors itself), and one landing after an unload must not
236// surface as an unhandled rejection.
237const background = (work: Promise<unknown>) => {
238 work.catch(() => undefined)
239}
240
241// The store outlives sessions and spans projects, so the key names the project.
242const startWelcome = async ($: Engine, cwd: string) => {
243 welcomedKey = `welcomed:${cwd}`
244 if ((await $.store.get(welcomedKey)) !== true) await update($, welcome, () => true)
245}
246
247const retireWelcome = async ($: Engine) => {
248 if (!(await read($, welcome))) return
249 await update($, welcome, () => false)
250 if (welcomedKey !== '') await $.store.set(welcomedKey, true)
251}
252
253// open seats the pane and starts loading; it answers what the person should
254// read, which is not always "opened": a session whose attached surfaces place
255// no panes (an older desktop, some remote surfaces) keeps it waiting undrawn.
256const open = async ($: Engine): Promise<string> => {
257 const opened = await $.ui.open({ id: PANE, title: 'Project' })
258 await retireWelcome($)
259 const current = await read($, project)
260 if (current.state === 'unlocated' || current.state === 'none' || current.state === 'failed') {
261 background(load($, current.state === 'failed' ? current.mpr : undefined))
262 }
263 return opened.isPlaced
264 ? 'Project pane opened.'
265 : `The Project pane is open but not shown here: ${opened.reason}`
266}
267
268export const register: Register = (on, options) => {
269 mprOption = typeof options.mprPath === 'string' ? options.mprPath : ''
270 mxcliOption = typeof options.mxcliPath === 'string' ? options.mxcliPath : ''
271
272 on('session.start', async ($, e, next) => {
273 await $.command.register({ name: 'mxcli-project', description: 'Show the Mendix project structure and MDL source in a pane' })
274 background(startWelcome($, e.cwd))
275 return next(e)
276 })
277
278 // The first session after `mxcli init` installed the companion: say it is
279 // here and offer the pane. A band, not a pane, because a band needs no room
280 // and shows unasked at any width.
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 if (e.props.hasSurvey || !(await read($, welcome))) return next(e)
283 const { Box, Text, Button } = $.ui.resolve(e)
284 return (
285 <Box flexDirection="row" gap={1} flexWrap="wrap">
286 <Text>
287 <Text bold>mxcli companion</Text> is installed: the Project pane shows this app's structure and MDL.
288 </Text>
289 <Button key="welcome-open" variant="primary" label="Open Project pane" onPress={() => void open($)} />
290 <Button key="welcome-dismiss" role="dismiss" label="Not now" onPress={() => void retireWelcome($)} />
291 </Box>
292 )
293 })
294
295 on('command.run', { command: 'mxcli-project' }, async $ => ({ text: await open($) }))
296
297 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
298 const ran = await next(e)
299 if (changesModel(e.command)) background(scheduleReload($))
300 return ran
301 }).catch(($, e, next) => next(e)) // an observer: its failure must never cost the call
302
303 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
304 const ran = await next(e)
305 if (touchesProjectFile(e.file_path)) background(scheduleReload($))
306 return ran
307 }).catch(($, e, next) => next(e)) // an observer: its failure must never cost the call
308
309 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
310 const ran = await next(e)
311 if (touchesProjectFile(e.file_path)) background(scheduleReload($))
312 return ran
313 }).catch(($, e, next) => next(e)) // an observer: its failure must never cost the call
314
315 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
316 const { Box, Text, Button, Code } = $.ui.resolve(e)
317 const proj = await read($, project)
318 const open = new Set(await read($, expanded))
319 const sel = await read($, selected)
320 const src = await read($, source)
321 const sideBySide = e.props.bodyColumns >= SIDE_BY_SIDE_MIN_COLUMNS
322 const hideTree = sideBySide && src.state === 'diagram' && (await read($, treeHidden))
323 const treeColumns = Math.min(56, Math.max(28, Math.floor(e.props.bodyColumns * 0.38)))
324 const sourceColumns = sideBySide && !hideTree ? e.props.bodyColumns - treeColumns - 2 : e.props.bodyColumns
325 // Side by side, each column is one screen tall and pages on its own; stacked,
326 // the pane scrolls as usual and the columns draw whole.
327 const pageRows = sideBySide ? Math.max(8, e.props.scroll.bodyRows - CHROME_ROWS) : Number.MAX_SAFE_INTEGER
328
329 const pager = (key: string, label: string, onPress: () => void) => (
330 <Button key={key} plain dimColor label={label} onPress={onPress} />
331 )
332
333 const header = (
334 <Box key="header" flexDirection="row" gap={1}>
335 <Text bold>Project</Text>
336 {'mpr' in proj && <Text dimColor>{basename(proj.mpr)}</Text>}
337 {proj.state === 'loaded' && <Text dimColor>({(proj.tookMs / 1000).toFixed(1)}s)</Text>}
338 <Button key="refresh" label="Refresh" onPress={() => void load($, 'mpr' in proj ? proj.mpr : undefined)} />
339 <Button
340 key="collapse"
341 label="Collapse"
342 onPress={() => void update($, expanded, () => []).then(() => update($, treeTop, () => 0))}
343 />
344 </Box>
345 )
346
347 let tree
348 switch (proj.state) {
349 case 'unlocated':
350 case 'loading':
351 tree = <Text dimColor>Reading the project…</Text>
352 break
353 case 'none':
354 tree = (
355 <Text>
356 No .mpr found here or one level down. Set the mprPath option of {PLUGIN}, or run this from the app's
357 folder.
358 </Text>
359 )
360 break
361 case 'choose':
362 tree = (
363 <Box flexDirection="column">
364 <Text>Several apps found; pick one:</Text>
365 {proj.candidates.map(c => (
366 <Button key={`mpr:${c}`} label={c} onPress={() => void load($, c)} />
367 ))}
368 </Box>
369 )
370 break
371 case 'failed':
372 tree = <Text color="error">mxcli project-tree failed: {proj.error}</Text>
373 break
374 case 'loaded': {
375 const { rows, more } = visibleRows(proj.tree, open, sideBySide ? 100_000 : 400)
376 const pg = pageOf(rows.length, await read($, treeTop), pageRows)
377 tree = (
378 <Box flexDirection="column">
379 {pg.above > 0 && pager('tree-up', `▲ ${pg.above} more`, () => void update($, treeTop, () => Math.max(0, pg.first - pageRows + 1)))}
380 {rows.slice(pg.first, pg.end).map(({ id, node, depth }) => {
381 const hasChildren = (node.children?.length ?? 0) > 0
382 const glyph = hasChildren ? (open.has(id) ? '▾' : '▸') : node.describable ? '·' : ' '
383 return (
384 <Box key={`row:${id}`} flexDirection="row" overflow="hidden">
385 <Text>{sel === id ? '›' : ' '}{' '.repeat(depth)}</Text>
386 <Button key={id} plain label={`${glyph} ${node.label}`} onPress={p => void press($, id, p.surface)} />
387 <Text dimColor wrap="truncate"> {node.type}</Text>
388 </Box>
389 )
390 })}
391 {pg.below > 0 && pager('tree-down', `▼ ${pg.below} more`, () => void update($, treeTop, () => pg.end - 1))}
392 {more > 0 && <Text dimColor>… {more} more rows; collapse a module to see them</Text>}
393 </Box>
394 )
395 break
396 }
397 }
398
399 let detail
400 switch (src.state) {
401 case 'none':
402 detail = <Text dimColor>Select a document to see its MDL.</Text>
403 break
404 case 'loading':
405 detail = <Text dimColor>Describing {src.qn}…</Text>
406 break
407 case 'failed':
408 detail = <Text color="error">describe {src.qn} failed: {src.error}</Text>
409 break
410 case 'drawing':
411 detail = <Text dimColor>Drawing {src.qn}…</Text>
412 break
413 case 'diagram': {
414 const table = $.ui.resolve(e)
415 const view = await read($, diagram)
416 const shown = diagramView(src.svg, view, sourceColumns * CELL_PX)
417 detail =
418 'Svg' in table ? (
419 <Box flexDirection="column">
420 <Box key="diagram-bar" flexDirection="row" gap={1} flexWrap="wrap">
421 <Text bold>{src.qn} domain model</Text>
422 <Button
423 key="diagram-zoom"
424 label={view.zoom === 'actual' ? 'Fit' : '100%'}
425 onPress={() => void update($, diagram, d => ({ zoom: d.zoom === 'actual' ? ('fit' as const) : ('actual' as const), x: 0 }))}
426 />
427 {shown.canPan && !shown.atStart && (
428 <Button key="diagram-left" label="◀" onPress={() => void update($, diagram, d => ({ ...d, x: Math.max(0, d.x - 1) }))} />
429 )}
430 {shown.canPan && !shown.atEnd && (
431 <Button key="diagram-right" label="▶" onPress={() => void update($, diagram, d => ({ ...d, x: d.x + 1 }))} />
432 )}
433 {sideBySide && (
434 <Button
435 key="diagram-tree"
436 label={hideTree ? 'Show tree' : 'Hide tree'}
437 onPress={() => void update($, treeHidden, h => !h)}
438 />
439 )}
440 </Box>
441 <table.Svg
442 key="diagram"
443 source={shown.svg}
444 alt={`Domain model of ${src.qn}: ${src.children.join(', ')}`}
445 isInteractive
446 {...(shown.width !== undefined ? { width: shown.width, height: shown.height } : {})}
447 />
448 </Box>
449 ) : (
450 <Text dimColor>
451 {src.label} has a diagram this surface cannot draw. Contains: {src.children.slice(0, 40).join(', ')}
452 </Text>
453 )
454 break
455 }
456 case 'container':
457 detail = (
458 <Box flexDirection="column">
459 <Text dimColor>
460 {src.label} ({src.type}) has no MDL source of its own.
461 </Text>
462 {src.children.length > 0 && <Text dimColor>Contains: {src.children.slice(0, 40).join(', ')}{src.children.length > 40 ? ', …' : ''}</Text>}
463 </Box>
464 )
465 break
466 case 'shown': {
467 const lines = src.mdl.replace(/\n$/, '').split('\n')
468 const pg = pageOf(lines.length, await read($, sourceTop), pageRows - 1)
469 detail = (
470 <Box flexDirection="column">
471 <Text bold>{src.qn}</Text>
472 {pg.above > 0 && pager('src-up', `▲ ${pg.above} lines`, () => void update($, sourceTop, () => Math.max(0, pg.first - pageRows + 2)))}
473 <Code source={lines.slice(pg.first, pg.end).join('\n')} language="sql" startLine={pg.first + 1} />
474 {pg.below > 0 && pager('src-down', `▼ ${pg.below} lines`, () => void update($, sourceTop, () => pg.end - 1))}
475 </Box>
476 )
477 break
478 }
479 }
480
481 if (!sideBySide) {
482 return (
483 <Box flexDirection="column" gap={1}>
484 {header}
485 <Box key="tree" flexDirection="column">{tree}</Box>
486 <Box key="source" flexDirection="column">{detail}</Box>
487 </Box>
488 )
489 }
490 if (hideTree) {
491 return (
492 <Box flexDirection="column" gap={1}>
493 {header}
494 <Box key="source" flexDirection="column">{detail}</Box>
495 </Box>
496 )
497 }
498 return (
499 <Box flexDirection="column" gap={1}>
500 {header}
501 <Box key="columns" flexDirection="row" gap={2}>
502 <Box key="tree" flexDirection="column" width={treeColumns} flexShrink={0} overflow="hidden">
503 {tree}
504 </Box>
505 <Box key="source" flexDirection="column" flexGrow={1} overflow="hidden">
506 {detail}
507 </Box>
508 </Box>
509 </Box>
510 )
511 })
512}
513hooks/tree.ts 165 lines1// Pure helpers for the Project view: no `$`, so the tests can call them
2// directly. Everything that reads the project goes through mxcli (the mod
3// draws; mxcli knows).
4
5import type { TreeNode } from '../types'
6
7// A row of the tree as drawn: the node, its path id and its depth.
8export type Row = { id: string; node: TreeNode; depth: number }
9
10// nodeId is the node's path of labels. Labels are unique among siblings in
11// project-tree's output (a folder and a document of the same name sort apart
12// by type, so the type is part of the id too).
13export const nodeId = (parent: string, node: TreeNode): string =>
14 `${parent}/${node.type}:${node.label}`
15
16// visibleRows flattens the expanded part of the tree, in order. `limit` keeps a
17// drawing under the engine's per-tree text budget; `more` says what was left.
18export const visibleRows = (
19 nodes: readonly TreeNode[],
20 expanded: ReadonlySet<string>,
21 limit = 400,
22): { rows: Row[]; more: number } => {
23 const rows: Row[] = []
24 let more = 0
25 const walk = (list: readonly TreeNode[], parent: string, depth: number) => {
26 for (const node of list) {
27 const id = nodeId(parent, node)
28 if (rows.length >= limit) {
29 more += 1
30 continue
31 }
32 rows.push({ id, node, depth })
33 if (node.children && node.children.length > 0 && expanded.has(id)) {
34 walk(node.children, id, depth + 1)
35 }
36 }
37 }
38 walk(nodes, '', 0)
39 return { rows, more }
40}
41
42// pageOf clamps a column's first row to what `total` rows allow at `size` rows
43// a page, and says what lies above and below the page. The pane scrolls as one
44// body, so each column pages on its own to scroll independently.
45export const pageOf = (total: number, top: number, size: number) => {
46 const page = Math.max(1, size)
47 const first = Math.max(0, Math.min(top, total - page))
48 const end = Math.min(total, first + page)
49 return { first, end, above: first, below: total - end }
50}
51
52// assumeDescribable copes with an mxcli older than project-tree's `describable`
53// field: a project can commit a newer companion than the mxcli its session hook
54// downloads. With no node carrying the field at all, offer MDL for every named
55// leaf and let describe answer, as clients did before the field existed.
56// Containers stay out: describing a module prints the whole module.
57export const assumeDescribable = (tree: TreeNode[]): TreeNode[] => {
58 const hasField = (nodes: readonly TreeNode[]): boolean =>
59 nodes.some(n => 'describable' in n || hasField(n.children ?? []))
60 if (hasField(tree)) return tree
61 const mark = (nodes: readonly TreeNode[]): TreeNode[] =>
62 nodes.map(n => ({
63 ...n,
64 describable: n.qualifiedName !== undefined && (n.children?.length ?? 0) === 0,
65 ...(n.children ? { children: mark(n.children) } : {}),
66 }))
67 return mark(tree)
68}
69
70// How a diagram is shown: the whole drawing scaled to the column ('fit'), or
71// at actual size ('actual'), cropped to the column and panned by whole
72// column widths (`x` counts them from the left).
73export type DiagramZoom = { zoom: 'fit' | 'actual'; x: number }
74
75const VIEWBOX = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/
76
77// diagramView rewrites the root's viewBox (and width/height, to keep the
78// aspect) so the surface draws the chosen window of the drawing. A surface
79// scales an Svg to its slot, so a wide drawing shown whole is too small to read.
80export const diagramView = (svg: string, view: DiagramZoom, columnPx: number) => {
81 const m = VIEWBOX.exec(svg)
82 if (!m) return { svg, canPan: false, atStart: true, atEnd: true, width: undefined, height: undefined }
83 const [x0, y0, w, h] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
84 // The size to draw it at, in CSS pixels, given to the Svg element: an
85 // interactive Svg draws in a frame, and a frame does not size itself to its
86 // content the way an image does — without these it is a thumbnail.
87 if (view.zoom === 'fit' || w <= columnPx) {
88 const width = Math.round(Math.min(w, columnPx))
89 return { svg, canPan: false, atStart: true, atEnd: true, width, height: Math.round((h * width) / w) }
90 }
91 const vw = Math.round(columnPx)
92 const maxX = w - vw
93 const x = Math.max(0, Math.min(view.x * vw, maxX))
94 const head = svg.slice(0, svg.indexOf('>') + 1)
95 const rest = svg.slice(head.length)
96 const newHead = head
97 .replace(VIEWBOX, `viewBox="${x0 + x} ${y0} ${vw} ${h}"`)
98 .replace(/ width="[\d.]+"/, ` width="${vw}"`)
99 .replace(/ height="[\d.]+"/, ` height="${h}"`)
100 return { svg: newHead + rest, canPan: true, atStart: x <= 0, atEnd: x >= maxX, width: vw, height: Math.round(h) }
101}
102
103// parseTree reads `mxcli project-tree` output.
104export const parseTree = (stdout: string): TreeNode[] => {
105 const start = stdout.indexOf('[')
106 if (start < 0) throw new Error('project-tree printed no JSON')
107 const parsed: unknown = JSON.parse(stdout.slice(start))
108 if (!Array.isArray(parsed)) throw new Error('project-tree did not print a list')
109 return parsed as TreeNode[]
110}
111
112// parseDescribe reads `mxcli describe --json` output: { name, type, mdl }.
113export const parseDescribe = (stdout: string): string => {
114 const start = stdout.indexOf('{')
115 if (start < 0) throw new Error('describe printed no JSON')
116 const parsed = JSON.parse(stdout.slice(start)) as { mdl?: unknown }
117 if (typeof parsed.mdl !== 'string') throw new Error('describe printed no mdl')
118 return parsed.mdl
119}
120
121// firstLine trims an mxcli error to what fits a row of the pane.
122export const firstLine = (text: string): string => {
123 const line = text
124 .split('\n')
125 .map(l => l.trim())
126 .find(l => l !== '')
127 return line ?? 'mxcli failed with no message'
128}
129
130// MUTATING_VERB matches the mxcli invocations that can change the model: a
131// script (exec), a one-liner (-c) that writes, a new project, a rename, a
132// marketplace install. A read-only `show` or `describe` must not reload the
133// tree: on a large project the reload is the slow part.
134const MXCLI = /(^|[\s/;&|(])mxcli(\s|$)/
135const WRITES = /\b(exec|new|rename|marketplace|import)\b/
136const ONE_LINER = /\s(-c|--command)\s/
137const WRITING_MDL = /\b(create|alter|drop|rename|move|grant|revoke|update|delete|insert)\b/i
138
139export const changesModel = (command: string): boolean => {
140 if (!MXCLI.test(command)) return false
141 if (ONE_LINER.test(command)) return WRITING_MDL.test(command)
142 return WRITES.test(command)
143}
144
145// touchesProjectFile is an Edit or Write of the MPR or a unit under
146// mprcontents/ — rare, but a change all the same.
147export const touchesProjectFile = (path: string): boolean =>
148 /\.mpr$/.test(path) || /(^|\/)mprcontents\//.test(path)
149
150// pickMpr chooses the project from what was found, in the order the
151// proposal fixes: the option, else the single .mpr here, else the single one a
152// level down. Several candidates leave the choice to the person.
153export const pickMpr = (
154 option: string,
155 here: readonly string[],
156 below: readonly string[],
157): { mpr: string } | { candidates: string[] } | { none: true } => {
158 if (option !== '') return { mpr: option }
159 if (here.length === 1) return { mpr: here[0]! }
160 if (here.length > 1) return { candidates: [...here] }
161 if (below.length === 1) return { mpr: below[0]! }
162 if (below.length > 1) return { candidates: [...below] }
163 return { none: true }
164}
165types/index.d.ts 46 lines1// A node of `mxcli project-tree` output.
2export type TreeNode = {
3 label: string
4 type: string
5 qualifiedName?: string
6 // `mxcli describe <type> <qualifiedName>` answers this node.
7 describable?: boolean
8 children?: TreeNode[]
9}
10
11// What the Source part of the pane shows.
12export type Source =
13 | { state: 'none' }
14 | { state: 'loading'; label: string; qn: string }
15 | { state: 'shown'; label: string; qn: string; mdl: string }
16 | { state: 'failed'; label: string; qn: string; error: string }
17 | { state: 'container'; label: string; type: string; children: string[] }
18 // A domain model drawn by `mxcli describe --format svg domainmodel <Module>`.
19 | { state: 'diagram'; label: string; qn: string; svg: string; children: string[] }
20 | { state: 'drawing'; label: string; qn: string }
21
22// Where the tree came from and how loading it went.
23export type Project =
24 | { state: 'unlocated' }
25 | { state: 'none' }
26 | { state: 'choose'; candidates: string[] }
27 | { state: 'loading'; mpr: string }
28 | { state: 'loaded'; mpr: string; tree: TreeNode[]; loadedMs: number; tookMs: number }
29 | { state: 'failed'; mpr: string; error: string }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'mxcli-companion': {
34 project: Project
35 expanded: string[]
36 selected: string
37 source: Source
38 treeTop: number
39 sourceTop: number
40 welcome: boolean
41 diagram: { zoom: 'fit' | 'actual'; x: number }
42 treeHidden: boolean
43 }
44 }
45}
46