Metadata-Version: 2.4
Name: saxml4adt
Version: 0.1.5
Summary: Query a FileMaker Save-as-XML export: cross-references, readable scripts, impact analysis
Author-email: Michael Wallace <michael.wallace@empoweredds.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/mw777eds/SaXML4ADT
Project-URL: Repository, https://github.com/mw777eds/SaXML4ADT
Keywords: filemaker,save-as-xml,cli,cross-reference
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: lxml>=5.0.0
Requires-Dist: typer>=0.9.0
Requires-Dist: keyring>=24.0
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: pytest>=7.4.0; extra == "dev"
Dynamic: license-file

# SaXML4ADT

[![PyPI][pypi-badge]][pypi]
[![Python][python-badge]][pypi]
[![Tests][ci-badge]][ci]

```bash
pipx install saxml4adt      # or: pip install saxml4adt
```

**Save-as-XML for Agentic Development Toolkit.** A command-line query store over a FileMaker **Save a Copy as XML** export:
exact cross-references, readable scripts, impact analysis, a freshness model, and a web console — built so an agent doing
FileMaker development with [`fm-cli`](https://github.com/claris/adt) can answer "what will this touch?" in milliseconds,
without taking FileMaker's schema lock, and can tell when its answer is out of date.

Read-only. `fm-cli` stays the only writer. See
[`docs/USE-CASES.md`](https://github.com/mw777eds/SaXML4ADT/blob/main/docs/USE-CASES.md) for the use cases this was built
against and the FileMaker quirks found on the way.

The idea of a queryable store over FileMaker's own structural export came from
[Nuosis/FM2WEB_CLI](https://github.com/Nuosis/FM2WEB_CLI) (a DDR toolkit); SaXML4ADT is a from-scratch implementation around
Save-as-XML and shares no code with it.

## Prerequisites

|                                           |                                                                               |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| Python 3.10+                              | `pipx` recommended                                                            |
|                                           |                                                                               |
| An ADT project                            | a folder with `adt.json` naming the file — the ADT toolkit's own `adt init`,  |
|                                           | not this tool's `saxml4adt init` (see *Install* below) — and ADT's CLI:       |
|                                           | `filemaker` (ADT 0.8+) or `fm` (0.6 and earlier). SaXML4ADT finds either,     |
|                                           | falling back to the binary in ADT's install root, and never mistakes macOS    |
|                                           | 27's `/usr/bin/fm` (Apple's Foundation Models CLI) for it. The CLI needs its  |
|                                           | keychain credentials for the file (`adt connect --username …` stores them on  |
|                                           | 0.8; `fm --file=… --username=… --store-credentials` before that). Both        |
|                                           | generations are supported on purpose: ADT 0.8's web viewer runtime needs      |
|                                           | FileMaker Pro 26.1, so a project whose clients run 26.0 stays on 0.6          |
|                                           |                                                                               |
| FileMaker Server with the **Data API** on | and an account with the `fmrest` extended privilege (the same account         |
|                                           | `adt.json` names is fine). Not hosted? See *Working without a server* below   |
|                                           |                                                                               |
| Any coding agent                          | Claude Code gets the freshness hook (`saxml4adt init`); agents without hooks  |
|                                           | (Codex, …) run fm-cli through `saxml4adt filemaker …` (or `saxml4adt fm …`,   |
|                                           | the same wrapper), which marks for them — `saxml4adt init --agents` writes    |
|                                           | that protocol into `AGENTS.md`                                                |

## Install

```bash
pipx install saxml4adt                                          # or: pip install saxml4adt
cd ~/projects/my-adt-project                                   # the folder with adt.json (or an empty folder)
saxml4adt serve                                                # opens the browser on the Setup screen
```

The bleeding edge, ahead of the last PyPI release: `pipx install git+https://github.com/mw777eds/SaXML4ADT`.

The Setup screen does the rest. It shapes itself around what the project already has: with no file recorded it shows the
`adt connect` command that records one, because that is ADT's file to write; with a hosted file it stores the Data API
password and nothing else, the account coming from `adt.json`; with a local file there is no password to store and it says
so. Then readiness checks, and buttons for the steps that can actually run — the console appears when the first build lands.
The same steps from the terminal:

```bash
saxml4adt init                                                 # hook + .gitignore + readiness check
saxml4adt install                                              # once per FileMaker file (see below)
saxml4adt export --save                                        # first time: asks for the password, stores it
saxml4adt build
saxml4adt serve
```

`adt.json` is ADT's file, and ADT writes it: `adt init --target fmnet://fms.example.com/MyFile` in an empty folder, or
`adt connect <target>` in a project that already has one, where `<target>` is an fmnet address or the path to a `.fmp12`.
SaXML4ADT reads it and never writes it, so there is one record of which file a project means. A FileMaker account with Full
Access is required, and only `adt connect` takes it — `adt init` has no `--username`, so record the account with
`adt connect --username <account> <target>` after the project exists.

`init` writes the hook into `.claude/settings.json`, adds the export folder and the store to `.gitignore` (they are client
data), and reports what is missing. `install` uses fm-cli to add one table (`SaXML4ADT_Export`), one layout of the same name,
and three scripts in a `SaXML4ADT` folder — the machinery that runs Save a Copy as XML on the server and hands the files back
through the Data API. Everything it adds is in
[`saxml4adt/ops/install.ndjson`](https://github.com/mw777eds/SaXML4ADT/blob/main/saxml4adt/ops/install.ndjson) for review.
Put the server's **full host name** in `adt.json` (`fmnet://fms.example.com/File`) so the Data API certificate matches; a
short alias can be mapped in `~/.config/saxml4adt/hosts.json` (`{"fms": "fms.example.com"}`).

Credentials go in the OS credential store (macOS Keychain as a `saxml4adt: host/file` item; `keyring` elsewhere), or in
`SAXML4ADT_FM_PASSWORD` for CI. `saxml4adt credentials` shows whether one is stored; `--forget` removes it and `--username
NEW --save` stores the next one. To change the account fm-cli edits as, run `adt connect --username <account> <target>`: it
records the account in `adt.json` and asks for that account's password, keeping it in fm-cli's own keychain entry (`filemaker
--forget-credentials` removes an old one; on ADT 0.6 the same entry is written with `fm --file=… --username=…
--store-credentials`). SaXML4ADT never reads or writes FileMaker's keychain items.

**Renamed from `saxml`.** Earlier builds installed a `saxml` command; it is gone. An agent whose `saxml …` calls start
failing with `command not found` mid-session is seeing the rename, not a broken install — switch to `saxml4adt` (same
subcommands) and re-run `saxml4adt init` so the hook and .gitignore entries carry the new name.

## Help

`saxml4adt help` lists topics, `saxml4adt help scripts` the commands in one, `saxml4adt help steps` one command with its
options and examples (`--json` for agents). `saxml4adt <command> --help` still works. `saxml4adt help concepts` lists the
mental models behind the store (marked vs unmarked, freshness, the hook loop, …);
`saxml4adt help concepts marked-vs-unmarked` prints one.

## The loop

```
saxml4adt export   →   saxml4adt build   →   query / serve
       ▲                                          │
       └── agent edits with fm → hook marks dirty ─┘
```

* **export** runs the Save a Copy as XML and brings it into `XML/SaveAsXML/` — hosted, the server produces it and every
  catalog file comes back through the Data API into `<File>/`; on this machine, FileMaker Pro writes it straight to disk as
  `<File>_<stamp>/` (`--catalogs ScriptCatalog,LayoutCatalog` for a partial export, merged into the folder). The stamp is
  what keeps two runs apart: FileMaker will not overwrite an export folder that already exists, it uniques the name instead.
  `--transport ssh` rsyncs the server's Documents folder instead, for hosts you can reach. `pull` downloads an export the
  server already produced (after `export --keep`) without running it again. A `note` in the output when fewer files loaded
  than expected usually means `Summary.xml` — every structural catalog still loaded; it's not FileMaker skipping an empty
  one, and SaXML4ADT never reads `Summary.xml` anyway.
* **build** loads the newest export into `saxml4adt.sqlite` **in place**: the build history, the change log and the dirty
  marks survive, and every object that changed since the previous build is reconciled against the marks (`--fresh` wipes).
  Seconds, even for a 70 MB export. Once the ingest has succeeded it deletes the older exports of that file — a full export
  is tens to hundreds of megabytes and nothing reads it again (`--keep N`, `--no-prune`, or `SAXML4ADT_KEEP_EXPORTS=3` /
  `=all` for a default of your own).
* **serve** opens the console: a map of catalogs, which objects are marked dirty / changed / changed-without-a-mark,
  drill-down to any object, the op that caused each mark, history by account, and the query log. It follows the store live.
  The server runs in the background — the terminal is free. Only one tab opens per project: running `serve` again while it's
  already up prints the existing URL and does not start a second server or open another tab; `serve --restart` stops and
  restarts it in place (same port) without opening a tab either — the console page you already have open detects the restart
  and reconnects on its own. `serve --stop` (or the title menu, which also closes that tab) ends it, and it quits by itself
  after 12 hours without a visitor.
* Every query prints JSON with `meta.stale` — the open marks the result touches — and warns; `--strict` exits 3 on a stale
  answer, `--max-age 30m` exits 4 on an old export, and `status` reports catalogs whose source files are gone or newer than
  the build.

## What it adds to `filemaker`

On ADT 0.6 and earlier the CLI is `fm`, has no `search:` ops, cannot read a custom function's body and has no
tab-order surface — every row below is then the only route to its answer. `filemaker` 0.8 answers "where is
this used?" itself: `search:field`, `search:script`, `search:layout`,
`search:variable`, `search:customFunction`, `search:valueList`, `search:table`, `search:tableOccurrence`
and `search:text` run against the live file and report their own coverage. Use them when the answer has to
reflect the file this minute. SaXML4ADT answers the same questions from the export — without opening the
file or taking its schema lock, across every file of a multi-file solution at once — and adds what a live
read cannot give:

|                                                    |                                                                      |
| -------------------------------------------------- | -------------------------------------------------------------------- |
| `saxml4adt steps <script>`                         | every step with **0-based index and 1-based line**, options          |
|                                                    | **decoded by name** and rendered as the line FileMaker Pro shows     |
|                                                    | (`filemaker` 0.8 names every step option too, though 41 of them come |
|                                                    | back as raw numbers; `fm` 0.6 exposes 51 step types only as numbered |
|                                                    | slots); a calc that names something the machine running the CLI      |
|                                                    | cannot look up — a plug-in function, say — reads back there as       |
|                                                    | `<Function Missing>`, while the export keeps the real text           |
|                                                    |                                                                      |
| `saxml4adt function <name>`                        | a custom function's body, its callers and what it calls, in one      |
|                                                    | answer — `filemaker` 0.8 gives the body with `read:customFunction`   |
|                                                    | and the callers with a separate `search:customFunction`; `fm` 0.6    |
|                                                    | cannot read a custom function at all                                 |
|                                                    |                                                                      |
| `saxml4adt broken-refs`                            | dangling Field/Script/Layout/TO references across the whole file,    |
|                                                    | including ones on layout objects — `filemaker`'s `problems` list is  |
|                                                    | reported per script                                                  |
|                                                    |                                                                      |
| `saxml4adt themes` / `styles <theme>`              | every theme in the file and every named style in each, in one answer |
|                                                    | — `filemaker`'s `read:theme` gives one theme's `namedStyleNames` at  |
|                                                    | a time. These are the only style names `create:layout` /             |
|                                                    | `addObjects` accept                                                  |
|                                                    |                                                                      |
| `saxml4adt duplicates script`                      | every name collision in the file, with the `id` of each — a by-name  |
|                                                    | `filemaker` op on one is `ambiguous`; address it by `id` (0.8; `fm`  |
|                                                    | 0.6 addresses writes by name only)                                   |
|                                                    |                                                                      |
| `saxml4adt taborder <layout>`                      | the tab order as FileMaker stores it, as one list — per-object       |
|                                                    | positions, gaps left by deleted objects, and objects that have **no  |
|                                                    | position at all** (anything the CLI adds without a `tabOrder`),      |
|                                                    | which is why CLI-added fields land unpredictably. `filemaker` 0.8    |
|                                                    | sets a position and `read:layout` with `detail` reports it per       |
|                                                    | object; the gaps and the unpositioned objects are what this adds.    |
|                                                    | `fm` 0.6 has no tab-order surface                                    |
|                                                    |                                                                      |
| `history` · `blame` · `diff` · `drift` · `touched` | what changed between exports, who changed it, and what changed       |
|                                                    | without anyone marking it — a live read only knows the present       |

## What it answers faster

`usages Table::field` · `refs-to layout X` · `callers` / `callees` / `triggers` · `describe layout|script|to|field` ·
`objects <layout>` (with parent ids and enclosing path) · `variables --problems` / `variable $$name` (assignments vs reads,
case-insensitive like FileMaker, spelling drift) · `search` (FTS5 over steps, calcs, comments, and object names, ranked ahead
of content hits) · `path TO-A TO-B` · `relations --suspicious` · `steps-of-type "Send Mail" --where "No dialog=Off"` ·
`fields-on <layout>` · `portals` · `unreferenced` (never "unused" — read its caveat).

## Freshness: an export is a snapshot

```bash
saxml4adt describe layout Settings                       # read
filemaker --file=… ops.ndjson                        # write with fm-cli
saxml4adt invalidate --from-ops ops.ndjson --as agent-2  # mark what the batch touched
saxml4adt describe layout Settings                       # -> meta.stale warns; --strict exits 3
saxml4adt refresh ~/exports/MyFile/SaveAsXML --catalogs LayoutCatalog   # re-ingest only that catalog
```

* `invalidate <kind> <name>` marks one object; `--from-ops` marks every write target in an fm-cli batch;
  `invalidate catalog ScriptCatalog` marks a whole catalog.
* `ops.ndjson` (also what `impact` preflights, below) is fm-cli's own batch format — one write per line, e.g.
  `{"op":"create:field","table":"PRF__Preferences","name":"smtpAddress","type":"text"}`.
* `--max-age 2h` refuses to answer from an export older than that (exit 4).
* `refresh --catalogs A,B` replaces only those catalogs from a new (possibly partial) export and clears their marks.
  FileMaker can produce a partial export: the *Save a Copy as XML* script step takes options JSON
  `{"catalogs_included":["ScriptCatalog"],"split_catalogs":true}`.
* `hooks/saxml4adt-invalidate-after-fm.sh` is a Claude Code `PostToolUse` hook that runs `invalidate --from-ops`
  automatically after any `filemaker …` or `fm …` command, so the discipline does not depend on the agent remembering.
* Hook discipline that is still on the agent: **write ops to a file** (ops on stdin/heredoc are invisible to the hook, which
  then marks every writable catalog dirty), and **export `SAXML4ADT_ACTOR`** so marks and `touched --by` can tell agents
  apart. The hook skips `--dry-run` and batches fm rolled back.
* Every object row records `modified_by`, `modified_at` and its `modifications` counter from the XML, so with an
  agent-specific FileMaker account a re-export also shows what that account touched.

## History and drift

Every object row carries the XML's own `modifications` counter and `modified_by`/`modified_at`. When a later export is
ingested (`build` or `refresh`), SaXML4ADT diffs those against the previous state and records what the export *showed*
changed — then reconciles it against what agents *said* changed:

```bash
saxml4adt refresh XML/SaveAsXML --catalogs ScriptCatalog   # prints drift: changes / unmarked / marked_unchanged
saxml4adt drift            # last refresh: predicted, changed-but-unmarked, marked-but-unchanged
saxml4adt history --by agent-bot --since 2026-08-28   # observed changes over time, with who and whether it was marked
saxml4adt touched --by agent-bot                      # straight from the export's stamps, no history needed
saxml4adt builds
```

**`unmarked_change` is the alarm this whole model exists for**: three agents and a person share one file, and the failure
that actually happens is a peer writing to a file you own without your knowing. `unmarked` changes mean someone edited
without telling the store (an agent that skipped `invalidate`, or a person in FileMaker Pro). `marked_unchanged` means a mark
was a false alarm or the write never landed. Give each agent its own FileMaker account and the export becomes its audit
trail.

### Every version, without keeping the XML

Each build also records what *changed inside* every object — attribute before/after, script steps as line hunks — in
`change_log`. Nothing else is kept, so the store stays small, yet any version since the store's second build can be
reconstructed by replaying the deltas backwards:

```bash
saxml4adt history --kind script --object "Nightly Sync"   # every build that changed it, with the deltas
saxml4adt show script "Nightly Sync" --at 3               # the script as build 3's export showed it
saxml4adt diff script "Nightly Sync" --from 3 --to 5      # attribute before/after + unified diff of steps
saxml4adt diff field "Contacts::email" --from 2           # …to current
```

Works for every kind (`layout`, `layout_object`, `field`, `table_occurrence`, `value_list`, `custom_function`, …); the
console's object panel shows the same timeline.

Web-viewer persistent stores (`ADT [<app>]` and friends, under `PersistentStoreCatalog`) get the same treatment: each build
records the payload's sha1, size, modification count, account and timestamp — never the payload — so a redeploy shows up as
an ordinary `change_log` row (old/new sha1 + size) via `history`/`show`/`diff`, same as any other object. `saxml4adt status`
also compares each `ADT [<app>]` entry's recorded sha1 against `webviewer-apps/<app>/dist/index.html` on disk and reports it
under a `webviewer` block, so a stale deploy is visible without opening FileMaker.

## Web console

```bash
saxml4adt serve                             # background server + browser; `serve --stop` ends it, 12 h idle timeout
saxml4adt serve --restart                   # stop + start in place, same port, no new tab (the open tab reconnects)
saxml4adt serve --foreground --port 8770    # in this terminal instead (Ctrl-C stops it)
saxml4adt export-web console.html           # self-contained snapshot with the data embedded
```

**Home is a review feed**: "since your last visit" — the builds you have not seen, then one row per changed object with a
pill (✓ marked / ⚠ unmarked / a twin-export notice), the kind and name, one line of what changed (step hunks, moved objects,
the attributes), and who touched it when. The name opens the object; the what-changed line opens it on the panel that shows
that change. *Mark all reviewed* advances your last-seen build (kept per store in this browser), and *show all builds →* is
History. The catalog map moved one click away, to the **Catalogs** tab and the rail.

From there: drill-down to objects with their marks, observed changes, steps, layout objects and references; a history view
with the per-refresh drift bars and who-touched-what; the query log. Marks can be made from the page when served live.

Every interaction is one of three verbs, and no click loses your place: **select** answers in place (clicking an object in a
layout preview outlines it and fills an inspector directly below — bounds, enclosure breadcrumb, theme style and local
overrides, field binding, last change; ⌥-click adds more; in a script, click a step and shift-click another for a range),
**peek** opens over the view and Esc restores it exactly, and **navigate** is the only thing that reroutes. Every list that
names an object — references, where-used, a layout's objects, a variable's scripts — peeks first: a slide-over brief with the
facts, its last change and its reference counts, and one **Open →** when you actually want to go there.

An **investigation trail** runs under the header: every deliberate navigation leaves a chip (kind + name), and clicking one
returns to that stop with its scroll position intact — an agent's stop lands there too, in amber. Back means the previous
chip; Home starts a fresh trail. It is session-local and sits above the browser's own Back, which keeps working.

The pane boundaries are **drag handles** (rail | list | detail); widths persist per browser and a double-click resets one.
**☰** collapses the catalog rail to a one-column icon strip. `docs/CONSOLE-IA.md` is the full contract, including every hash
parameter a deep link can carry.

### Pins and agent navigation

**📌 Pin** on any selection adds an entry to your pins — layout objects, a range of script steps, a calc fragment — instead of
replacing the last one. The header button shows a live count and peeks the list. Say "look at my pins" and the agent reads
all of them in one pull with the `pinned_items` MCP tool; reading grays them into a short history rather than deleting them,
so they stay re-askable until you clear them. Nothing is ever pasted into your prompt.

The other direction: an agent can put something on your screen with `console_show`, which drives the open tab to a view and
shows a toast saying what it is showing and why. Your previous view and scroll are saved first, and **Esc** — or the
`↩ Esc returns you` chip that outlives the toast — puts you back.

## MCP server

`saxml4adt mcp` speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio (JSON-RPC 2.0,
newline-delimited), so Claude Desktop, Claude Code, Cursor and any other MCP client can query the store directly — without
shelling out to `saxml4adt`. It is hand-rolled (stdlib + the rest of this package only; no `mcp` package dependency) and
read-only except for one tool, `mark_dirty`, which writes to the store's own freshness ledger (never to the FileMaker file —
`fm-cli` stays the only writer of that).

It also serves the concepts as resources — `saxml4adt://concept/the-hook-loop`, `saxml4adt://concept/marked-vs-unmarked` and
the rest, the same text `saxml4adt help <name>` prints. An agent that can call `mark_dirty` should be able to read what a
dirty mark *is*, and these are listed before the store is opened, so they answer in a project that has not built anything
yet.

```bash
saxml4adt mcp --print-config          # Claude Desktop snippet + the `claude mcp add` line, with an absolute --db path
saxml4adt mcp --db ~/projects/my-adt-project/saxml4adt.sqlite   # run it (a client normally launches this itself)
```

`--print-config` fills in an absolute path so the config works regardless of the client's working directory:

```bash
claude mcp add saxml4adt -- saxml4adt mcp --db /Users/you/projects/my-adt-project/saxml4adt.sqlite
```

or, in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "saxml4adt": {
      "command": "saxml4adt",
      "args": ["mcp", "--db", "/Users/you/projects/my-adt-project/saxml4adt.sqlite"]
    }
  }
}
```

**Tools** — one per read command that matters for an agent mid-task, each taking the same option names as its CLI counterpart
and returning the same `{meta, …}` envelope (so `meta.stale` still warns when a result touches a mark made since the export):
`overview`, `status`, `files`, `search`, `object` (= `describe`), `referenced_by` (= `refs-to`, impact analysis), `script` (=
`steps`), `layout_objects` (= `objects`), `layout_html`, `styling`, `style_suggestions`, `variables`, `history`, `show_at` (=
`show`), `diff`, `blame`, `perf`, `conventions_check` (= `conventions`), `unreferenced`, `query_log`, and `mark_dirty` (=
`invalidate`, the one write tool). Each result comes back as `content: [{type: "text", text: <json>}]` plus a matching
`structuredContent`.

Two more talk to the console rather than the export:

* **`pinned_items`** — the items the user pinned in the console for you to look at, each with a ready-to-read text block and
  the structured data behind it. Reading marks them read (they stay in the console, grayed); `{"include_read": true}` brings
  them back.
* **`console_show(to, note)`** — put something on the user's screen: drives their open console tab to a view, announces
  itself in a toast, and leaves Esc pointing back at where they were. Reports whether a tab actually applied it, and returns
  the full URL for when none is open. Needs the console to be running (`saxml4adt serve`) and to be launched from the project
  folder.

**Resources**: `saxml4adt://overview` (the same JSON as the `overview` tool) and `saxml4adt://layout/<name>.html` — one per
layout, the same rendered standalone-HTML preview `layout-html` writes to disk, with dummy sample data.

