SLOPSHOPPER

mxcli-companion

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

newpanebandguardcommandprocess
★ 131v0.1.0Apache-2.0updated 2026-10-15mendixlabs/mxcli/mods/companion
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mxcli-companion
│ ┃ Project ✕ › fix the failing auth test and add an audit log call │ ┃ Project [ Refresh ] [ Collapse ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No .mpr found here or one level down. Set ⎿ Read 6 lines │ ┃ the mprPath option of mxcli-companion, or ⏺ Update(src/auth.ts) │ ┃ run this from the app's folder. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Select a document to see its MDL. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mxcli-project │ ⎿ mxcli-companion: Project pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Project
Project [ Refresh ] [ Collapse ] No .mpr found here or one level down. Set the mprPath option of mxcli-companion, or run this from the app's folder. Select a document to see its MDL.
README

mxcli - Mendix CLI for AI-Assisted Development

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

The mxcli language poster -- click to open the interactive version

Click the poster to open the interactive version.

Why mxcli?

Mendix projects are stored in binary .mpr files that AI agents can't read directly. mxcli bridges this gap by providing:

  • MDL (Mendix Definition Language) - A SQL-like syntax for querying and modifying Mendix models
  • Multi-tool support - Works with Claude Code, GitHub Copilot, OpenCode, Cursor, Continue.dev, Windsurf, Aider, and more
  • Full-text search - Search across all strings, messages, and source definitions
  • Code navigation - Find callers, callees, references, and impact analysis
  • Catalog queries - SQL-based querying of project metadata
  • Linting - Check projects for common issues
  • Unix pipe support - Output formats designed for scripting and chaining

Design principles

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:

  • Token efficiency - the agent emits one larger MDL script instead of many small, chatty tool calls. MDL is 5-10x denser than raw model JSON, and one script is one model turn -- less orchestration overhead means lower cost and faster runs.
  • Complexity in the deterministic layer - one MDL statement maps deterministically to multiple documents (or MCP tool calls). The LLM states intent; mxcli carries the fan-out, ordering, and read-modify-write bookkeeping.
  • Human-readable DSL - MDL is inspired by SQL, Ada, and PL/SQL. LLM-generated scripts are easy for humans to review, audit, and author. Readable code is reviewable code.
  • Headless agentic loop - build and test Mendix applications without ever opening Studio Pro.
  • Understanding large applications - a queryable catalog and dependency-graph tables let agents explore complex apps through straightforward SQL.
  • Precise skills - a concrete DSL lets skill files embed specific, copyable examples of exactly the MDL to produce, for more reliable output.
  • Power with safety - mxcli init provisions a Dev Container that scopes exactly what the agent can see, do, and modify.
  • Deterministic verification - mxcli check and mxcli lint verify generated projects against deterministic rules -- no LLM judgement -- keeping the feedback loop fast and reliable.
  • Longer-term: smaller local models - generating a constrained DSL is simpler for an LLM than orchestrating tool calls, and a grammar can be enforced via grammar-constrained decoding.

See Design Principles in the documentation for the full rationale.

What is mxcli?

Mxcli is a tool that enables some of the following use cases.

A textual DSL for mendix models

MDL, Mendix Definition Language, is a DSL that provides textual models at the same abstraction level as the visual models in Studio Pro.

Mxcli MDL

Command line tool to work with Mendix projects

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

Mxcli

A REPL to work with Mendix projects

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.

Mxcli repl

Skills and configuration to enable Agentic Coding on Mendix projects

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.

Mxcli skills

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.

Mxcli claude

A set of extensible skills

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 skills

Metadata Catalog

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

Mxcli catalog

A Mendix project linter

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

Mxcli lint

VSCode for Mendix projects

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.

mxcli vscode claude code

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.

mxcli vscode mendix extions

Run and test your Mendix projects

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.

mxcli docker portable application distribution

Automated Playwright-cli testing for Mendix projects

The devcontainer is configured for use with playwright-cli so Claude Code can test your running application.

Data migration for Mendix projects

Claude code can migrate existing data, or generate demo data in the postgres container when you run your application.

Edit your Mendix Project in the browser with GitHub Codespaces

GitHub codespaces

Quick Start

Starting from scratch

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.

From the web or an iPad (empty repo, no local install)

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:

  1. Create an empty GitHub repo — this is where the app lives and where sessions run.
  2. (Optional) Get a hub key for a browser preview: open <https://hub.mxcli.org/cli>, sign in with GitHub, and Create a hub key.
  3. Create a Claude Code environment on the repo. Set MXCLI_HUB_KEY=<key> (from step 2) and allow outbound HTTPS to GitHub Releases, Mendix's CDN, and hub.mxcli.org.
  4. Paste the bootstrap prompt into a session. The agent installs mxcli, runs 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.
  5. Iterate with the warm loop below; add --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.

--hub and tunnel-hub are 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 in mxcli run --local is 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.

Existing project

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.

Supported AI Tools

ToolConfig FileDescription
Claude Code.claude/, CLAUDE.mdFull integration with skills and commands
OpenCode.opencode/, opencode.jsonSkills, commands, and lint rules
Cursor.cursorrulesCompact MDL reference and command guide
Continue.dev.continue/config.jsonCustom commands and slash commands
Windsurf.windsurfrulesCodeium's AI with MDL rules
Aider.aider.conf.ymlTerminal-based AI pair programming
UniversalAGENTS.mdWorks with all tools
# list supported tools
mxcli init --list-tools

# add tool to existing project
mxcli add-tool cursor

Installation

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 …@latest is not supported: the generated ANTLR parser isn't committed, so a module-source build fails. Use a pre-built binary or make build.

Core Features

Explore Project Structure

# 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

Full-Text Search

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

Code Navigation

# 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

Widget Discovery and Bulk Updates

EXPERIMENTAL: These commands are an untested proof-of-concept. Always use dry run first 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.

Catalog Queries

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

Linting

# 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.

Best Practices Report

# 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.

Testing

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.

Create and Modify

# 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 Language

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 keywords
  • mxcli syntax entity - Entity creation syntax
  • mxcli syntax microflow - Microflow creation syntax
  • mxcli syntax page - Page creation syntax
  • mxcli syntax search - Full-text search syntax

AI Assistant Integration

The 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

What Gets Created

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 scripts

Tool-Specific:

  • Claude Code: .claude/settings.json, CLAUDE.md, commands, lint-rules, lint-config.yaml (System module excluded from lint), skills
  • Cursor: .cursorrules - Compact MDL reference
  • Continue.dev: .continue/config.json - Custom commands and slash commands
  • Windsurf: .windsurfrules - MDL rules for Codeium
  • Aider: .aider.conf.yml - YAML config for Aider

VS Code Extension (auto-installed with Claude):

  • Syntax highlighting and diagnostics
  • Hover and go-to-definition
  • Code completion
  • Context menu commands

Dev Container

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:

ComponentPurpose
mxcliMendix CLI for AI-assisted development (copied into project)
MxBuild / mxMendix project validation and building (~/.mxcli/mxbuild/)
JDK 21 (Adoptium)Required by MxBuild

| Docker-in-Docker | Ru

Source 3 files
hooks/register.tsx 513 lines
1// 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}
513
hooks/tree.ts 165 lines
1// 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}
165
types/index.d.ts 46 lines
1// 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