Metadata-Version: 2.5
Name: connect-flow
Version: 0.1.1
Summary: Auto-arrange Amazon Connect contact flow JSON into a readable layout without changing behaviour
Project-URL: Homepage, https://gitlab.com/mludvig/connect-flow
Project-URL: Repository, https://gitlab.com/mludvig/connect-flow
Project-URL: Issues, https://gitlab.com/mludvig/connect-flow/-/issues
Project-URL: Changelog, https://gitlab.com/mludvig/connect-flow/-/blob/master/CHANGELOG.md
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Utilities
Requires-Python: >=3.11
Requires-Dist: boto3>=1.34
Provides-Extra: graphviz
Requires-Dist: pygraphviz>=2.0.1; extra == 'graphviz'
Description-Content-Type: text/markdown

# connect-flow

**A minimal IaC toolkit for Amazon Connect contact flows: promote a flow
between environments, verify it, and keep its layout readable.**

A CLI built around one idea: a contact flow JSON file in your repo should be
an environment-neutral source of truth, and each environment (dev, test,
prod) a rendering of it. `copy`/`normalize` translate the environment-specific
IDs and ARNs baked into a flow so one file can be promoted anywhere;
`diff`/`lint`/`digest` verify it along the way; `prettify` auto-arranges its
block layout without changing its behaviour.

---

## Why this exists

**The problem.** A contact flow exported from Amazon Connect is full of
environment-specific identifiers baked directly into the JSON — Lex bot
alias ARNs, Wisdom AI-agent ARNs, Lambda ARNs, sibling contact-flow/
flow-module/queue/prompt ARNs. Promoting a flow from dev to test to prod
means finding and swapping every one of them, by hand, in a multi-thousand-
line file — a process that's slow, error-prone, and leaves no record of
*which* identifiers changed or why. And once a flow is edited in three
places, there's no cheap way to tell whether the copy in git still matches
what's actually deployed.