The server connects to the store lazily, so `initialize` / `tools/list` still answer before a build exists; a tool call
against a missing store comes back as that one call's error, not a dead connection. Nothing but JSON-RPC ever reaches stdout
— diagnostics go to stderr.

## Layout snapshots (macOS)

`saxml4adt snapshot` captures real FileMaker Pro layouts as PNGs — ground truth to compare against `layout-html`'s renders.
Once per run it raises the file's document window to the front of FileMaker's own window list (see the requirements below —
this step matters), then switches the live layout with AppleScript, captures the document window with Quartz +
`screencapture` (works on an occluded window, steals no focus), and records the layout FileMaker actually loaded, since an
`OnLayoutLoad` trigger can redirect.

```bash
saxml4adt snapshot "Contact Detail"                          # one layout -> ./Snapshots/Contact Detail.png
saxml4adt snapshot --all --out Snapshots/Empowered_Beginning --compare   # every layout + a side-by-side compare.html
saxml4adt snapshot "Contact Detail" "Invoice Detail" --file Empowered_Contacts --delay 2
```

Requirements:

* macOS, with FileMaker Pro running and the target file already open (not just hosted — open in this copy of Pro).
* Screen Recording permission for whichever app runs `saxml4adt` (Terminal, iTerm, etc.) — System Settings → Privacy &
  Security → Screen Recording.
