14 KiB
ARD — Architecture & Design Reference
Onboarding ref for dify/cli/ contributors. Cover canonical patterns, layer contracts, scaffolding recipe, dev workflow, anti-patterns. Read before adding command or touching shared infra.
Layout
src/
kernel/ plugin + Context primitives — no CLI knowledge
plugins/ argv global-flags env config session token catalog http ops output io commands — the 12 services, one folder each; index.ts is the plugin and the only file anything outside the folder imports
argv/ the invocation's tokens as a context input, plus the argv parser (parse.ts, framework shared by global-flags and commands)
global-flags/ flags any command accepts (`--verbose`, `--json`), parsed off argv before the command is resolved
catalog/ the cached catalog document: types, parseCatalog, op lookup
http/ the authenticated fetch wrapper with the catalog fingerprint header; refetches on 412, and on 404 when the catalog moved
ops/ the catalog as a command tree (refetched on request); command.ts adapts one op to a Command, tree.ts builds their command tree
output/ output mode per stream: text on a terminal, JSON in a pipe; `--json` or `DIFY_OUTPUT` forces one
io/ the one way anything is printed
commands/ command pipeline plus the command framework (command.ts, cancel.ts, registry.ts) every command imports; help.ts shapes the help views, text.ts renders them for a terminal
protocol/ pure catalog-shape logic: kinds, bind, pins, fold, shape (a JSON Schema as one of ten shapes), op-id (dotted id and its spaced words) — no I/O
discovery/ pure id tree and ranked search over commands and ops — no I/O
call/ the `call` flag table; JSON and SSE response decoding; builds a request from a resolved op + validated input; renders the response by kind
commands/ one folder per command leaf; index.ts is the only file the registry discovers
net/ fetch init and proxy dispatcher, shared by the http plugin and the pre-login device flow
store/ config/session/token file and keychain persistence
sys/ the process edges — env, argv, exit, platform paths; io/ holds streams.ts, color.ts and view.ts (a JSON value paired with its text form, the mode picks one)
skills/ reads skills from a GitHub folder or a local folder, and installs them
Kernel
A plugin is { name, needs, build(ctx) }. needs is a list of the other plugin objects it reads through ctx.get(...) — an array of the actual plugin values, not strings or classes, so a plugin literally names its dependencies and TypeScript checks build's ctx against that list (src/kernel/plugin.ts).
Context (src/kernel/context.ts) builds services lazily: ctx.get(plugin) runs plugin.build(ctx) the first time that plugin is requested, caches the result, and returns the cached service on every later call in the same invocation. A plugin never builds if nothing asks for it. needs is only the compile-time allow-list for that plugin's ctx.get; the kernel never walks it, so declaring needs: BASE_PLUGINS on the commands plugin costs nothing until a command actually calls ctx.get on one of them (version logged out builds six plugins, never http).
State stays with its owner. A plugin's build() closes over its own state (an in-memory cache, a loaded config document); nothing outside reaches into it — code that needs it calls ctx.get(thatPlugin) again and gets the same cached instance.
ctx.defer(fn) queues a cleanup to run after the command finishes (for example, aborting a streaming call's AbortController on SIGINT), instead of scattering try/finally through command bodies.
There is no event bus and no listener registration. Plugins that need to react to something call ctx.get on the plugin that has it; there is no subscribe/publish path to keep in sync.
The one commands plugin (src/plugins/commands/index.ts) needs BASE_PLUGINS (global-flags env config session token catalog http ops io) and its build() returns { run }. runPipeline takes the argv left over by global-flags, resolves the typed Command from the generated tree, parses those tokens against that command's Zod input schema, and calls run(input, ctx) — the pipeline never branches on which command it is.
A plugin's folder is private. Code outside it imports only the plugin's index.ts (@/plugins/<name>) and reaches the plugin's behaviour through ctx.get(plugin); a helper two plugins share is exposed on a service, and a helper only commands use lives outside src/plugins/. The two exceptions are contracts rather than services: the command framework in plugins/commands/ (Command, Outcome, runSignal, the tree types) and the argv parser in plugins/argv/parse.ts.
Global flags are one Zod object in src/plugins/global-flags/index.ts. The plugin pulls the flags it knows out of the argv input, exposes their values as flags and the remaining tokens as rest; any plugin or command reads a global flag through ctx.get(globalFlags), never by scanning argv. Which flag wins over an env var or a config key is decided in the plugin that owns the setting, not here.
Help is the row: commandRow(Ctor, path) (src/plugins/commands/describe.ts) is the one function that renders a command's --help output and its entry in help --full — there is no separate help-text template. Catalog operations are commands in the same tree, so entriesOf(tree) in plugins/commands/help.ts feeds the map, namespace listings and search (src/discovery/) from one list; words no command claims go to discover in the pipeline. plugins/commands/text.ts renders the same values for a terminal through a section table.
Tests substitute one plugin's service directly: new Context([[plugin, mockService]]) seeds the cache so ctx.get(plugin) returns the mock without building the real one.
New command scaffold
- Create the folder:
src/commands/<topic>/<verb>/(orsrc/commands/<verb>/for a top-level command likelogin). - Write
index.ts: a default-exported class extendingCommand<typeof INPUT>with staticsummary,effect('read' | 'write' | 'destructive', default'read'),input(a Zod object), optionalpositionalandexamples, and an instancerun(input, ctx)that returns the value to print — or anOutcomewhen the command needs a non-zero exit or has already written its own output. - Run
pnpm tree:gento add the command tosrc/commands/tree.generated.ts. - Run
pnpm tree:check(also gated in CI) to confirm the generated tree matches the command folders.
Error handling
Throw BaseError. Never throw raw Error for domain failures.
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'workspace id required',
hint: "pass --workspace or run 'difyctl use workspace <id>'",
})
ErrorCode is the exhaustive error-code object; do not scatter raw code strings. exitFor(code) maps it to a process exit code, and BaseError.toEnvelope() serializes it as the one JSON error line on stderr.
| Exit | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic error |
| 2 | Usage error (bad flag, missing or invalid input) |
| 4 | Auth error (not logged in, forbidden) |
| 6 | Catalog error (unavailable, or unknown op id) |
| 7 | Rate limited |
New error code: add to ErrorCode and map it to an ExitCode in codes.ts. Never scatter exit codes inline.
Output
Everything the CLI prints goes through the io plugin's service, never through a stream directly. Three writes cover every case; a new output format or a --quiet flag is a change inside this one plugin.
export type IOService = Readonly<{
document: (value: Printable | View) => Promise<void> // stdout: results, help
emit: (value: Printable | View, channel: ChannelValue) => Promise<void> // either stream
notice: (text: string) => void // one text line on stderr: warnings, progress
raw: (chunk: string | Buffer) => Promise<void> // bytes as given: a text-kind body
streams: IOStreams // stdin and the TTY flags
}>
Printable (a JSON primitive, an array, or a plain object) is what a command may return and what document accepts. A View (sys/io/view.ts) is a Printable plus its text form; help and errors pass one, and the stream's mode picks which is written. Results and streams are JSON in every mode. Streamed events go through raw, one per line. stdout writes use the callback form so a closed pipe reaches printEnvelope as a rejection and the run ends at exit 0. printEnvelope emits the envelope through the same service on the err channel.
IOStreams is the raw layer underneath: realStreams() wraps process.std* in production, bufferStreams() captures output in tests, and ioService(streams) wraps either.
Testing
Keep tests beside the owner as *.test.ts. When a command delegates to a pure module (call/, protocol/), test that module directly. Test the command class or the commands pipeline when argument parsing, help, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes.
dify-mock fixture server
test/fixtures/dify-mock/server.ts mirrors /openapi/v1/* and the catalog endpoint. Each test starts an isolated instance:
import { startMock } from '../../../test/fixtures/dify-mock/server.js'
const mock = await startMock({ scenario: 'happy' })
// ... test against mock.url ...
await mock.stop()
happy (default) is the standard fixture set: 4 apps across 2 workspaces, 2 workspaces, 1 active session. See Scenario in scenarios.ts for the full union — auth failures, rate limiting, catalog-fingerprint mismatches, HITL pauses, pending imports, and more.
New scenario: extend the Scenario union in scenarios.ts, branch in the relevant handler. No per-test mocks — one fixture surface keeps tests aligned with the real API.
Assertions
Inline string/regex/JSON checks — no golden files.
expect(JSON.parse(out).data).toHaveLength(2)
Scripts
| Command | When to run |
|---|---|
pnpm dev <cmd> [args] |
Run CLI from source during dev |
pnpm test |
Full vitest suite — run before every commit |
pnpm test:coverage |
Coverage report |
vp check cli |
Scoped static check from the repository root |
vp check --fix cli |
Scoped static fixes from the repository root |
pnpm build |
Production bundle (vp pack) |
pnpm tree:gen |
Regenerate src/commands/tree.generated.ts |
pnpm tree:check |
Verify the generated tree matches the commands |
pnpm build:bin |
Cross-compile standalone binaries via Bun (CI) |
pnpm build:bin:local |
Same, pinned to the dev channel |
pnpm tree:gen rule: run after adding, removing, or renaming any command. The generated tree.generated.ts is the runtime command registry; a stale tree makes commands invisible at runtime. It also runs through prebuild, predev, and pretest.
README hand-maintained. When adding a command, update the command table in README.md manually.
Lint rules that catch contributors
The repository runs Vite+ Oxlint as the primary code-quality linter, an explicit ESLint config for unsupported cases, and Vite+ Oxfmt for formatting. The fallback config does not depend on the Antfu ESLint config.
| Rule | What it catches |
|---|---|
perfectionist/sort-named-imports |
Alphabetical, case-insensitive |
perfectionist/sort-imports |
Relative imports last; import type first |
antfu/consistent-chaining |
Long .foo().bar().baz() must split across lines |
unicorn/no-new-array |
Use Array.from({ length: n }) not new Array(n) |
noUncheckedIndexedAccess (tsc) |
arr[i] is T | undefined; guard before use |
Run vp check --fix cli from the repository root for scoped formatting, lint, and TypeScript fixes and diagnostics.
PR conventions
- One feature, one PR. Bundle test + impl + doc update.
- Commit style:
<type>(cli): <imperative subject>. Types:feat,fix,refactor,docs,chore. Body explains why if non-obvious. - Plan/spec/superpowers files do not ship in CLI commits.
- Verify diff before committing —
.local.jsonand.vitest-cache/gitignored but check anyway.
Anti-patterns
| Pattern | Do instead |
|---|---|
Raw string error codes 'not_logged_in' |
ErrorCode.NotLoggedIn |
| Long positional arg lists | A Zod input object with named fields |
if (kind === 'sse') { ... } else if (kind === 'list') ... |
A Record<Kind, Renderer> dispatch table (see call/render/index.ts) |
| A command reaching into another plugin's closed-over state | ctx.get(thatPlugin) — the cached service, not its internals |
console.log in src/ |
Return the value from run(); the pipeline prints it |
| New dependency without approval | Check first |