Metadata-Version: 2.4
Name: saxml4adt
Version: 0.1.3
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

**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 [`fm-cli`](https://github.com/claris/adt) on `PATH` with its keychain credentials for the file (`fm --file=… --username=… --store-credentials`) |
| 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 fm …`, 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, update `username` in `adt.json`
(or run `adt connect`) and `fm --forget-credentials` / `--store-credentials` its own entry —
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 answers that `fm` cannot

| | |
|---|---|
| `saxml4adt steps <script>` | every step with **0-based index and 1-based line**, options **decoded by name** — including the 51 step types `fm` exposes only as numbered slots (the XML's `position="N"` equals fm's slot index), and calcs `fm` renders as `<Function Missing>` |
| `saxml4adt function <name>` | custom function bodies (`fm` cannot read them) |
| `saxml4adt broken-refs` | dangling Field/Script/Layout/TO references, including ones on layout objects that FileMaker's own *problems* list omits |
| `saxml4adt themes` / `styles <theme>` | every theme in the file and its named styles — the only names `create:layout` / `addObjects` will accept |
| `saxml4adt duplicates script` | name collisions `fm` cannot address individually |
| `saxml4adt taborder <layout>` | the tab order as FileMaker stores it — per-object positions, gaps left by deleted objects, and objects that have **no position at all** (everything `fm` adds), which is why fm-added fields land unpredictably; `fm` has no tab-order surface |

## 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
fm --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 `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).

```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 (this package's own "ADT MCP Server Connector" window is a real example) 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 connector 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 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 --target ./FM_Starting_Point.fmp12    # ADT records the file in adt.json
adt connect --username <account> <target>      # a Full Access account (a name, never a password)
saxml4adt init                                 # hook, .gitignore, MCP registration, agent skill
```

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 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 can omit objects fm-cli appended to an existing layout** —
  the live layout has them, the export does not. `saxml4adt verify-layouts`
  cross-checks every layout's object count against the live file through fm-cli.
* **fm-cli writes a blank modifier name** on everything it creates or updates,
  and the first export overwrites that blank on *layouts* with the export session
  (`SaXML Export - <account> <pid>`). 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. `v0.1.0` is on PyPI now, which is why *Install* above leads with
`pipx install saxml4adt`; the next tag replaces it the same way.

## Development

```bash
git clone https://github.com/mw777eds/SaXML4ADT && cd SaXML4ADT
python -m pip install -e '.[dev]'
python -m pytest -q                                   # 190 passed, 7 skipped — the skips need SAXML4ADT_TEST_EXPORT
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).