* The `fmextscriptaccess` extended privilege on the account signed in to the file, for AppleScript layout control. Granting
  it does not take effect on an already-open file — close and reopen it first.
* **`go to layout` and `get name of current layout of database "X"` both act on that database's FRONT window — not
  necessarily the document window a user would recognize.** A hidden card window (ADT's "MCP Server Connector" window,
  present through ADT 0.6 and removed in 0.8, was the case that found this; any solution's own card window does the same) can
  be frontmost instead; when that happens, every switch and readback silently happens *there* while the visible document
  window never moves, so every captured PNG comes back byte-identical regardless of which layout was requested (`current
  layout` even reads back as matching, since it is honestly reporting the card window's layout — the mismatch only shows up
  as pixels that never change). `snapshot` handles this itself: before the sweep it raises the document window it found via
  Quartz to the front of FileMaker's own window list (`go to window`, or a System Events `AXRaise` fallback if that verb
  isn't in FileMaker's dictionary — the fallback needs Accessibility automation permission granted to the host running this
  command), so every later switch/readback in the run actually targets it. If you ever see a run come back with every PNG
  hashing identically, this raise is what to check first.
* A modal FileMaker dialog (a `Show Custom Dialog` step, the "Summarize" field dialog, …) blocking a layout switch is also
  handled automatically — it is detected, the app is activated and Escape is sent, and the switch is retried once before that
  one layout is given up on and the sweep moves on (see `warning`/`dialog_layouts` in the JSON summary). One bad layout — a
  dialog that doesn't clear, a timed-out AppleEvent, anything else — is always isolated to its own row; it never aborts the
  rest of the sweep.

