difyctl is a single-binary Go CLI that lints, diffs, and canonically formats Dify workflow DSL (YAML) files — so git-driven workflow teams catch schema breakage, variable-path regressions, and silent graph rewrites before they ship.
- Why This Exists
- Features
- Install
- Quick Start
- Usage
- Rule Catalog
- Exit Codes
git diffvsdifyctl diff- CI Recipe
- Design
- Building
- Testing
- Scope
- Contributing
- License
Dify is a fast-growing LLM-workflow platform (92k+ stars; ranked #2 in the RAG-momentum report with 305 commits/week as of 2026-04-17). Most teams:
- Author workflows in Dify's web editor.
- Export a YAML DSL.
- Check the DSL into Git.
- Pray
git diffreviewers catch mistakes.
The problem: this last step doesn't work.
- Dify's UI happily saves DSLs with dangling
{{#node.var#}}references — you only discover the broken path when a user hits production. (dify#19114) - There is no local linter, no offline validator.
git diffon YAML is dominated by key-order churn and list-reordering noise — a real "node renamed" looks identical to a trivial edit. (dify#26158, dify#7966)
difyctl is a git-first linter and semantic differ. Not a replacement for Dify's editor — a safety net for the 30-second gap between "hit Export" and "merge to main".
| Change | git diff signal |
difyctl diff signal |
|---|---|---|
Node renamed (llm-1 → rewrite) |
~40 line-level diff lines | REMOVED node llm-1 + ADDED node rewrite + BREAKING variable-ref … if anything downstream still references {{#llm-1.text#}} |
| Position-only move (cosmetic drag) | 2 changed lines | CHANGED node llm-1 moved (one line) |
| Variable output removed from Start | Maybe 1 line, easy to miss | BREAKING variable-ref: {{#start-1.query#}} output 'query' removed |
| Edge rewired | Messy reordered list | ADDED edge, REMOVED edge |
lint— 20 deterministic rules (DIFY001..DIFY020) with stable IDs, severity (error / warning), file path, and line number.diff— semantic graph diff, categorized intoBREAKING,REMOVED,ADDED,CHANGED. Detects renamed nodes, body-changed vs position-only moves, rewired edges, and — most importantly — broken variable references that silently kill runtime flows.fmt— canonical YAML re-emit. Top-level keys ordered (app→kind→version→workflow). Nodes sorted byid, edges sorted byid. Idempotent:fmt(fmt(x)) == fmt(x).--format json— machine-readable output on every subcommand.- Exit codes for CI — 0 OK, 1 issues, 2 usage, 3 IO/parse.
- No panics on malformed input — returns structured errors.
- Iterative DFS cycle detection — safe on 10k-node graphs.
- Iteration-body aware — back-edges inside an
iterationsubgraph do NOT trigger the cycle rule. - Zero required external deps beyond
gopkg.in/yaml.v3— single binary, no Python/Node runtime.
go install github.com/JSLEEKR/difyctl/cmd/difyctl@latestgit clone https://github.com/JSLEEKR/difyctl.git
cd difyctl
go build -o difyctl ./cmd/difyctl
sudo mv difyctl /usr/local/bin/difyctl version
# v1.0.0# 1. Lint a single workflow file.
difyctl lint my-workflow.yml
# 2. Compare two versions of a workflow.
difyctl diff old-workflow.yml new-workflow.yml
# 3. Normalize keys so git diffs stay readable.
difyctl fmt -w my-workflow.ymlOutput of difyctl lint on a broken file:
my-workflow.yml:17: [DIFY013/error] node 'llm-1' references '{{#ghost.question#}}' but node 'ghost' does not exist
my-workflow.yml:21: [DIFY006/error] duplicate node id 'llm-1' (first defined at line 14)
2 errors, 0 warnings
difyctl lint <file.yml> [--format text|json] [--dify-version 1.0]Flags:
--format text|json(defaulttext) — output format.jsonproduces a structured report withpath,findings, and severity summary.--dify-version— informational flag (v1.0 accepts it without applying version-gated rules; v1.1 will.)
Text output example:
workflow.yml:23: [DIFY017/error] llm node 'llm-1' is missing 'data.model'
workflow.yml:45: [DIFY012/warning] orphan node 'floater' has no incoming or outgoing edges
1 errors, 1 warnings
JSON output example. Success and IO/parse-error paths share the same envelope
shape so jq filters like .findings[] and .error work without branching
on exit code:
{
"path": "workflow.yml",
"findings": [
{ "rule": "DIFY017", "severity": "error", "message": "llm node 'llm-1' is missing 'data.model'", "path": "workflow.yml", "line": 23 },
{ "rule": "DIFY012", "severity": "warning", "message": "orphan node 'floater' has no incoming or outgoing edges", "path": "workflow.yml", "line": 45 }
],
"summary": { "error": 1, "warning": 1 },
"error": null
}On IO/parse failure the shape is identical except error is a string,
findings is [], and exit code is 3 — consumers never see empty stdout.
difyctl diff <a.yml> <b.yml> [--format text|json] [--fail-on-breaking]Flags:
--format text|json--fail-on-breaking— exit 1 when anyBREAKINGchange is detected. Use this in CI for pull-request gating.
Text output categorizes changes:
[BREAKING]
variable-ref llm-1: reference to {{#start-1.query#}} broken: output 'query' removed from 'start-1'
[REMOVED]
node floater: type=llm
edge e-3: llm-1 -> end-1
[ADDED]
node answer: type=answer
edge e-4: llm-1 -> answer
[CHANGED]
app version: '0.1' -> '0.2'
node llm-1: body-changed
node start-1: moved
summary: 1 breaking, 2 removed, 2 added, 3 changed
JSON output uses a single envelope shape on both success and error so jq
filters like .changes[] work without branching on the exit code:
{
"changes": [
{ "category": "BREAKING", "kind": "variable-ref", "id": "llm-1", "detail": "reference to {{#start-1.query#}} broken: output 'query' removed from 'start-1'" },
{ "category": "ADDED", "kind": "node", "id": "answer", "detail": "type=answer" }
],
"error": null
}On IO/parse failure the shape is identical except error is a string and
changes is [] — consumers never see empty stdout.
difyctl fmt <file.yml> [-w]Flags:
-w— write canonical form back to the file in-place. Without it, writes to stdout.
Recommended pre-commit usage:
difyctl fmt -w workflows/*.yml| ID | Severity | Name | Description |
|---|---|---|---|
| DIFY001 | error | missing-app | Top-level app block is empty or missing. |
| DIFY002 | error | unknown-app-mode | app.mode not in {workflow, chatflow, agent-chat}. |
| DIFY003 | error | kind-mismatch | Top-level kind is missing or not app. |
| DIFY004 | error | missing-version | Top-level version is missing. |
| DIFY005 | error | missing-node-id | Node has no id field. |
| DIFY006 | error | duplicate-node-id | Two nodes share the same id. |
| DIFY007 | error | unknown-node-type | Node type not in known set. |
| DIFY008 | error | missing-node-data | Node has no data map. |
| DIFY009 | error | edge-dangling-source | Edge source references non-existent node. |
| DIFY010 | error | edge-dangling-target | Edge target references non-existent node. |
| DIFY011 | error | duplicate-edge | Two edges share the same (source, target, sourceHandle) tuple. |
| DIFY012 | warning | orphan-node | Non-start node with no incoming AND no outgoing edges. |
| DIFY013 | error | unresolved-var-ref | {{#node.var#}} references a missing node or a variable the source node does not declare. |
| DIFY014 | error | graph-cycle | DFS cycle in the graph (back-edges inside iteration bodies are allowed). |
| DIFY015 | error | missing-start | Workflow has no start node. |
| DIFY016 | error | missing-end | Workflow has no end or answer node. |
| DIFY017 | error | llm-missing-model | type: llm node has no data.model, or data.model lacks provider/name. |
| DIFY018 | error | code-missing-code | type: code node is missing data.code or data.code_language. |
| DIFY019 | error | iteration-missing-start | iteration node does not have exactly one iteration-start child (via parent_id). |
| DIFY020 | warning | unreachable-from-start | Node is not reachable from any start node via forward edge traversal (iteration bodies are silenced). |
start, end, answer, llm, code, http-request, if-else, iteration, iteration-start, knowledge-retrieval, parameter-extractor, question-classifier, template-transform, variable-aggregator, variable-assigner, tool. Both hyphen and underscore forms of each name are accepted.
Variable references are resolved when the source node declares an output with that name. In addition to whatever the node explicitly declares under data.outputs / data.output_variables, difyctl treats the following as built-in defaults:
| Node type | Default outputs |
|---|---|
llm |
text, usage |
knowledge-retrieval |
result |
http-request |
body, status_code, headers |
template-transform |
output |
iteration |
output |
iteration-start |
item, index |
variable-aggregator |
output |
tool |
text, files |
question-classifier |
class_name |
start nodes expose whatever they declare under data.variables[].variable. parameter-extractor exposes data.parameters[].name. variable-assigner exposes each data.items[].variable_selector[-1] (the tail of the assigned path). code declares outputs under data.outputs.
Lint (
DIFY013) and diff (BREAKING variable-ref) share a single source of truth for this table — seeinternal/varref. If the two commands ever disagree on whether a given{{#node.var#}}resolves, it is a bug in that package.
| Code | Meaning |
|---|---|
| 0 | OK (lint clean, diff clean — or diff had changes but no --fail-on-breaking, fmt succeeded) |
| 1 | Lint found at least one error finding; or diff --fail-on-breaking found BREAKING |
| 2 | Usage / argument error (wrong number of args, unknown flag, unknown format) |
| 3 | IO / parse error (file not found, malformed YAML, root not a mapping) |
warning findings do NOT fail lint by default.
Suppose you rename a workflow node from llm-1 to rewriter and update the one {{#llm-1.text#}} reference downstream. A typical Dify workflow has ~8 keys per node block.
-
git diffshows: the id changed in one place,datakeys may have re-serialized in different order, the edges list may have reordered items, a variable-ref string changed, position fields may differ if the UI moved things. 20-40 diff lines. -
difyctl diffshows:[REMOVED] node llm-1: type=llm [ADDED] node rewriter: type=llm [CHANGED] node downstream: body-changedFour lines. No false positives.
Miss a downstream reference?
[BREAKING]
variable-ref end-1: reference to {{#llm-1.text#}} broken: node 'llm-1' removed
You catch it in CI, not at 3am.
name: Workflow Lint
on:
pull_request:
paths:
- 'workflows/**.yml'
jobs:
difyctl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- name: Install difyctl
run: go install github.com/JSLEEKR/difyctl/cmd/difyctl@latest
- name: Lint all workflows
run: |
set -e
for f in workflows/*.yml; do
difyctl lint "$f"
done
- name: Semantic diff vs base branch
run: |
git fetch origin "${{ github.base_ref }}"
for f in workflows/*.yml; do
if git show "origin/${{ github.base_ref }}:$f" > /tmp/old.yml 2>/dev/null; then
difyctl diff --fail-on-breaking /tmp/old.yml "$f"
fi
done#!/bin/sh
changed=$(git diff --cached --name-only --diff-filter=ACM | grep '^workflows/.*\.yml$' || true)
[ -z "$changed" ] && exit 0
for f in $changed; do
difyctl fmt -w "$f"
difyctl lint "$f" || exit 1
git add "$f"
doneSee docs/superpowers/specs/2026-04-19-difyctl-design.md for the full design spec: data model, rule engine, diff algorithm, canonical-format algorithm, exit-code contract, test strategy.
High-level notes:
- One binary, stdlib
flag. Cobra would have added 3 MB of deps and saved about six lines of code. Not worth it. yaml.v3with*yaml.Node. Parsed structs for fast field access, raw node tree retained for accurate line numbers in lint output and for canonical-order formatting.- Rule engine is a slice of
Ruleinterface values, not reflection. Each rule is one file (rule_cycle.go,rule_varref.go, etc.) — trivial to add #21. - Iterative DFS for cycle detection. The 305-commits-per-week upstream repo could ship a 10k-node workflow and we still don't stack-overflow.
- Generator ≠ Evaluator. This package was built in Phase 2 of the daily-challenge pipeline and will be audited by an independent evaluator in Phase 3.
lint, diff, and fmt all funnel reads through internal/fileio.ReadCapped so a new input guard only has to be written once. This matters because previous cycles repeatedly hit the same cascade bug: a check landed in one subcommand and silently skipped another, corrupting user files.
| Behaviour | lint | diff | fmt |
|---|---|---|---|
| 32 MiB file-size cap | ✓ | ✓ | ✓ |
| Reject directories (exit 3, clean message) | ✓ | ✓ | ✓ |
| Reject UTF-16 / UTF-32 BOM (no data loss) | ✓ | ✓ | ✓ |
| Reject empty document | ✓ | ✓ | ✓ |
| Reject comment-only document | ✓ | ✓ | ✓ |
Reject null scalar root (~, null) |
✓ | ✓ | ✓ |
Reject non-mapping root (42, true, - a) |
✓ | ✓ | ✓ |
Reject multi-document YAML (--- separator) |
✓ | ✓ | ✓ |
| Reject duplicate mapping keys | ✓ | ✓ | ✓ |
Reject YAML anchors/aliases (&x / *x) |
— | — | ✓ |
| Round-trip self-check (re-parse own output) | — | — | ✓ |
| Follow symlinks (read) | ✓ | ✓ | ✓ |
fmt -w preserves symlink shape + target mode |
— | — | ✓ |
--format json error envelope |
✓ | ✓ | — |
| Multi-arg input | ✗ | n/a | ✗ |
Stdin input (-) |
✗ | n/a | ✗ |
Known asymmetries:
fmthas no--format jsonflag. Its output IS the file; an envelope would defeat the purpose. Lint/diff need the envelope so CI pipelines piping intojqnever see empty stdout on failure.fmtnormalises CRLF → LF line endings (yaml.v3 handles this internally). If you need CRLF preserved, do not usefmt -w— lint and diff do not touch the file.fmtrefuses YAML anchors/aliases (&x/*x, including<<: *basemerges). Lint/diff accept them because they only read the document; fmt re-emits, and canonical reordering could move the anchor after its alias, producing invalid YAML. Dify's DSL exporter does not emit anchors, so this only affects hand-crafted files.fmtruns a round-trip self-check on its own output: after canonical re-emit, the bytes are re-parsed via the same loader; if re-parse fails the bytes are dropped and an error is returned instead of being persisted. This is the architectural backstop that catches the entire "Format produced syntactically invalid YAML" class — Cycles E (UTF-16 ASCII strip), H (multi-doc truncation), and I (anchor reorder) were each one shape of this class. Lint and diff do not need it because they never re-emit.- Multi-argument input is not yet supported on any subcommand; use a shell loop or
find … -execfor now. Addinglint file1 file2 …is tracked for v1.1.
go build ./... # build everything (cmd + all internal packages)
go vet ./... # static checks — must be clean
go test ./... # run the full test suiteBinaries land at ./difyctl if you build with:
go build -o difyctl ./cmd/difyctlCross-compile:
GOOS=linux GOARCH=amd64 go build -o dist/difyctl-linux-amd64 ./cmd/difyctl
GOOS=darwin GOARCH=arm64 go build -o dist/difyctl-darwin-arm64 ./cmd/difyctl
GOOS=windows GOARCH=amd64 go build -o dist/difyctl-windows-amd64.exe ./cmd/difyctl$ go test ./...
ok github.com/JSLEEKR/difyctl/cmd/difyctl 0.016s
ok github.com/JSLEEKR/difyctl/internal/diff 0.005s
ok github.com/JSLEEKR/difyctl/internal/fileio 0.005s
ok github.com/JSLEEKR/difyctl/internal/fmt 0.004s
ok github.com/JSLEEKR/difyctl/internal/lint 0.023s
ok github.com/JSLEEKR/difyctl/internal/model 0.003s
ok github.com/JSLEEKR/difyctl/internal/parse 0.004s
ok github.com/JSLEEKR/difyctl/internal/varref 0.002s
- 274 tests across 8 packages.
- Rule tests are table-driven — one test file per rule, each exercising the happy path and at least one failure case.
internal/fmthas an idempotence test (fmt(fmt(x)) == fmt(x)) that will catch ANY accidental key re-ordering drift.internal/parsehas a "no-panic on garbage bytes" test covering binary noise and malformed YAML.
lint,diff,fmt,version.- 20 rules, JSON output, CI-ready exit codes.
- No HTTP, no side effects, no writing to remote Dify servers.
- Version-gated rules (
--dify-versionstops being informational). - Extended node-type coverage as upstream Dify adds types.
- Suppression comments (
# difyctl:disable=DIFY012).
difyctl export --api-key … --host …— bulk pull DSLs from a live Dify instance (dify#26158).difyctl import— push validated DSLs back.- Version migration (
v1.0 → v1.1).
Bug reports and PRs are welcome. Please:
- Open an issue first for anything larger than a typo.
- Include a failing test case.
- Run
go test ./... && go vet ./... && gofmt -l .before committing.
If you're adding a new lint rule:
- Add a file
internal/lint/rule_<name>.gowith a single type implementingRule. - Register it in
internal/lint/rules.goin ID order. - Add a table-driven test in
internal/lint/rule_<name>_test.go. - Document it in the Rule Catalog section of this README.
MIT © 2026 JSLEEKR. See LICENSE.