**The solution.** `connect-flow copy`/`normalize` translate a flow's
identifiers against a per-environment map file, so one JSON file in git
becomes the environment-neutral template and each environment a rendering of
it — see [Promoting a flow between
environments](#promoting-a-flow-between-environments) below.
`diff`/`lint`/`digest` answer "has this drifted" and "is this well-formed"
without a console round trip. And because flows in git get edited by
several people, their *layout* rots too — crossing edges, error paths
visually competing with the happy path, blocks wherever they happened to
land mid-edit — which is what `prettify` is for.

Every rewrite this tool makes, whether it's an identifier or a coordinate,
goes through the same discipline: patch only the literals that are supposed
to change, in place, with a targeted regex — never `json.dumps` over the
whole document, which would rewrite every line and bury any accidental
behavioural change inside an unreadable diff. A semantic digest (a hash of
everything behavioural) is taken before and after every write; if it
doesn't match what the operation is supposed to produce, nothing is written
and the tool reports a safety violation instead. See
[`docs/safety.md`](docs/safety.md) for the full mechanism.

So a layout rewrite's diff is exactly the numbers that moved:

```diff
       "ActionMetadata": {
         "560e67f4-4322-4788-831e-e49c2a0e7dd2": {
-          "position": { "x": 1284, "y": 61 },
+          "position": { "x": 240, "y": 120 },
           "children": ["d3ee852c-65de-4180-95a6-c8bd1f10b5bf"]
         },
```

And the layout itself goes from this to this:

```
   BEFORE — edited by hand over a year    AFTER — one command

   [Play prompt]        [Disconnect]      [Entry]
        │   ┌────────────────┘  ▲            │
   [Entry]  │   [Error handler] │            ▼
        └───┼────┐      │  ▲    │         [Get input] ──► [Compare] ──► [Play prompt]
   [Compare]│  [Get input]─┘    │              ╷              ╷               │
        │   └──────┘     └──────┘              └┄┄┄┄┄┬┄┄┄┄┄┄┄┄┘               ▼
        └───────────────────────┘              [Error handler]          [Disconnect]

   happy path zig-zags, error paths       happy path straight and left-to-right,
   cross it and each other                error paths pushed below, out of the way
```

## What you get

`connect-flow` uses git-style subcommands. Every one of them takes a
`TARGET` that is either a **local path** or a **live flow / flow-module
ARN**, interchangeably — so `diff`-ing a file in your repo against what's
actually running in production is one command, not a download step plus a
comparison step.

| subcommand | purpose |
|---|---|
| `connect-flow copy --from SOURCE --to DEST [--from-map FILE] [--to-map FILE] [--apply]` | promote a flow 1:1, translating identifiers through an intermediate `<<...>>` token form; a live `--to` is previewed unless `--apply` |
| `connect-flow normalize TARGET... [--reverse] [--map FILE]` | the strictly offline form of the same translation — local files only, no AWS |
| `connect-flow prettify TARGET...` | rewrite block positions into a readable layout |
| `connect-flow diff TARGET_A [TARGET_B]` | semantic diff between two targets, local and/or remote |
| `connect-flow lint TARGET...` | structural checks: dangling transitions, unreachable actions, empty branches |
| `connect-flow digest TARGET...` | print each target's semantic digest — "is my file still what's live?" |
| `connect-flow list [--instance ID-OR-ALIAS --flows\|--flow-modules]` | list instances, or flows/flow modules within one, or describe one via `--flow`/`--flow-module` |

Without `--from-map`/`--to-map`, `copy` is a byte-verbatim transfer — fetch a
remote flow to disk, or upload a local file to a remote flow, with no
identifier translation at all.

`--profile`/`--region`/`--json`/`--quiet` are accepted either before or after
the subcommand, aws-cli style: `connect-flow --profile lab --region us-east-1
list` and `connect-flow list --profile lab --region us-east-1` are
equivalent.

### How a run works

```
  SOURCE (--from)                                            DEST (--to)
  ───────────────                                             ───────────
                                                            ┌─► local file  (+ .bak)
  local file ─┐                                             │
              ├─► parse ─► translate/layout ─► patch text ──┼─► live ARN, preview only
  live ARN  ──┘     │      (from-map/to-map,   (regex,      │   (--apply to write)
                    │       dot)                in place)   │
                    └──► digest ── compare ──┘               └─► stdout (--emit-map only)
                                      │
                                   mismatch ───► refuse to write, exit 2
```

`prettify` is a different `translate/layout` step over the same pipeline —
geometry only, no identifier maps — with the same digest safety gate.

## Promoting a flow between environments

`copy` and `normalize` are two thin argument shapes over one engine: read a
flow, substitute identifiers against a map file, optionally re-layout, write
it somewhere. They differ only in which sources/destinations are legal:

| verb | source | destination | touches AWS |
|---|---|---|---|
| `copy` | local, live ARN, or `INSTANCE/NAME` (`--from`) | local file, or live (`--to`, previewed unless `--apply`) | reads (+ writes with `--apply`) |
| `normalize` | local file only | local only | never |

`copy` translates in one direction per map: `--from-map` normalises
`--from`'s real identifiers to `<<...>>` placeholders, `--to-map` resolves
those placeholders to `--to`'s real identifiers — both are optional and
independent, so a single `copy` can do either half, both, or neither
(a verbatim transfer). `normalize` instead takes one map and a `--reverse`
flag, since it always has exactly one local file on both ends.

### The map file

One JSON file per environment, in the shape
`{"<<ARN:Some Name>>": "arn:aws:...", "<<ID:Some Name>>": "22222222-..."}`
— flat, or grouped into named sections (`{"lexv2": {...}, "others": {...}}`);
both are accepted, and a grouped file's section names are preserved when a
skeleton is emitted. A plain, non-`<<...>>` key such as `IVR_BOT_AGENT_ID`
works too — a placeholder is whatever the map says it is, not only a
`<<...>>` token.

### Bootstrap a map for a flow that doesn't have one yet

`--emit-map` discovers every ARN-shaped literal and documented bare-id field
in a flow and writes them as a map — nothing is written to the flow itself.
Each entry is named automatically wherever a name is available: a Lambda ARN's
function name is parsed straight out of the ARN (works even from `normalize`,
which never touches AWS); a `ContactFlowId`/`FlowModuleId`/`QueueId` is
resolved to its live Name via a Describe call against `copy`'s `--from`
instance (turned off with `--no-auto-resolve`, same flag as live-to-live
`copy`). Anything with no name available — like the Lex bot-alias ARN below —
falls back to a `<<ARN:RENAME-ME-HASH>>`/`<<ID:RENAME-ME-HASH>>` key, HASH a
short hex digest of the value itself (so the key a given value gets doesn't
depend on the order flows/modules were fed into `--emit-map`).

`--to` is still required but never written to in this mode:

```
$ connect-flow copy --from contact-flows/main-ivr.json --to flows/main-ivr.json \
    --emit-map maps/dev.json
$ cat maps/dev.json
{
  "discovered": {
    "<<ARN:RENAME-ME-8ed6922a>>": "arn:aws:lex:ap-southeast-2:000000000000:bot-alias/22222222-2222-2222-2222-222222222222/33333333-3333-3333-3333-333333333333"
  }
}
```

Rename any remaining `RENAME-ME` keys by hand to something meaningful — the
edited file *is* that environment's map. Running `--emit-map` again against
the same `FILE` — the natural shape of a loop, once per downloaded flow or
module — merges in, rather than overwrites: existing entries and groups are
kept as-is, and only genuinely new values are appended, so the file
accumulates into one shared map across the loop.

### dev → committed template → test

```
# dev's live flow -> an environment-neutral file, committed to the repo
connect-flow copy --from arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --from-map maps/dev.json --prettify --to flows/main-ivr.json --profile dev

# template -> test instance (maps/test.json holds test's real values for the same keys);
# previewed first, applied once the diff looks right
connect-flow copy --from flows/main-ivr.json --to-map maps/test.json \
    --to arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --profile test
connect-flow copy --from flows/main-ivr.json --to-map maps/test.json \
    --to arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --profile test --apply --publish --yes
```

`copy --to`, whether previewing or applying, refuses (exit 2) if the
destination's live content no longer normalises back to the source's
template form through `--to-map` — i.e. someone edited it outside this
pipeline since it was last copied — unless `--force`.

### Offline: re-point or CI-gate a local file, no credentials needed

```
connect-flow normalize flows/main-ivr.json --reverse --map maps/test.json -o /tmp/rendered.json
connect-flow normalize contact-flows/*.json --map maps/dev.json --check    # CI gate
```

`normalize` never touches AWS on its own code path — no credentials, no
network calls — which is what makes it the right verb for a pre-commit hook
or a CI job that has no AWS credentials at all.

### Unresolved identifiers are a hard error

If a template still contains a `<<...>>` placeholder — or the resolve/
normalise direction leaves any of the map's own literals in place — after
translation, that's refused (exit 2), not silently written: a template with
a leftover placeholder is exactly the failure mode this feature exists to
catch. `--force` downgrades it to a warning.

See [`docs/adr/0001-cross-environment-id-mapping.md`](docs/adr/0001-cross-environment-id-mapping.md)
for the design decisions behind this (placeholder style, map format, the
duplicate-value and substring-shadowing rules),
[`docs/adr/0009-copy-verb.md`](docs/adr/0009-copy-verb.md) for `copy`'s
two-pass design, and [`docs/safety.md`](docs/safety.md) for how the rewrite
itself is proven safe.

## `prettify`

Run `connect-flow prettify --help` for all options.

*Example uses:*

### Reformat all contact-flows in a directory in-place

```
connect-flow prettify contact-flows/*/*.json --in-place
```

### What a reformat actually looks like

Here's `tests/fixtures/messy.json` in the Connect designer, before and after
`connect-flow prettify` — same flow, same behaviour, same digest, only the
block positions rewritten:

**Before** — the happy path doubles back on itself and the error branches
cross it:

[![before](docs/images/messy.before.jpg)](docs/images/messy.before.jpg)

**After** — the happy path runs left to right along the top, error branches
drop below it:

[![after](docs/images/messy.after.jpg)](docs/images/messy.after.jpg)

### Use --render to see that before/after without opening the console

`--render` writes a `STEM.before.png` / `STEM.after.png` pair — `connect-flow`'s
own sketch of the graph, drawing the blocks where the rewrite will put them.
It combines with `--check`/`--dry-run`, which write nothing at all, so you can
look before letting the tool near the file:

```
connect-flow prettify contact-flows/my-flow.json --dry-run --render --render-dir /tmp/renders
```

The report says whether the layout actually got better, in numbers:

```
$ connect-flow prettify tests/fixtures/messy.json --dry-run --render --render-dir /tmp/renders
CHANGED tests/fixtures/messy.json
    read: tests/fixtures/messy.json
    wrote: rendered
    before: crossings=2 backward_happy_edges=4 overlaps=0 canvas=1670x1020
    after : crossings=0 backward_happy_edges=0 overlaps=0 canvas=1050x680
```

Edge crossings and backward happy-path edges both to zero, on the same flow
pictured above.

## `diff`

Run `connect-flow diff --help` for all options.

`diff` compares two targets *semantically* — it answers "does this behave
differently", not "are these files byte-identical". Layout, key order and
designer churn are normalised away first (see [Flow normalisation
explained](#flow-normalisation-explained) below). Either side can be a local
path, a live ARN, or `INSTANCE/NAME-OR-ID` addressing (`--kind
{flow,flow-module}`, default `flow`, disambiguates which resource the name
refers to — see [`docs/adr/0010-diff-instance-addressing.md`](docs/adr/0010-diff-instance-addressing.md)),
so file↔file, file↔live and live↔live all work the same way. It exits `1`
when the two differ, which makes it usable as a CI check.

*Example uses:*

### See what actually changed between two flows

Here `tiny-edited.json` is a copy of the fixture with one prompt reworded:

```
$ connect-flow diff tests/fixtures/tiny.json tiny-edited.json
A: tests/fixtures/tiny.json
B: tiny-edited.json
  ~ e5565ba7-ebc9-43da-8d9e-94412c33ab4b

--- e5565ba7-ebc9-43da-8d9e-94412c33ab4b (tests/fixtures/tiny.json)
+++ e5565ba7-ebc9-43da-8d9e-94412c33ab4b (tiny-edited.json)
@@ -1,7 +1,7 @@
 {
   "Identifier": "e5565ba7-ebc9-43da-8d9e-94412c33ab4b",
   "Parameters": {
-    "Text": "Hello!"
+    "Text": "Hello, welcome to support!"
   },
   "Transitions": {
     "Conditions": [],
```

A per-Identifier summary (`+` added, `-` removed, `~` changed) followed by a
unified diff of just the affected blocks — rather than of the whole file,
where one edited prompt would be buried among thousands of unchanged lines.
`--json` emits the same structurally.

### Confirm a reformat changed nothing

Two files with different layouts and identical behaviour are *identical* to
`diff`, which is the whole point:

```
$ connect-flow diff tests/fixtures/messy.json tests/fixtures/messy.pretty.json
identical: tests/fixtures/messy.json == tests/fixtures/messy.pretty.json
```

### Has my repo drifted from what's really deployed?

```
connect-flow diff contact-flows/main-flow.json \
    arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --profile prod
```

### Compare the same flow across two environments by name

`INSTANCE/NAME-OR-ID` addressing (ADR 0008) works on either side, avoiding
the need to look up ARNs by hand:

```
connect-flow diff \
    dev-instance/"CSC Support Flow" \
    prod-instance/"CSC Support Flow" \
    --profile prod
```

### Did someone leave an unpublished draft in the console?

`--both-stages` compares one flow's saved draft against its own published
content — a single ARN, no second target:

```
connect-flow diff arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --both-stages --profile prod
```

### Compare a Terraform template against the deployed flow

A `.json.tftpl` is full of `${...}` placeholders that will never match the
resolved ARNs in the live flow. `--normalize-opaque` replaces every
placeholder *and* every ARN-shaped literal with one `<opaque>` token before
comparing, so the structural comparison still works:

```
connect-flow diff contact-flows/main-flow.json.tftpl \
    arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --normalize-opaque --profile prod
```

It's off by default because it deliberately hides a real class of bug — the
wrong ARN wired into a block — so reach for it only when comparing a
template to its rendered result.

### Compare the same flow lineage across two instances

A flow copied from one instance to another keeps its own identity, but
every `ContactFlowId`/`FlowModuleId`/`QueueId` it references — an "Invoke
Flow Module" block, for example — gets a fresh id in the new instance, even
when the referenced flow/module/queue is genuinely "the same one" by name.
`--normalize-opaque` also resolves these against each target's own instance
(a live `Describe*` call), replacing the id with `<<FlowModule:Name>>`
(etc.) — so two references that both resolve to the same Name stop looking
like a diff, while a reference that resolves to a *different* Name still is
one:

```
connect-flow diff arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    arn:aws:connect:ap-southeast-2:000000000000:instance/3333.../contact-flow/4444... \
    --normalize-opaque --profile prod
```

This only applies to a remote (ARN) target — there's no instance to resolve
a bare id against for a local file. A reference id that can't be resolved
(deleted resource, no permission) is left as-is and a warning is printed,
rather than silently hidden.

## `lint` and `digest`

Run `connect-flow lint --help` / `connect-flow digest --help` for all
options. Both are strictly read-only — there is no writer path in either, so
neither can ever modify a flow, local or live.

*Example uses:*

### Catch broken wiring before it reaches Connect

`lint` reports dangling transitions (a `NextAction`/`Conditions[]`/`Errors[]`
entry pointing at an Identifier that doesn't exist), blocks unreachable from
`StartAction`, a missing `StartAction`, and `Compare`/`DistributeByPercentage`
blocks with no conditions at all. It exits `1` if any target has findings:

```
$ connect-flow lint tests/fixtures/unreachable.json
ISSUES tests/fixtures/unreachable.json
    unreachable: '4008265c-66ba-4495-b465-9c586352ff0b' is not reachable from StartAction
    unreachable: '41b9ba6c-fef3-48dd-849c-425afcd58a7c' is not reachable from StartAction

$ connect-flow lint tests/fixtures/messy.json
OK     tests/fixtures/messy.json
```

These are the mistakes a hand-edited or templated flow makes and the console
never shows you, because you only find out when a call falls off the end of
a branch at 2am.

### Answer "is my file still what's live?" in one line

`digest` prints the semantic hash of each target — the same normalised form
`diff` compares — so a whole flow reduces to one comparable string:

```
$ connect-flow digest tests/fixtures/messy.json tests/fixtures/messy.pretty.json tests/fixtures/tiny.json
2afe14e5f2b6ec64122ab9e03e866d343a96ce43ff4f259ece5d166b988cf1b1  tests/fixtures/messy.json
2afe14e5f2b6ec64122ab9e03e866d343a96ce43ff4f259ece5d166b988cf1b1  tests/fixtures/messy.pretty.json
50c2bbe077f06890311f33d13d9ece8d39290b957d685eeb83c52d8fddfae72d  tests/fixtures/tiny.json
```

The first two are the messy and prettified versions of one flow: different
files, different layouts, one digest. That's the property the whole tool
rests on.

Mixing local and remote targets in a single invocation is the quick "did
anything drift" sweep — no diff to read, just two strings to compare:

```
connect-flow digest contact-flows/main-flow.json \
    arn:aws:connect:ap-southeast-2:000000000000:instance/1111.../contact-flow/2222... \
    --profile prod
```

Both accept `--stage {published,saved}` (default: published) for remote
targets, and the same `--instance`/`--flow`/`--flow-module`
addressing as `prettify`.

## Flow normalisation explained

`diff` and `digest` both work on one canonical form of a flow rather than on
its raw JSON, and `prettify` uses the digest of that same form as its safety
check. Normalisation is what makes all three trustworthy: without it, a flow
re-saved in the console with no edit at all would look "changed" every time,
and a tool that cries wolf on every save gets ignored.

What gets normalised away, and why none of it is behavioural:

| normalised | why it isn't a behavioural difference |
|---|---|
| all geometry — `position`, `entryPointPosition`, annotation `size` | pure layout; the whole point of `prettify` is to change exactly this and nothing else |
| `Actions[]` array order | Connect dispatches by Identifier reference, not array position, so the order blocks appear in the file means nothing |
| JSON key order and formatting | whitespace and key ordering carry no meaning to Connect |
| `conditionMetadata[].id` on `Compare`/`DistributeByPercentage` | designer display decoration with no counterpart in `Transitions.Conditions[]`, and Connect regenerates it on every save even with no edit |
| a compound block's hidden child Identifier (`diff` only) | e.g. inside a "Create Wisdom Session" block; re-randomised on every save, so `diff` aliases it to `<parent-identifier>#<index>` and compares that |
| `${...}`/`<<...>>` placeholders and ARN literals (`diff --normalize-opaque`, opt-in) | lets a Terraform template, or a `copy`/`normalize` token-form template, be compared to its rendered result; off by default because it also hides a genuinely wrong ARN |
| `ContactFlowId`/`FlowModuleId`/`QueueId` referenced from inside a flow, resolved to the referenced resource's Name (`diff --normalize-opaque`, remote targets only) | these ids aren't stable across instances, only the Name is — see [ADR 0007](docs/adr/0007-normalize-opaque-reference-resolution.md) |
| the whole designer layer — `ActionMetadata`, its key order, `Annotations`, `snapToGrid` (`diff --functional`, opt-in) | affects only how the console draws the flow; it's rewritten on every save, so two snapshots of an unedited flow always differ here — see [ADR 0016](docs/adr/0016-functional-only-comparison.md) |

The last two rows are orthogonal axes and compose — `--functional` chooses
which fields are compared, `--normalize-opaque` chooses how values inside the
surviving fields are matched:

```bash
# same instance: is the saved draft functionally the published flow?
connect-flow diff 'arn:...:contact-flow/2222...' 'arn:...:contact-flow/2222...:$SAVED' --functional

# across instances: same logic, different queue/flow ids either side
connect-flow diff dev.json 'arn:...:contact-flow/3333...' --functional --normalize-opaque
```

What is deliberately **not** normalised, because it *is* behavioural:

- **`Transitions.Conditions[]` order** — Connect evaluates conditions
  first-match, so reordering two branches changes which one runs.
- **`ActionMetadata` key order** — not behavioural at runtime, but it's
  tracked anyway in the digest (as a separate `ActionMetadataOrder` field),
  because the writer must prove its own rewrite didn't disturb it. Pass
  `diff --functional` to leave it, and the rest of the designer layer, out of
  a comparison between two independently-saved flows.
- **Everything else** — `Parameters`, block `Type`, `StartAction`,
  `Settings`, annotations' text, and any unrecognised top-level `Metadata`
  key are all carried into the canonical form verbatim. The digest is
  sensitive to every one of them by design; see
  [`docs/safety.md`](docs/safety.md) for how that's tested rather than
  merely intended.

## Working against live Amazon Connect instances

`connect-flow` extends the same _"provably no behavioural change"_ guarantee
across the network: it can fetch a flow or flow module straight from a live
Connect instance, prettify it, and push it back, with a rollback point and
post-write verification.

### AWS credentials

Use the usual `--profile` and/or `--region` cli parameters, or `AWS_*` environment
variables.

`connect-flow list` on lists the Connect instances visible to those credentials;
add `--instance` plus `--flows` or `--flow-modules` to list resources within one instead:

```
connect-flow list --region us-east-1 --profile lab

connect-flow list --instance 11111111-1111-1111-1111-111111111111 \
    --flow-modules --region us-east-1 --profile lab
```

`--instance` accepts either a bare instance id or an alias, resolved via an
exact (case-sensitive) name match — no separate flag for each. Pass
`--flow`/`--flow-module` to `list` instead to describe that one flow — its
ARN, description, version and last-modified time (including the instance it
lives in) — rather than enumerating everything in the instance; each also
accepts either a bare id or a name, resolved the same way:

```
connect-flow list --instance 11111111-1111-1111-1111-111111111111 \
    --flow "Main Menu" --region us-east-1 --profile lab
```

A remote target can also be addressed via `--instance` plus a repeatable
`--flow`/`--flow-module`, instead of a full ARN, with `--region`
required in that form:

```
connect-flow prettify --instance lab-instance \
    --flow-module "Main Menu" --region us-east-1 \
    --dry-run --diff --profile lab
```

`--flow`/`--flow-module` also accept a full ARN, or a value of the form
`INSTANCE/NAME-OR-ID` that names its own instance (id or alias) — useful for
addressing a flow in a *different* instance than `--instance`, or without
`--instance` at all:

```
connect-flow list --flow "lab-instance/Main Menu" --region us-east-1 --profile lab
```

**A remote write always defaults to the unpublished draft (`:$SAVED`), never
the live content** — pass `--publish` to write live instead:

```
# writes a draft only, with a rollback snapshot; live content is untouched
connect-flow prettify arn:aws:connect:us-east-1:000000000000:instance/1111.../flow-module/2222... \
    --in-place --profile prod

# writes the live content, unattended
connect-flow prettify --instance 1111... --flow-module 2222... --region us-east-1 \
    --in-place --publish --yes --profile prod
```

Every remote write is guarded four ways:

```
  1. version snapshot   immutable pre-change copy   (--snapshot before|after|both|none)
  2. sha256 check       taken immediately before the write — refuses on conflict
  3. read-back verify   re-reads what Connect stored, recomputes its digest
  4. confirmation       mandatory (--yes) rather than skippable off a TTY
```

`copy --to` additionally refuses to upload a file whose digest has drifted
from the destination's live content — a straight digest compare for a
map-less (byte-verbatim) copy, or the reverse-normalisation drift check
described [above](#promoting-a-flow-between-environments) when `--to-map` is
given — unless `--force`. See
[`docs/safety.md`](docs/safety.md#safety-for-remote-targets) for the full
detail, including the residual race the sha check narrows but can't close.

The pre-change snapshot depends on `CreateContactFlow{,Module}Version`,
which Connect only actually supports for Campaign-type contact flows — for
anything else it fails outright, and the write is refused with a pointer to
`--local-backup DIR` (writes the pre-change content to `DIR/STEM.json`
first, no dependency on that API) or `--force` (proceeds with no rollback
point at all).

The remote-write guardrail flags (`--yes`, `--force`, `--publish`,
`--no-verify`, `--snapshot`, `--local-backup`) have no short forms — a
remote write is never one keystroke. Plain I/O-destination flags like
`--output`/`-o` aren't subject to that: they don't touch a live instance by
themselves, so there's no safety reason to make them harder to type.

## Install

```
pipx install connect-flow
```

Requires Python 3.11+. The base install only needs `boto3`, for
live-instance support — `boto3` needs no configuration to run
`normalize`/`digest`/`lint` on local files or `diff` on two local files,
since those never make an AWS call.

`prettify`, `copy --prettify`/`normalize --prettify`, and `--render` also
need `pygraphviz` to compute a layout; install it with the `graphviz` extra:

```
pipx install "connect-flow[graphviz]"
# or, if connect-flow is already installed:
pipx inject connect-flow pygraphviz
```

`pygraphviz`'s wheel bundles the Graphviz libraries, so no system Graphviz
install is needed either way. Running a layout command without the extra
installed fails cleanly with an error telling you to install it.

## Use it as a pre-commit hook in your flows repo

If you keep exported contact flow JSON in a git repo, `connect-flow` can run
as a pre-commit hook and fail the commit whenever a flow's layout has
drifted, so the tidy layout is the state the repo is always in, rather than
something someone remembers to run.

This is set up in **the repo that holds the flow JSON**, not here.
`connect-flow` ships a [`.pre-commit-hooks.yaml`](.pre-commit-hooks.yaml),
which is the manifest offering the hook to other repos to install.

Add this to your flows repo's own `.pre-commit-config.yaml`:

```yaml
- repo: https://gitlab.com/mludvig/connect-flow
  rev: master
  hooks:
    - id: connect-flow
      files: ^contact-flows/.*\.json$
      ## Uncomment to enable in-place re-formatting
      # args: [prettify, --in-place]
```

Set `files:` to wherever your flow JSON actually lives. The hook's default
pattern matches every `.json` in the repo, which will also feed it
`package.json` and friends.

By default the hook runs `connect-flow prettify --check`: it writes nothing
and fails the commit if a flow file would be reformatted — well suited to a
CI gate too.

## Exit codes

| code | meaning |
|---|---|
| `0` | success — nothing changed, or a write succeeded |
| `1` | `--check`/`diff`/`lint` only: at least one target would change, differs, or has findings |
| `2` | flow safety violation (would-be behavioural change), or a target failed to parse / isn't a contact flow |
| `3` | bad usage (bad flags, missing args, a directory passed as a target) |
| `4` | an AWS/remote failure — credentials, access denied, a sha256 conflict, or a resource that doesn't exist |

## Going deeper

- [`docs/design.md`](docs/design.md) — why Graphviz `dot` rather than a
  hand-rolled layout engine, with the edge-crossing measurements that
  settled it.
- [`docs/data-model.md`](docs/data-model.md) — how a flow's ports and
  happy/error paths are modelled.
- [`docs/safety.md`](docs/safety.md) — exactly what guarantees "never
  changes behaviour", and how each one is enforced rather than merely
  asserted.

## License

MIT — see [`LICENSE`](LICENSE).