`--compare` also renders each captured layout with `layout-html --sample dummy` into the output directory and writes
`<out>/compare.html`: the FileMaker PNG and the HTML render side by side per layout, for eyeballing fidelity. It is a plain
local file — nothing gets published anywhere.

The JSON summary reports, per layout, `{layout, actual, png, width, height, warning?}`, plus a top-level `dialog_layouts`
list of any layouts that hit a modal dialog. `actual` and `warning` only differ from the requested name when an
`OnLayoutLoad` trigger redirected the view; `--all` skips layout names starting with `.` and keeps going past a failed
switch, so one bad layout does not abort the run.

## Working without a server

No FileMaker Server, no Data API, no credentials — a file open locally in FileMaker Pro (FM Starting Point, or any solution
you have open on your own machine) is enough, and the console runs against the resulting store exactly the way it does for a
hosted project.

**Turn FileMaker sharing on before you start.** ADT reaches a file that is open in FileMaker Pro through FileMaker's own
sharing, so a file open in Pro with sharing enabled is the working shape — `adt connect` records it as
`fmnet://localhost/<File>`, and fm connects to it as a client. That is what lets the file stay open in Pro while `saxml4adt
install` writes to it. Without sharing, Pro holds a local file single-user and fm cannot open it at all: it fails with
`DBError 803` and the only way through is to close the file, install, and reopen it.

