This file is the single source of truth for coding agents working on the
n8n-decanter CLI itself (not on a synced workflow dir — that's the template's
AGENTS.md). It is tool-agnostic: Codex and opencode read it natively, and
Claude Code reads it through a one-line import in CLAUDE.md. Everything —
response style, project rules, changelog/docs/backlog duties, git workflow,
commands, architecture, and the shared recipes — lives here; keep the rules in
one place and let the per-agent files stay thin pointers.
PLAN.md is a separate, complementary source of truth: it is the CLI's
design document (data model, flows, past decisions). This file governs how
to work on the repo; PLAN.md governs what the CLI is. Keep both current and
don't let either drift from the code.
- Very compact — no long conclusions or wrap-up prose.
- Prefer bullet points over paragraphs.
- Highlight important things and decisions so they stand out.
Every session gets a label so concurrent sessions stay legible:
- Task maps to a numbered backlog Plan →
[NN] <name>— the bare plan number in brackets (e.g.[30] agent ergonomics). - Task has no plan →
[MISC] <name>(e.g.[MISC] plans reorg).
<name> is very short and precise — 2–5 words describing the actual work.
An agent can't rename its own session — there is no model-invokable rename
in Claude Code (/rename is a human-only slash command). So the agent's job is
to surface the exact command for the user to paste: as soon as the label is
known, print a one-liner such as — Run /rename [30] agent ergonomics — so the
human can apply it in one keystroke. Re-prompt when the work shifts enough that
the name no longer fits.
Standalone CLI that keeps the Code-node source of n8n workflows in git in a
folder-per-workflow layout: Code node sources become individual .js/.ts
files, synced with the instance over n8n's built-in MCP server (Plan 32 —
draft-first, code-only; workflow structure stays n8n's job, mirrored into a
read-only workflow.json snapshot). PLAN.md is the design document and
source of truth — it also records past decisions/observations so the project
could be rebuilt from it.
When your work changes the design, data model, flows, or surfaces a new decision or observation, update the PLAN.md. Never let PLAN.md silently drift from the code, update it unasked.
Every task/plan execution starts by reading the harness's permission config
so you know what's already pre-approved — this is the single biggest lever
for not interrupting the user with questions about commands you were always
allowed to run. For Claude Code that's .claude/settings.json (shared) and
.claude/settings.local.json (per-machine): the allow / deny / ask
lists spell out which Bash(...), WebFetch, and other calls run without a
prompt. Other harnesses (Codex, opencode) have their own allowlist config —
read whichever applies. Don't ask permission for something the allowlist
already grants, and don't re-run a variant just to dodge a prompt when the
plain command is allowed.
A compound command auto-approves only if every segment matches an allow rule —
one unmatched straggler (an unlisted subcommand, a mutation, or a git -C <dir> …
that sidesteps the git <cmd> * rules) gates the whole batch behind a prompt,
and elaborate batches also bury their answer in noise. Write commands that clear
on sight and read cleanly:
- Anything before the real command breaks the match — a git global option
(
git -C,git --no-pager,git -c <cfg>) or an inline env-var assignment (STEP=… node …). The matcher keys on the prefix, sogit -C <dir> status,git --no-pager reflog,git -c core.hooksPath=/dev/null status, andSTEP=x node test/e2e.mtsnever hit theirBash(…)rules. Pin the directory with a leadingcd <dir>(allowlisted viaBash(cd:*)) instead ofgit -C; drop--no-pager/-cunless you truly need them; pass config as a flag, not an env prefix, when the tool allows it — the e2e/guardproxy runners take--step=as well asSTEP=, sonode test/e2e.mts --step=xstill matchesBash(node test/*).-c core.hooksPath=/dev/nullin particular is a no-op onstatus/diff/add/checkout(git runs no hooks there) and oncommitit defeats the main-checkout guard — never add it; the only sanctioned commit override isALLOW_MAIN_COMMIT=1. (gh's-Ris the exception — it matches only after the subcommand:gh pr view <n> -R <repo>, notgh -R <repo> pr view.) Otherwise just rely on the session's persistent working directory. - Don't bundle unrelated diagnostics into one
&&/;mega-line. One un-allowlisted segment prompts for the entire batch. Split independent checks into separate calls (they run in parallel — no speed cost) so an un-allowed command prompts alone instead of dragging the safe reads down with it. - Keep legitimate dependent chains (
a && bwherebneedsa) and pipes (… | jq,… | tail) — the goal is not "never chain," it's "don't let agit -Cprefix or an unrelated straggler gate a batch of safe reads." - Loops, command substitution, and heredocs never auto-approve — no rule can
fix them, so avoid them for reads. A
for … done/whileloop, a$(…)substitution, and acat > file <<EOFheredoc can't be decomposed into allowlistable prefixes, so the whole command prompts no matter what's inside (and the prompt exists for a reason — it shows the human the real expansion). Most read-loops are just a glob the tool already expands:head -1 docs/cli/*.md, notfor f in docs/cli/*.md; do head -1 "$f"; done. Write scratch files with the Write tool into the scratchpad (thennode <path>), nevercat >+rm. List tags withgit tag --list(baregit tagmatches no rule and can create a tag). To probe upstream n8n source, list a whole directory in one call —curl -sL "https://api.github.com/repos/n8n-io/n8n/contents/<dir>?ref=<tag>" | jq -r '.[].name'— instead of loopingcurl … -w %{http_code}over guessed paths. - Write output a human can read, not just run. Drop decorative
echo "=== … ==="banners; ask one question per command — don't run two approaches to the same check (e.g. agit merge-tree | grep | awkconflict-hunt and agit merge --no-commitdry-run). Reach for a plain built-in (gh pr checks,git status --short, a single--json+--jq) before agrep | awk | sort | headpipeline.
Maintain CHANGELOG.md (Keep a Changelog format) in the same change as the
code, without being asked: every user-facing change — CLI commands/flags,
sync behavior, data model (.decanter.json, markers, placeholders), guard
rules, template contents — gets an entry under [Unreleased] in the fitting
category (Added/Changed/Fixed/Removed), written for users, not a commit log.
Internal refactors and test-only changes get no entry. Prefix breaking
changes with Breaking:. On release, rename [Unreleased] to
[<version>] - <date> and start a fresh [Unreleased].
The user-facing docs live in /docs as plain Markdown (repo root, outside
website/ so they outlive the Astro tooling — Astro reads them via a glob
loader). Keeping them current is a PR acceptance criterion, on par with the
changelog. Docs stay usage-level; PLAN.md remains the internal design source of
truth. Keep it plain Markdown (no bespoke MDX components) so the corpus stays
generator-agnostic.
The command surface is described in three independent places that must not drift. A user-facing change (any CLI command/flag, sync behavior, data model, guard, or config a user would look up) updates every one in the same PR — not just the one you happened to think of:
README.md— the## Commandsverb-index table and the feature-bullet list up top. A new verb needs a one-line table row (verb + what it does — no flag signatures; those live in--helpand/docs); a notable capability needs a feature bullet. (This is the one most easily forgotten — it's not generated from the others.)/docs(plain Markdown, rendered by the site inwebsite/) — the matchingdocs/cli/*page(s) and the overview command surface. New verb → new page.CHANGELOG.md— an[Unreleased]entry in the right category.
Same bar throughout: user-facing → update all three; internal refactors and test-only changes → none.
npm run check:docs mechanically enforces the structural half of this rule
(Plan 40). It parses the verb sets straight from n8n-decanter.mts and proves
every verb has a docs/cli/* page, a README ## Commands row, and an
overview.md entry — and that no docs/**, template/**, or CLI usage string
ships a copy-paste-broken verb-last command (n8n-decanter <ref> <verb>,
which the verb-first CLI rejects). CI runs it after typecheck, so the cross-PR
drift the per-PR grep below can't catch now fails the build instead. It is
structural, not semantic: a green check does NOT mean a flag's prose is
current — that stays human/agent review (and the periodic audit that produced
Plan 39). A verb rename/retire that the
check flags is fixed by updating the surfaces, or the small maintained map in
scripts/check-docs-surface.mts (its only per-verb manual touch).
Before opening a user-facing PR, grep the verb name across README.md,
docs/, and CHANGELOG.md — every surface that lists sibling verbs should
list yours too. (The simulate verb once shipped in /docs + changelog but not
the README because the old rule named only /docs; this checklist exists so
that can't recur.)
plans/ is the backlog: one file per item, sorted into draft/, open/,
done/, blocked/ by status (plans/AGENTS.md has the full conventions —
filenames NN-slug.md, the header block, the merge-in-favour-of-lower-number
rule; in-progress work lives in open/). Short backlog-grade ideas start as a
note in draft/; graduating one moves it to open/ and fleshes it out. When
your work fully completes a plan (implemented, tested, documented as
applicable), flip its **Status:** to Done and move the file to done/
(updating inbound relative links). Partially done is not done: leave it in
open/ and note the status. Don't delete, reword, or reorder the user's
entries, and don't add ideas of your own unasked.
Distinctive features are tracked as their own class. When a change
introduces a feature that's distinct from n8n itself and from the generic
"n8n-as-code" (git-sync) concept — a capability that differentiates this
tool rather than mirroring n8n or plain workflow-syncing — mark its plan
**Class:** Distinctive feature. This keeps the tool's differentiators visible
and tracked as a distinct class.
Executing a plan checks for drift first. Every plan header carries a
**Snapshot:** timestamp + main commit hash (plans/AGENTS.md has the exact
field format) recording when it was created or last reworked. Before starting
work on any plan the user asks you to execute, diff CHANGELOG.md
([Unreleased] and, if released since, the relevant version sections) and
skim git log --oneline <snapshot-hash>..main for changes the plan's tasks
didn't anticipate — a data-model shift, a guard change, a verb/flag rename.
Surface anything relevant before proceeding rather than executing a stale plan
as if nothing changed; if nothing changed, proceed normally.
When adding agentic/LLM-facing material for this repo (a skill, recipe,
hook, instruction file), never add it for one agent only (e.g. just a
Claude SKILL.md): put the substance in this tool-agnostic root AGENTS.md
(same convention as the template's sync-dir AGENTS.md — Codex/opencode
read it natively) and keep per-agent files (.claude/skills/*, CLAUDE.md,
opencode config, …) as thin pointers to it, so every agent stays in sync.
- main is protected — never commit to or push main directly. Every change
lands via PR from a short-lived branch (
feat/…,fix/…,docs/…,chore/…), squash-merged so main stays linear: one commit per PR. A localpre-commithook (scripts/hooks/pre-commit, auto-wired by thepreparenpm script on everynpm install) refuses any commit made in the main checkout (not just on themainbranch) — see "The main checkout is guarded locally too" below. - When a plan number is known, prefix everything with it — from the moment
it's known, in both planning and execution. If the work belongs to a
numbered plan (
plans/*/NN-slug.md, "Plan 27"), name the branch<type>/plan-NN-<slug>and the worktree dir<type>-plan-NN-<slug>inside your default worktree dir (e.g. branchfeat/plan-27-verb-grammar, worktree.claude/worktrees/feat-plan-27-verb-grammar), and tell the user to label the session by pasting/rename [27] <short name>(an agent can't rename its own session — see the "Session labels" rule above). This ties the session, branch, worktree, and PR back to the plan at a glance. The plan number is often known only after work has started (planning surfaces it, or an existing plan gets claimed mid-task) — when it becomes known, it's worth a rename: create a correctly-named worktree/branch and move the work over (see "One worktree per task" for the move-files recipe), and rename the session. Zero-padding is optional (plan-7andplan-07are both fine); when the work has no plan number of its own (adraft/note not yet graduated, or misc work), use the ordinary<type>/<slug>form and a[MISC] <short name>session label. - Feature PRs are decoupled from releases — merging one is never a release.
A user-facing PR only appends its entry under
[Unreleased](per the Changelog rules); it does not bumppackage.json, tag, or cut a Release.[Unreleased]is meant to accumulate across many PRs, so user-facing work sitting in[Unreleased]on main is the expected steady state, not a problem to fix. Internal-only PRs (no[Unreleased]entry) likewise just merge. - Releasing is a deliberate, separate act: a dedicated release PR. When you
decide to cut version
x.y.z, open achore/release-x.y.zbranch whose only job is to roll[Unreleased]→[x.y.z] - <date>(starting a fresh empty[Unreleased]) and bumppackage.json(semver; while 0.x: breaking → minor, everything else → patch). Merging that release PR is the release. After merge, tag the squash commitvX.Y.Zon main, push the tag, and create the GitHub Release from it with that version's changelog section as the notes (gh release create vX.Y.Z --verify-tag --notes-file <section>). The package is on npm (plans/done/13-open-source-release.md), butnpm publishis the maintainer's step — agents never run it. An agent's release work ends at the pushed tag + GitHub Release; the maintainer publishes to npm. Cutting a release is the maintainer's call — open a release PR only when explicitly asked, never automatically because[Unreleased]is non-empty. - CI (lint + typecheck +
npm test) must be green before merge, now enforced GitHub-side (see the ruleset bullet below). The ruleset gates every merge on the required checks — markdown-only changes can't fail them, but they still gate the merge, so watch them to green (gh pr checks <n> --watch) before merging. - A
chore/release-*PR additionally runs the full Docker smoke suite automatically (test/smoke-n8n.mtsacross the 3 pinned n8n tags). Every other PR skips it — it boots real containers and is far too slow for the per-PR gate — so a release is the one merge that always has a real-instance run behind it. It is deliberately not in the ruleset's required-check list: a skipped matrix job reports its name with${{ matrix.n8n_tag }}unexpanded, which would never match a required entry and would hang every ordinary PR. So read the smoke result yourself before merging a release (gh pr checks <n> --watch). Off-cycle, run it on any branch withgh workflow run ci.yml --ref <branch>. - Every repo-modifying task runs in its own worktree — no exceptions,
docs and Markdown included. There is no "fast path" that branches in the
main checkout: the main checkout is shared, so a concurrent session (or the
IDE's git integration) flips its
HEADand branch out from under you mid-edit. Create the worktree in your agent's default worktree dir, falling back to repo-root.worktrees/if your tool has none — Claude Code:.claude/worktrees/; Codex/opencode:.worktrees/; both are gitignored (git worktree add -b feat/x .claude/worktrees/feat-x main). The location is free to vary per agent, but the branch/worktree naming does not — always pass an explicit-b <branch>so the<type>/<slug>(or<type>/plan-NN-<slug>) name survives. Work there — don't edit the main checkout unless the user explicitly says to. Read-only work (questions, reviews, exploration) needs no worktree. Claude Code enters it viaEnterWorktreewithpath: .claude/worktrees/feat-x. After the PR is merged, remove the worktree, delete the branch, and refresh main withgit switch main && git pullin the main checkout (merged branches auto-delete on GitHub). Each worktree needs its ownnpm install. Concurrentnpm testacross worktrees is safe (tests bind ephemeral ports), and so is concurrenttest:smoke(PID-suffixed container name, ephemeral host port, mkdtemp work dir). Never rungit clean -fdx/-fdXfrom the repo root — it deletes the worktree dirs (.claude/worktrees/,.worktrees/) including uncommitted work; clean inside subdirs (e.g.dist/) instead. - GitHub-side enforcement (require PR + green required checks, block force-push) is live via the public-repo ruleset (plans/done/13-open-source-release.md). Auto-merge is not enabled on the repo and admin-bypass is disallowed, so a blocked merge means "checks still pending" — wait them out, don't try to force it.
- Sandboxed shells: push with
git push origin <branch>, not-u— the upstream-uwrites is what makes a latergit branch -Dneed a blocked.git/configwrite. See "Sandboxed shells" below.
Every repo-modifying task runs in its own worktree in your default worktree
dir (Claude Code .claude/worktrees/<name>, fallback .worktrees/<name>)
branched off main (git worktree add -b feat/x .claude/worktrees/feat-x main)
— the core rule is in "Git workflow & releases" above; read-only work needs no
worktree.
The trap that keeps biting: being launched inside an existing worktree does NOT make it the right place for your task. A worktree already carries a branch and, often, unrelated uncommitted work (that's why it exists). Dumping a new, distinct task's edits on top mixes two unrelated changes into one branch — the exact mess this rule prevents. So:
- A distinct task gets a fresh worktree. Before editing, check the worktree
you're in: if its branch/uncommitted changes are unrelated to your task
(
git status,git log --oneline main..HEAD), stop andgit worktree adda new one offmain— do not add your edits to the dirty worktree. - Create a new worktree when the user tells you to, and default to one for any new distinct task. When unsure whether the current worktree is "yours," the safe answer is a fresh worktree — never silently reuse someone else's.
- Only keep working in the current worktree when the task genuinely continues the work already staged there.
- Already made edits in the wrong worktree? Move only those files out
cleanly:
git diff -- <files> > patch,git worktree add -b <branch> .claude/worktrees/<name> main,git -C <new worktree> apply patch, thengit checkout -- <files>in the original to revert them there.
The GitHub ruleset blocks pushes to main, but nothing upstream stops a
local commit made in the main checkout when the worktree rule gets skipped. A
tracked scripts/hooks/pre-commit catches that: it aborts any commit not
made from a linked worktree (in whichever worktree dir it lives). It gates on
the working tree, not the branch — a linked worktree's git-dir lives under
.git/worktrees/ (allowed)
while the main checkout's is plain .git (refused) — which subsumes the old "no
commits on the main branch" rule, since main only ever lives in the main
checkout. It's self-installing — the prepare npm script
(scripts/setup-hooks.mts) points core.hooksPath at scripts/hooks on
every npm install, so the guard activates automatically in each clone and
worktree (which already run their own npm install). No hand-wiring needed;
the manual equivalent, if you ever want it, is:
git config core.hooksPath scripts/hooksA missing hook is a no-op, so this is safe to set before the file exists.
There is never a legitimate local commit in the main checkout (the only local
touch is git switch main && git pull after a merge); the emergency override is
ALLOW_MAIN_COMMIT=1 git commit …. If a commit is refused with "Refusing to
commit in the main working tree", you skipped the worktree step — start a
worktree in your default worktree dir (git worktree add -b <branch> .claude/worktrees/<name> main) and retry.
Agent command sandboxes (Claude Code sandbox mode, Codex sandbox, …) route
all network egress through an authenticating localhost proxy
(HTTPS_PROXY=http://…@localhost:<port>, allowlisted hosts only) and block
direct .git/config writes. Credentialed git operations authenticate fine
since credential.https://github.com.helper points at gh.
- Push without
-uso branch deletion stays sandbox-clean..git/configwrites are blocked, sogit branch -Dof a branch with an upstream warnscould not lock config fileand orphans abranch.<name>section. The upstream is whatgit push -uwrites — verified:push -uwritesbranch.<name>.remote+.merge, plaingit push origin <branch>writes nothing. So usegit push origin <branch>; the latergit branch -Dthen has no config to touch and needs no escalation. (Cost: pushing that branch again also needsorigin <branch>— trivial, since the normal flow is push once thengh pr merge.) Clean up sections left by older-upushes unsandboxed:git config --remove-section branch.<name>. - Node's
fetch()ignores the proxy → a barefetch failedeven for allowlisted hosts, whilecurlon the same URL returns 200. The fix isNODE_USE_ENV_PROXY=1(Node >= 24), and this repo now ships it:.claude/settings.jsonsets it underenv, sonpm run audit:globalscompletes sandboxed with no per-command prefix. It belongs in the harness'senvconfig precisely because an inlineVAR=… cmdbreaks allowlist matching (see "Invoke commands so a human can approve and read them"). Other harnesses need the same variable in their own env config. ghfails intermittently withtls: failed to verify certificate: x509: OSStatus -26276— on GETs as well as POSTs, on a host that worked seconds earlier.SSL_CERT_FILEdoes not fix it;ghspawned from node fails far more thanghrun directly from the shell. Retry before concluding anything is blocked.- A failed
ghmutating call may still have succeeded server-side, so never trust a single negative read.gh release createreturned the TLS error yet did create the release, and agh release listseconds later still did not list it — re-running would have created a duplicate. Re-check after a delay and query the specific object (gh release view <tag> --json …), not a list, before retrying any mutation. - Commit, status, diff, log, branch creation, worktree add, and push/gh (credentialed) — all fine sandboxed.
npm test # unit tests (node:test, test/unit/) + e2e suite
# (test/e2e.mts) + guard-proxy suite (test/guardproxy.mts) +
# mcp-spawn suite (test/mcpspawn.mts — spawns the
# SCAFFOLDED .mcp.json command as a child process on an
# unassisted PATH, local + global install shapes; Plan 58
# Task 3. guardproxy tests the guard in-process, so this
# is the only suite proving the command actually STARTS) +
# interactive picker suite (test/interactive.mts,
# PassThrough streams — no pty); e2e and guard-proxy bind
# localhost ports, and one e2e step uses fs.watch
# (macOS FSEvents) — sandboxes that block port binding
# or FSEvents break them (unit tests and the
# interactive suite run fine sandboxed).
# STEP=<substring> (env or --step=) runs a single
# step/scenario of e2e/guardproxy/smoke in isolation
npm run lint # Biome linter (biome.json); CI gates on it. Config
# keeps the correctness/suspicious rules but turns off
# the deliberate-style rules this repo intentionally
# uses (useTemplate, noNonNullAssertion, noExplicitAny,
# noTemplateCurlyInString). `npm run lint:fix` autofixes.
npm run typecheck # tsc -p tsconfig.cli.json (CLI sources) + scripts/
# typecheck.mts (node files — NOT plain tsc, see below)
npm run check:docs # Plan 40: STRUCTURAL docs-surface guardrail — proves every
# verb has a docs/cli page + README ## Commands row +
# overview.md entry, and that no doc/template ships a
# copy-paste-broken verb-last command. Offline, no deps;
# CI runs it after typecheck. Structural only — a green run
# does NOT mean the prose is current (that stays manual).
npm run test:smoke # OPT-IN, dev-only: real n8n in Docker (test/smoke-n8n.mts,
# plans/15); needs a running Docker daemon; never part
# of npm test
npm run field-test:stage # OPT-IN, dev-only: blind-agent field-test harness
# (test/field-test/, Plan 35). stage boots + provisions
# a throwaway n8n (or FIELD_N8N_URL targets a running one)
# and scaffolds a neutral scratch project, printing a
# manifest. `field-test:run <manifest>` drives blind
# `claude -p` sessions per the S*.md scenarios. The two
# knobs that define a round's WORLD are flags, not env
# vars — `--model <name>` (default sonnet) and
# `--n8n-tag <image>` (default n8nio/n8n:2.30.7); every
# round through 2026-08-08 ran one world, and the env-only
# form was unusable from a sandboxed agent. Vary ONE at a
# time; both land in the archived manifest + report.
# Run UNSANDBOXED (nested claude needs the
# Anthropic API + must reach the local n8n; fs.watch dies
# sandboxed). `run.mts --smoke`/`--netcheck` are debug
# probes; `field-test:verify <manifest>` runs the scripted
# invariant checks; `field-test:report <manifest>` renders
# a self-contained HTML timeline of the agentic sessions.
# Host mode prepends the workDir's node_modules/.bin to
# the blind session's PATH — a deliberate SIMULATION of
# the global install most users have (the guard itself no
# longer needs it; `npx --no-install` resolves it). Each
# run prints its `PATH policy`; FIELD_NO_PATH_HELP=1 drops
# the prepend to measure a genuinely unassisted PATH.
# FIELD_NO_CLI=1 stages the Plan 57 DISCOVERABILITY
# condition (scenario S6): full committed project
# evidence, but node_modules removed — a fresh clone.
# The run then shadows any ambient n8n-decanter off PATH
# and points npm at an empty prefix, so a maintainer's
# global install can't silently satisfy the round; node/
# npm/npx/git survive and `npm install` still recovers.
# Host mode only (the container bakes the CLI globally).
# FIELD_WORKTREE=1 stages the LINKED-WORKTREE condition
# (scenario S17): the project is committed and a
# `git worktree add` sibling becomes the session's world,
# so every gitignored file lives only in the main
# checkout. Scores whether the agent ships from there or
# starts copying credential files in — copying the auth
# file forks a single-use rotating token. Host mode only.
# The stage packs + locally installs OUR built CLI (no
# global link) + pre-seeds a correct
# .env. Never part of npm test; grading is a separate pass.
# A round auto-archives to test/field-test/runs/<iso>-<runId>/
# as raw.tgz (transcripts + verify + guard.log + a
# credential-free manifest + a bare clone of the workflow
# history) + report.html — COMMIT BOTH: an agentic round is
# expensive and irreproducible, and being in git is what
# keeps it from dying with its worktree. Any view re-renders
# from the raw: `field-test:report -- --from <raw.tgz>`.
# Teardown: `field-test:stage --down <manifest>`.
node n8n-decanter.mts <init|pull|push|preflight|diff|watch> …The e2e suite is one sequential, stateful scenario (each step builds on the previous); individual steps can't be run in isolation. It execs the CLI as a subprocess — exec must stay async, a sync exec deadlocks against the in-process mock server.
- The CLI is TypeScript (
.mts), run natively via Node's type stripping — no build step; requires Node >= 22.18. Only erasable TS syntax is allowed (erasableSyntaxOnly: no enums, namespaces, or parameter properties), and relative imports name the real.mtsfile.tsconfig.cli.jsonchecks the CLI's own sources; the roottsconfig.jsonstays the workflow node-file config — its name is load-bearing (discovered by name byscripts/typecheck.mtsand the sync-dir upward search). n8n-decanter.mts— thin CLI dispatcher; one module per concern inlib/; shared data-model types inlib/types.mts.- Grammar is verb-first (Plan 27):
n8n-decanter <verb> [workflow…];node runlives under thenodenamespace, the agent guard undermcp(mcp connect/mcp serve). A ref verb with no workflow opens the picker on a TTY. The structure/lifecycle verbs are retired (rename, create, archive, node create, node rename — removed post-Plan-33): those acts go through n8n's MCP tools (guarded), andpullreconciles the local mirror — files follow renames; a Code node added over MCP withoutjsCode(the guard blocks code inaddNode) lands as an empty file whose first push seeds the source (normalized ingetWorkflowDetails). - Backends (Plan 32): the workflow code path (pull/push/watch/preflight/
diff/publish/unpublish) rides
lib/mcp.mts— a hand-rolled JSON-RPC client for n8n'sPOST /mcp-server/http(initialize handshake, SSE parsing, bearer or OAuth auth with rotate-persist refresh, 429 backoff).lib/api.mts(public REST,N8N_API_KEYoptional) serves only what MCP can't: executions and data-table reads. - Agent guard, two transports (same
guardMessagecore inlib/mcpserve.mts):mcp connect(lib/mcpconnect.mts) is a stdio MCP server the agent spawns from the scaffolded.mcp.json— decanter's creds, no secret, stderr-only logging;mcp serveis the localhost HTTP variant (per-session secret,.decanter-proxy.jsondiscovery file) for URL-configured agents. Both forward everything except two refusals, shared so the transports can't drift:update_workflowwrites that setjsCode(guardMessage), and apublish_workflowwhose draft carries dangling node references (guardPublish— async, needs a read; fail-closed, so a read that fails refuses too and says the check failed). - Data model (the part that spans files):
workflows/<slug>/workflow.json— read-only structure snapshot, each Code node'sjsCodereplaced by a//@file:code/<node>.jsplaceholder; node sources live kebab-case-named in the folder'scode/subdir. Pull rewrites it; nothing pushes it (the placeholders remain the file map — re-pointing one is how.js↔.tsconversions register). The folder is the kebab slug of the name for new pulls and is sticky thereafter (never follows a remote rename; Plan 27).workflows/<slug>/.decanter.json— state: node-id → file-path map (code/prefix included), per-node sync hashes + cached node names (Plan 32 — ids anchor identity, MCP addresses by name, push resolves id→name from a fresh read), and the cached displayname(Plan 27).lastPushedHashmeans "hash of the remote code at last sync (push or pull)". The old whole-workflow structure hash is gone..decanter-auth.json(sync-dir root) — MCP OAuth credentials minted by init; refresh tokens are single-use (rotated + persisted on every use)..jsnode files are lossless (byte-identical round-trip)..tsfiles are one-way: push compiles via esbuild (bundle: true— helper files from anywhere in the sync dir (shared/is the scaffolded default) and opted-in npm deps are inlined into each importing node) and appends a// @ts-n8n sha256:<hash of compiled JS>marker line — marker presence is what identifies a TS-managed node on pull. Because a bundler really does resolve those specifiers, the node-file tsconfig usesmoduleResolution: "bundler";node16/nodenextwould reject the extensionless relative imports the template documents (TS2835). It also setsallowImportingTsExtensions(legal becausenoEmit): esbuild resolves an explicit../shared/money.tstoo, so without it the typecheck would reject (TS5097) whatpushbundles happily. Docs still teach the extensionless form — the flag is tolerance, not a recommendation.
- Push runs two independent gates, in order:
- Compliance guard (
lib/validate.mts, shared with preflight'slayoutcheck and watch): layout violations are hard errors that--forcedoes NOT bypass. A watch save runs the same folder-wide guard but scopes the abort (Plan 69): violations attributable to the saved file are fatal, the rest are printed and let the save through —ValidationResult.errorsByFileis the lookup. Without the folder view a save could not see node names at all, so it shipped dangling$('…')refs thatpushrefuses. - Per-node drift guard: a node's remote CODE moved off the last-sync hash
(and differs from the local payload) → abort; only this one is bypassed
by
--force. Remote structure changes never block (preflight'ssnapshotcheck reports an informational stale hint instead).
- Compliance guard (
- Pushes land on the workflow's draft (one atomic
update_workflowbatch of{jsCode}-only merge writes);publish/push --publishis the go-live step. Pull reads the tip (draft if one exists). A workflow must beavailableInMCP(per-workflow n8n-side switch) — the picker shows unavailable ones as a red third state with guidance. - Pull never touches
.tssources; remote edits to TS-managed nodes are warned about (diffto inspect — no.remote.jsartifacts since Plan 32). Pull re-baselineslastPushedHasheven on conflict — meaning the next push overwrites remote edits by design. Pull's rename machinery also migrates pre-code/flat layouts. - Sync hashes are recorded from a post-write confirming read
(
update_workflowreturns a summary, never the workflow). template/is copied byinitinto new sync dirs. Files namedX.exampleare inert in this repo on purpose (so agent tooling ignores them here) and materialize asXin the target — keep that suffix convention when adding agent/tool config to the template, and always use the full real filename before.example(settings.json.example).
n8n Code node source is a function body (top-level return/await), which
tsc rejects in .ts files (TS1108). scripts/typecheck.mts wraps node
files in an in-memory async function via a custom CompilerHost and maps
diagnostic lines back; node files are recognized by a .decanter.json
sibling (directly, or in the parent of their code/ dir). Files on disk must
stay verbatim — never "fix" a node file by wrapping it on disk or stripping
its top-level return.
Drive the real CLI (node n8n-decanter.mts <verb>) as a subprocess against a
throwaway mock n8n API — do not import lib/ modules directly and call
functions; the CLI process is the surface users touch.
- Mock server: a
node:httpserver on127.0.0.1:<port>serving BOTH surfaces —POST /mcp-server/http(bearer-authed JSON-RPC: answerinitializewith plain JSON + anmcp-session-idheader,tools/callas SSEdata:lines; implementsearch_workflows/get_workflow_details/update_workflow(merge semantics) /publish_workflowfrom a mutable in-memory workflow with anavailableInMCPflag) and, for the API-only verbs, theGET /api/v1/...routes.test/e2e.mts's mock is the reference implementation. Fixture gotcha:connectionsmust reference real node names or the compliance guard blocks pushes. - Sync dir: temp dir with
.env(N8N_HOST=http://127.0.0.1:<port>,N8N_MCP_TOKEN=test-mcp-token,N8N_API_KEY=test) +decanter.config.json({"root":"./workflows","workflows":["wf1"]}), thengit init+ localuser.name/user.email— pull/push/watch auto-commit, and watch refuses its startup pull without git. - Bootstrap: run
node n8n-decanter.mts pull(cwd = sync dir) to create the layout; then drive the verb under test. - watch: spawn it, poll its captured stdout for marker lines
("watching workflow", "pushed", conflict text); 200 ms debounce — allow
~500 ms after a file write before asserting. Piped stdin exercises the
non-TTY paths. For TTY-only paths (the structural-conflict prompt) wrap
the CLI in
expect— macOSscript -q /dev/nulldoes NOT work with piped stdin ("tcgetattr … not supported on socket"). Coordinateexpectsends with driver-side file edits via marker files.
fs.watchdies under sandboxed shells (EMFILE, watchfrom FSEvents) and takes the watch process down — run watch drives unsandboxed; it is an environment artifact, not a code failure.- Never coordinate a watch step with a sleep — wait for the observed effect
(
waitForintest/e2e.mts). The stepwatch: debounce coalesces…used to fail2 !== 3often enough that this file called it "a property of the machine" and told you to accept it. It was not: it slept 320 ms for a push that arrives ~212 ms after the save (200 ms debounce, measured idle), and that ~106 ms of slack disappeared reproducibly whenever a second e2e suite ran at the same time — which is exactly what happens when you work in two worktrees. Two concurrent runs still fail the old code and pass the new one. So: a sleep is only for asserting an absence (no second push, nothing afterclose()), never for waiting on something that will happen. - The e2e suite's mock (
test/e2e.mts) is in-process and not importable; building a fresh mock is faster than extracting it. - CLI output contains ANSI codes even when piped (known, Plan 11) — match with regexes, not exact strings.
Hard-won facts for standing up a throwaway n8n container and driving it (used by
test/smoke-n8n.mts and Plan 32's MCP spike). Booting one by hand keeps biting on
the same traps:
/healthzgoes green BEFORE the REST API mounts. A healthy/healthzstill servesn8n is starting up. Please waitfor/rest/*and 404s/rest/login. Gate readiness onGET /rest/settingsreturning JSON, then doPOST /rest/owner/setup. Skipping this makes setup "200" against a not-ready server and every later call fails confusingly.- Owner password needs a special char, not just length+digit+uppercase. n8n's
rule rejects
Decanter123;Sm0ke-Test-Pass!(orSp1ke-Test-Pass!) passes. POST /rest/owner/setupissues then8n-authcookie itself (once REST is ready) viaSet-Cookie— read it withres.headers.getSetCookie()(fetch special-cases it;headers.get('set-cookie')won't see it), matchingn8n-auth=[^;]+.POST /rest/loginis rate-limited (5/window, seex-ratelimit-*headers) — don't spam it; prefer the setup cookie.- Public API key (for
/api/v1/*, headerX-N8N-API-KEY) is minted atPOST /rest/api-keyswith the owner cookie (scopes list insmoke-n8n.mts).
- Endpoint:
POST /mcp-server/http(Streamable HTTP JSON-RPC). Probe with no token → 401 when the endpoint exists, 404 when it does not (n8n too old, or a wrong host/path). A no-token probe cannot tell "live" from "switched off" (corrected 2026-08-04, reproduced on 2.30.7): withmcpAccessEnabled:falsethe endpoint still 401s an absent/stale token and answers a valid token with 403{"message":"MCP access is disabled"}. Decanter maps all three (Plan 74). Responses may betext/event-stream— parsedata:lines. Sendaccept: application/json, text/event-stream; carry themcp-session-idresponse header on later calls. - Enabling and the token are BOTH headless via the owner cookie (no UI needed):
the
N8N_MCP_ACCESS_ENABLED=trueenv did not flip the DB flag on 2.30.7 — insteadPATCH /rest/mcp/settings {"mcpAccessEnabled":true}(scopemcp:manage) turns it on, andPOST /rest/mcp/api-key/rotatereturns a fresh raw MCP token ({data:{apiKey}}).GET /rest/mcp/api-keyis get-or-create but redacts the key after first creation (******1D3Y), so use rotate to get a usable token. Auth to the MCP endpoint isAuthorization: Bearer <that token>— the public API key is NOT accepted (401). IfN8N_MCP_MANAGED_BY_ENV=truethe PATCH is forbidden (env-locked). - Per-workflow opt-in (list vs. detail split):
search_workflowslists all workflows instance-wide (create/read/discover), butget_workflow_detailsand the edit tools refuse a workflow ("Workflow is not available in MCP") until itsavailableInMCPflag is on. So MCP can enumerate everything but can only read full content / edit opted-in workflows. Toggle headlessly withPATCH /rest/mcp/workflows/toggle-access {"availableInMCP":true,"workflowIds":[id]}. Creating is code-based:create_workflow_from_code(n8n Workflow SDK code, must passvalidate_workflowfirst — a client discipline the server doesn't force; decanter'screateenforces it since Plan 33). - Node source fidelity is exact:
get_workflow_detailsreturns full nodes/connections/settings with Code-nodejsCodebyte-exact (the "sanitized/credentials-stripped" caveat strips credentials, not node params), plusversionId/activeVersionId. Writes go throughupdate_workflowas an ordered batch of ops (updateNodeParameters/setNodeParameter/addNode/renameNode(oldName+newName)/addConnection(source+target)/…) that address nodes by name; there is no full-JSON-replace tool. AnupdateNodeParameterswrite lands on the draft only (bumpsversionId, leavesactiveVersionIduntouched — an active workflow keeps running the published version);publish_workflowactivates it. This is the API-inaccessible draft-first capability. - More verified write/read semantics (2.30.7):
updateNodeParametersmerges into existing params (a{jsCode}-only write preservesmode/language); node ids surviverenameNode. Reads are NOT draft-only (corrected 2026-07-25, reproduced against real n8n — see the smoke suite's "MCP read semantics" step): oneget_workflow_detailsreturns the draft tip (nodes) and the published version (activeVersion.nodes) plusactiveVersionId, andget_workflow_version(versionId)returns full node params (jsCodebyte-exact) for ANY version — draft, active, or a superseded one.get_workflow_historyis the index (version list — metadata only, no node params); it was the tool the old "returns metadata without node params" claim conflated withget_workflow_version. (All these reads are still credential-sanitized per the fidelity note above — node credential refs stripped, params intact.) Data-table tools are add-only (create/rename/add rows+columns;search_data_tablesreturns schema, never row values);create_data_tablerequires aprojectId(get it fromsearch_projects). - The docs-site tool reference is NOT exhaustive (it lists ~33 of the 41+
tools on 2.30.7) — n8n-io/skills' SKILL.md and a live
tools/listare better inventories. Undocumented-but-real (re-verified 2026-07-22, from the n8n source): the version-history trioget_workflow_history/get_workflow_version/restore_workflow_version(n8n ≥ 2.29; restore re-applies a past version as the draft, new history entry, live untouched); execution readsget_execution(includeData/nodeNames/truncateData) andsearch_executions;prepare_test_pin_data(returns per-node output JSON Schemas + coverage, no data — read-only, the caller authors the values; decanter builds pin values client-side from captures, and Plan 37 adopts the schemas for scenario gap scaffolding); andpublish_workflow(versionId)publishes a past version straight to live (a futurepublish --version?).test_workflow= synchronous/draft/pinData/5-min cap;execute_workflow= async/production → read back viaget_execution. - Two auth methods, and workflow visibility is the same under both:
search_workflowslists all workflows regardless of auth; theavailableInMCPgate only limitsget_workflow_details/edit. Auth is orthogonal to visibility.- OAuth2 (standard, browser consent — the real-client path). Discover at
GET /.well-known/oauth-authorization-server(advertisesauthorization_endpoint/mcp-oauth/authorize,token_endpoint/mcp-oauth/token,registration_endpoint/mcp-oauth/register,grant_types_supportedincl.refresh_token, PKCES256,token_endpoint_auth_method: none). Flow: RFC 7591POST /mcp-oauth/register(→client_id) →GET /mcp-oauth/authorize?...&code_challenge=...(302s to a browser consent page) → token exchangePOST /mcp-oauth/tokenreturnsaccess_token+refresh_token(expires_in: 3600); therefresh_tokengrant silently renews with no browser. A real client (or decanter'sinit) opens the authorize URL in a browser and catches the code at a localhost callback — it does not call/rest/*. (To drive consent headlessly in a spike only: capture the OAuth session cookie from the authorize 302, thenPOST /rest/consent/approve {"approved":true}with owner+session cookies to get the redirect?code=.) - Rotatable Bearer token (headless, no browser) — the
/rest/mcp/api-key/*path above; good for CI/provisioning where no browser exists at setup time.
- OAuth2 (standard, browser consent — the real-client path). Discover at
- What a real app needs vs. spike-only: production clients use the standard
/mcp-oauth/*+/mcp-server/httpendpoints. The internal/rest/*calls here (/rest/mcp/settingsenable,/rest/mcp/workflows/toggle-accessopt-in,/rest/consent/approve) are undocumented and version-fragile — use them to script a throwaway instance, but in a shipped tool prefer guiding the user through the n8n UI over depending on them.
A periodic maintenance pass over the repo — run it on demand (Claude Code:
/housekeeping). Each step is a check that may produce a small PR; batch
related fixes into a single worktree branch/PR (every repo-modifying task uses a
worktree — see "Git workflow & releases" above). A pass that finds nothing to do
is a valid outcome — report it. Anything needing a non-obvious decision →
surface it, don't guess.
Start from an up-to-date main (git switch main && git pull), then:
- Backlog hygiene (
plans/open/,plans/draft/) — for each open plan or draft, check whether the code already fully satisfies it (implemented + tested + documented). If so, flip**Status:**toDoneand move the file toplans/done/(updating inbound relative links); if only partial, leave it inopen/and append a short status. Never delete, reorder, reword, or add to the user's entries. - Docs & changelog currency — diff what merged since the last pass against
/docs,CHANGELOG.md[Unreleased], andPLAN.md. Any user-facing CLI / sync / data-model / guard / config change that landed without its docs + changelog entry gets one now (rules: "Changelog" and "Documentation site" above). PLAN.md must not have drifted from the code.npm run check:docsmechanically catches the structural drift (a verb missing a page/README/ overview entry, or a verb-last command) — but the semantic prose check (does a flag's description still match its behavior?) stays this manual pass. - Release check — releases are decoupled from feature PRs (see "Git
workflow & releases" above), so a non-empty
[Unreleased]is normal: it accumulates until the maintainer decides to cut a release, and is not by itself a signal to release. Do not cut a release here — that's a deliberate, maintainer-requestedchore/release-x.y.zPR. This check only verifies consistency: the latest git tag ==package.jsonversion, and that tag's[x.y.z]changelog section matches what's released. If those line up, surface the size/age of the pending[Unreleased]as an FYI and move on.npm publishis the maintainer's step — agents never run it. - Worktree & branch prune — remove worktrees (in
.claude/worktrees/and.worktrees/) whose branch is merged or gone (git worktree remove), delete merged local + remote branches, and clean stale.git/configbranch.<name>sections left by sandboxed deletes (see "Sandboxed shells" above). Nevergit clean -fdxfrom the repo root — it nukes both worktree dirs. - Dependency PR triage — review open Dependabot PRs; merge the safe ones
(green CI, minor/patch). For majors, record the decision rather than silently
merging. Standing decision — do NOT adopt TypeScript 7 (native/Go) until a
stable release exposes the programmatic compiler API:
scripts/typecheck.mtsdrives a customCompilerHostand the ts-plugin tests need that JS API, so a green-looking bump would silently break node-file typechecking. TS 6.x is fine. - CI & tests green — main's required checks are green,
npm testandnpm run typecheckpass locally, and no open PR is red. - Drift audits —
template/*.examplestill match their repo counterparts;n8n-globals.d.tsis single-sourced (init copies the root file — notemplate/*.exampleduplicate; Plan 43) and its declared surface is checked against n8n byscripts/globals-drift-audit.mts; runnpm auditfor new advisories.