The account is not optional here either. A Full Access account must exist in the file and be recorded with
`adt connect --username <account>`, even for a file FileMaker itself would open without one — everything SaXML4ADT does
inside the file goes through fm-cli as that account.

There are then two ways to produce the export. By hand, with *Save a Copy as XML* in Pro, which needs nothing installed. Or
with `saxml4adt install`, which puts an export script in the file; running it, the script sees that the file is not hosted
anywhere else and writes the export straight into `XML/SaveAsXML/<File>_<stamp>/` beside the `.fmp12`, which is where `build`
already looks — it reads the newest. Both routes end in the same place; the script is worth the one-time install if you will
re-export more than a couple of times.

The stamp is not decoration. FileMaker's *Save a Copy as XML* refuses to overwrite a destination folder that already exists
and uniques the name instead — `<File> Copy`, then `<File> Copy 1` — so a fixed destination meant every export after the
first landed in a folder nothing was reading. A name that cannot repeat cannot be uniqued. `build` resolves an export by what
a directory holds rather than by what it is called, so a `<File> Copy 1` folder an older version left behind is still read,
and still pruned once it is in the store.

```bash
pipx install saxml4adt
mkdir my-project && cd my-project
# put the .fmp12 here, open it in FileMaker Pro, and turn sharing on

adt init .                                                   # ADT creates the project
adt connect --username <account> fmnet://localhost/<File>    # the shared local file, by its fmnet address
saxml4adt init                                               # hook, .gitignore, MCP registration, agent skill
```

The address, not the path. `<File>` is the file's name without `.fmp12`, and the target has to be the `fmnet://localhost/…`
form even though the file is on this machine: handed a path, fm opens the `.fmp12` itself, which Pro will not allow while it
holds the file — `DBError 803`, the failure this whole section is about. Handed the address, fm connects as one more client
and the file stays open.

Keep the `.fmp12` in the project folder: the export script writes beside the file, which is then also where `build` looks.
Nothing in this path calls the Data API — the account exists so fm-cli can open the file, not to log in to a server.
`saxml4adt init` installs the PostToolUse hook, adds `saxml4adt.sqlite*`, `.saxml4adt/` and `XML/SaveAsXML/` to `.gitignore`,
registers the MCP server in `.mcp.json` and writes the agent skill; `saxml4adt build` will find an export under
`XML/SaveAsXML/` on its own even without ever running `init`, so this step is convenience, not a requirement.

`saxml4adt serve`'s Setup screen (the one under *Install* above) has a first-class local mode: it names the file the project
is pointed at, says there is no Data API and no password to store, and offers *Build from files on disk* as the step that
applies here.

With the file open in Pro — locally, not just hosted-and-connected — run **File → Save a Copy as XML…**. Three of its options
matter here (confirm the exact wording against your copy of Pro; these are the option names, not necessarily the on-screen
labels):

* split the catalogs into one file per catalog rather than one combined XML file — `saxml4adt build` reads either shape, but
  everything below assumes the split one
* include the analysis / Database Design Report details — this is what makes tab order (`saxml4adt taborder`) available at
  all; skip it and every layout's object order goes unrecorded, not merely incomplete
* keep binary data in the export — the switch that decides whether button icons and layout images are written out at all;
  turn it off and every layout preview in the console renders without its images and icons, though everything else about the
  object is still tracked normally

Save into `XML/SaveAsXML/<File>/` next to the project folder — `<File>` is the live file's name without `.fmp12`, which is
what FileMaker names the split files after, and has to match the file ADT recorded. That unstamped name is what saving by
hand produces and `build` still reads it; only the installed export script stamps the folder. Then:

```bash
saxml4adt build
saxml4adt serve
```

Repeating the export and `saxml4adt build` later reconciles the store against whatever changed since — no watcher required,
though `saxml4adt watch` works here too: it polls the export root, and a new stamped export folder appearing under it
rebuilds the store from that one.

What this path cannot do: `export`, `pull` and `credentials` reach a server — the first two fetch an export the hosted file
cannot hand over any other way, and the third stores a Data API password there is nothing here to log in to. Everything
backed by fm-cli rather than the Data API does work: `install`, `uninstall`, `verify-layouts` and `saxml4adt filemaker` (or
`saxml4adt fm`) all open a local file by path. Nearly everything else behaves exactly as it does for a hosted project: every
query command, `refresh --catalogs` (against a fresh manual export instead of a pulled one), `mcp`, `watch`, `export-web`,
and `snapshot` (which drives FileMaker Pro directly over AppleScript and never touches the Data API, hosted or not).

`saxml4adt doctor` (and `init`'s own readiness check) judges a project against what its own target actually needs, so a
healthy local project reports `"ok": true` and exits 0. The checks that belong to the hosted path are listed separately under
`not_applicable`, each with the reason it was skipped — a stored password and the Data API, for a local file. A requirement
that does not apply is not a requirement you have failed to meet, and the distinction matters most here, where half the
checklist is about a server you do not have.

## Preflight an fm-cli batch

```bash
saxml4adt impact ops.ndjson
```

For each op: does the target exist in the export, is its name ambiguous, what references would a delete break, and do the
table / occurrence / theme names it mentions resolve — all before `fm` opens the file and takes the schema lock.

## Verify a write

```bash
saxml4adt build before/ --db a.sqlite && saxml4adt build after/ --db b.sqlite
saxml4adt diff-stores a.sqlite b.sqlite --script "Do Thing" --assert-only-changed 118   # exit 6 if anything else moved
```

## Conventions

`saxml4adt conventions` checks every table, table occurrence, field, script, variable and custom function name against a
naming-conventions file — `saxml4adt.conventions.json`, next to `saxml4adt.sqlite` in the project folder:

```json
{
  "version": 1,
  "tokens": {
    "code": "[A-Z]{3}", "Code": "[A-Z][a-z]{2}", "lcode": "[a-z]{3}",
    "Name": "[A-Z][A-Za-z0-9]*", "name": "[a-z][A-Za-z0-9]*", "NAME": "[A-Z][A-Z0-9_]*"
  },
  "rules": {
    "table":            { "pattern": "{code}__{Name}",        "example": "CNT__Contacts" },
    "table_occurrence": { "base": "{code}__{Name}", "related": "{lcode}_{code}__{Name}", "example": "cnt_ADR__Addresses" },
    "field":            { "pattern": "{name}",  "example": "firstName" },
    "field_global":     { "pattern": "{name}_g", "example": "sessionId_g" },
    "field_key":        { "pattern": "_{name}", "example": "_contactId" },
    "script":           { "pattern": "{Name}", "example": "Contact_Create" },
    "variable":         { "pattern": "${name}", "example": "$contactId" },
    "variable_global":  { "pattern": "$${NAME}", "example": "$$CURRENT_USER" },
    "custom_function":  { "pattern": "{name}", "example": "trimAll" }
  },
  "ignore": ["^zz_", "^Global$"]
}
```

Each `{token}` in a pattern substitutes the named regex from `tokens`; every other character is literal (a `$` in
`variable`/`variable_global` matches a literal `$`). A rule may give several named alternatives instead of one `pattern` —
`table_occurrence` above passes a name that matches either `base` (a table occurrence standing in for its own base table) or
`related` (one added for a specific relationship context, prefixed with the other table's `lcode`) — and a name passes the
rule if *any* alternative fully matches. `ignore` is a list of regexes checked against a name before it is checked at all (so
`zz_Archive__Old` or a stock `Global` table are never flagged).

`field_global` covers fields with FileMaker's *Global storage* on; `field_key` covers fields whose name looks like a key
(`/^_/` or `/id$/i`, case-insensitive) **and** that are actually used on either side of a relationship — a `customerNotes`
field ending in nothing key-shaped stays a plain `field` even if it happens to hold an ID-like value.

```bash
saxml4adt conventions --infer                  # guess one from the majority pattern already in the store
saxml4adt conventions --infer --write          # save the guess as saxml4adt.conventions.json (--force to overwrite)
saxml4adt conventions                          # check every name — the default once the file exists
saxml4adt conventions --kind field -f table    # one rule kind only
saxml4adt conventions --strict                 # exit 1 if anything violates
```

`--infer` classifies every existing name into a shape (`AAA__Xxx`, `aaa_AAA__Xxx`, `xxx`, `Xxx`, `xxx_g`, `_xxx`, `XXX`,
`Xxx_Xxx`, `xxx_xxx`, or `other`), picks the majority shape per rule kind, and reports a `confidence` (the share of names
that actually match it) plus the top-5 shape histogram — review those before `--write`ing an inferred file over a real one.
`--check` (the default once a file exists and `--infer` is not given) reports every name that does not match, with `expected`
(the alternatives it was checked against) and, where trivially derivable — camelCasing a field, uppercasing a global variable
— a `suggestion`; `table` and `table_occurrence` violations get no suggestion, since picking the right 3-letter code is a
human call.

## Multi-file projects

A solution split across several `.fmp12` files — a UI file plus one or more data files wired together with external data
sources — builds into **one store holding all of them**, with references resolved across the file boundary: a field in a data
file used only by layouts in the UI file no longer reads as unreferenced. `saxml4adt build --file A --file B` (repeatable) or
a `saxml4adt.project.json` listing `"files"` in the project root drives which files are ingested; `saxml4adt files` lists
what the store holds — objects per file, last build, and any unresolved external data source. Every query that names an
object takes `--file <name>` to narrow it to one file; left off, the answer covers the whole project — or, once the store
holds more than one file and one is marked **primary**, the primary file, with `--all-files` (alias `--all`) restoring
store-wide scope for that one call. Single-file projects, and multi-file ones with no primary set, are unaffected —
`file_id 1` everywhere is exactly the old behavior, byte-identical.

Mark the development file with `saxml4adt files --set-primary Empowered_Beginning` (or a `"primary"` key in
`saxml4adt.project.json`, which wins when both are set; `--clear-primary` removes the store's own mark). Once a primary is
set, fm-cli write commands (`variable --rename --apply`, `variables --fix-spelling --apply`, `uninstall`) refuse to target
any other file unless you pass `--file <that file>` explicitly — the primary is the file you are developing; the rest are
treated read-only from here. `export`/`install`/`uninstall --dry-run` still work per-file regardless, since pulling or
installing the export machinery on an old file is harmless.

`build` also guards against **copy-twins**: a file whose export shares more than 20% of its object uuids with a different
file already in the store — the same file present twice under different names, e.g. an old export left next to a newer one —
is held for confirmation (or, without a terminal, refused with exit 3) unless `--allow-twin` (alias `--yes`) is passed;
`saxml4adt files` reports any such pair it finds, with the overlap percentage.

Full design in [`docs/MULTI-FILE.md`](https://github.com/mw777eds/SaXML4ADT/blob/main/docs/MULTI-FILE.md).

## Boundaries

* Read-only. `fm-cli` remains the only writer.
* Structure only. A table can be wired everywhere and hold zero records; this tool cannot see data, whether layout objects
  render, or business facts.
* `unreferenced` ≠ unused. Manual-by-design scripts, external callers (Data API, WebDirect), and by-name runtime addressing
  are invisible here; targets that *could* be addressed dynamically are reported `UNDECIDABLE`.

## Known FileMaker limits (found while building this)

* **Save-as-XML omitted objects fm-cli appended to an existing layout** — before fm-cli 0.8.0 the appended objects were left
  out of the layout's stored object list, so the export (and Browse mode) did not show them while the live layout had them.
  The 0.8.0 release fixed that for new writes; a layout appended to by an earlier release — every 0.6 write — can still carry
  it. `saxml4adt verify-layouts` cross-checks every layout's object count against the live file through fm-cli.
* **An export writes to the file it is exporting.** fm-cli's blank modifier name (the original trigger) was fixed in 0.5, but
  *Save a Copy as XML* still replaces the modification record of a layout fm-cli created that has not yet been through an
  export — with the exporting session, at export time. One export settles it for that layout; a layout FileMaker Pro saved is
  never marked. Seen again on fm-cli 0.8.0: the `SaXML4ADT_Export` layout `install` creates was marked by its first export,
  the Pro-made layout beside it was not. SaXML4ADT's own writes pass `--user-name`, so FileMaker records the agent rather
  than `fm CLI <version>`. Details: [`docs/bug-reports/`](https://github.com/mw777eds/SaXML4ADT/tree/main/docs/bug-reports).

## Platforms

macOS and Windows (FileMaker Pro's platforms). macOS is what this is developed on; Windows uses the same code paths —
credentials via Windows Credential Manager (`keyring`), the hook as `saxml4adt hook --as "…"`, and the container transport,
which needs no ssh. Windows has had no hands-on run yet: if something breaks, `saxml4adt init` prints the readiness check to
include in an issue. The `--transport ssh` path assumes a Linux FileMaker Server Documents path (`--remote-docs` to
override).

## Releasing

Tag a version and push it: `git tag vX.Y.Z && git push origin vX.Y.Z`. The `publish` workflow builds the wheel and uploads it
to PyPI through trusted publishing (project `saxml4adt`, environment `pypi`) — no tokens in the repo, and the tag is checked
against `pyproject.toml`'s version and the suite is run before anything is uploaded, because a PyPI release cannot be
replaced, only yanked.

Releases live on [PyPI](https://pypi.org/project/saxml4adt/), which is what *Install* above and every other instruction here
means: `pipx install saxml4adt` (or `pip`). Installing from this repository is for working ON SaXML4ADT, or for picking up a
fix ahead of the next tag — see *Development* below, and the `git+` line under *Install*. `saxml4adt --version` says which
copy you are running and whether it came from a release or a checkout.

## Development

```bash
git clone https://github.com/mw777eds/SaXML4ADT && cd SaXML4ADT
python -m pip install -e '.[dev]'
python -m pytest -q                       # the skips need SAXML4ADT_TEST_EXPORT;
                                          # a few more skip without fm-cli on PATH
SAXML4ADT_TEST_EXPORT=/path/to/an/export python -m pytest -q     # + those against a real export
```

Layout: `saxml4adt/export.py` (find and repair the XML), `calc.py` (tokenize calcs), `steps.py` (decode script steps),
`ingest.py` (build the store), `queries.py` (every question), `conventions.py` (naming-conventions check + infer), `cli.py`
(typer app: commands, export transports, hook, init, install), `web.py` + `web/index.html` (console), `mcp.py` (MCP stdio
server).

[pypi]: https://pypi.org/project/saxml4adt/
[pypi-badge]: https://img.shields.io/pypi/v/saxml4adt.svg
[python-badge]: https://img.shields.io/pypi/pyversions/saxml4adt.svg
[ci]: https://github.com/mw777eds/SaXML4ADT/actions/workflows/ci.yml
[ci-badge]: https://github.com/mw777eds/SaXML4ADT/actions/workflows/ci.yml/badge.svg
