When you change code patterns, conventions, or architecture, update this file in the same commit. AGENTS.md is the single source of truth for project conventions — if it's stale, the next agent will follow wrong patterns.
drycc is the command line utility for the Drycc PaaS. This Go project is the
user-facing tier of a three-repo stack — it talks to a controller over HTTP
through controller-sdk-go. Read this file before editing anything.
controller (Python/Django, the API server) ← defines the contract
↓ HTTP REST
controller-sdk-go (Go client SDK) ← wraps the HTTP calls
↓ imported
workflow-cli (this repo, the `drycc` binary) ← parses args, renders output
- The controller is the source of truth for URL shape, request/response
payloads, and error semantics. When adding a feature, look at the controller's
rootfs/api/urls.pyandrootfs/api/views/first. - A new backend endpoint normally touches all three repos: add the SDK function, then the CLI command, then (if needed) the controller test already exists or is added by the controller author.
- These repos live as siblings on this machine under
~/Sources/. Paths used in examples below assume that layout.
- Go 1.26, module path
github.com/drycc/workflow-cli - CLI framework:
github.com/spf13/cobra - HTTP client:
github.com/drycc/controller-sdk-go - Output:
github.com/olekukonko/tablewriter(tables),pkg/coder(flat YAML) - i18n:
github.com/chai2010/gettext-go+.po/.mocatalogs vendor/is gitignored — it is regenerated locally, never committed
cmd/root.go Binary entrypoint; builds the root cobra command
internal/
parser/ cobra command definitions (one file per domain)
commands/ command implementations (DryccCmd methods) + Commander interface
completion/ shell-completion helpers (AppCompletion, AddonCompletion, ...)
loader/ LoadAppSettings: resolves appID + settings + SDK client
template/ cobra example templating
plugins/ plugin loader
pkg/
coder/ YAML encode/decode (flat snake_case, no K8s Manifest wrapper)
i18n/ translation loader + catalogs (en_US, zh_CN)
settings/ client config file (~/.drycc/client.json)
testutil/ test HTTP server + helpers
git, ssh, logging, webbrowser small supporting packages
scripts/
build release build script
update-translations.sh i18n extraction (needs go-xgettext + gettext)
version/ version constants
Every domain (addons, apps, certs, gateways, ...) is spread across five
files. To add a subcommand you touch all of them. Example for addons conn:
| Layer | File | Responsibility |
|---|---|---|
| 1. SDK | controller-sdk-go/<domain>/<domain>.go |
Connection(c, appID, name) → HTTP call |
| 2. command | internal/commands/addons.go |
(d *DryccCmd) AddonsConnection(...) — loads settings, calls SDK, renders output |
| 3. cobra wiring | internal/parser/addons.go |
addonsConnectionCommand(cmdr) — Use/Short/Example/flags; registered in NewAddonsCommand via cmd.AddCommand(...) |
| 4. interface | internal/commands/commands.go |
add the method signature to the Commander interface |
| 5. root | cmd/root.go |
rootCmd.AddCommand(parser.NewAddonsCommand(&cmdr)) (only for new top-level domains, not subcommands) |
| 6. completion | internal/completion/completion.go |
<Domain>Completion struct with CompletionFunc (shell tab-complete) |
| 7. coder | pkg/coder/<domain>.go |
only when the command reads/writes K8s-style YAML manifests |
Don't forget layer 4. The Commander interface enumerates every command
method; mocks and tests rely on it. Adding a DryccCmd method without declaring
it in the interface is the #1 missed step.
- Top-level cobra
Usestrings are full words unless they are an industry term (tls,ps,ptypes,certs).addons,gateways,volumes,domainsare all full words. Don't abbreviate casually. - Go identifiers mirror the SDK/controller exactly: SDK
Connection()→ CLIAddonsConnection(). Never invent a divergent name (e.g. don't call the SDKConnectionsomething else in the CLI). addon/AddOn/addon— the project spells it lowercase "addon" in prose and identifiers (addons,AddonInstance,AddonClass). Keep it.- All API-related field names and CLI parameters use snake_case. K8s
camelCase names (e.g.,
livenessProbe,httpGet,postStart,backendRefs) are converted to snake_case by the controller'sCamelCaseJSONField. CLI command arguments and display labels must also use snake_case:liveness_probe,http_get,post_start,backend_refs, etc.
All Short/Long/flag descriptions/<name> arg descriptions must be wrapped:
Short: i18n.T("Show connection details for an addon"),After adding/changing strings, regenerate catalogs:
export PATH="$PATH:$(go env GOPATH)/bin" # go-xgettext lives here
go install github.com/gosexy/gettext/go-xgettext@latest # one-time
bash scripts/update-translations.sh -xkg # extract + fix .po + build .moThen translate the new msgstr "" entries in
pkg/i18n/translations/drycc/zh_CN/LC_MESSAGES/cli.po (English is the source —
en_US entries mirror the msgid). Re-run -g after editing .po. Keep the
technical term "addon" untranslated in zh_CN to match the identifiers.
- Lists ->
d.getDefaultFormatTable([]string{"COL1","COL2"}), render withtable.Append/table.Render. It is whitespace-packed (NoWhiteSpace); assert withassert.Containsin tests, not exact-string equals. - Single-object details ->
pkg/coderflat YAML (snake_case, no K8s Manifest wrapper) via a*Coder.Encode(). CoderDecodeis justjson.Unmarshal,Encodeis justmarshalYAML- no field renaming. - Connection/secret-style key->value blobs -> KEY/VALUE table, keys sorted.
- Mutating ops (apply/remove/upsert) -> print
"<verb> <obj> ... ", callprogress(d.WOut)for a spinner, thendone.
internal/parser/flags.go holds package-level app, version, limit
vars reused across command constructors. Set via PersistentFlags / Flags.
commands.ResponseLimit(limit) resolves 0 → user's configured default.
pkg/testutil.NewTestServerAndClient()spins up anhttptestserver + writes a temp client config. Register handlers withserver.Mux.HandleFunc(...).internal/commands/main_test.gosetsLANG=LC_ALL=LC_MESSAGES=en_US.UTF-8soi18n.T()returns English and assertions are deterministic. Don't delete it and don't set conflicting locales in command tests.- Mark new tests
t.Parallel(). - Use
github.com/stretchr/testify/assert(already imported everywhere). - The whole
commandspackage is tested via theDryccCmdstruct wired to the fake server; never hit a real controller in unit tests.
workflow-cli depends on github.com/drycc/controller-sdk-go by pseudo-version
commit. When you change the SDK locally and want to test here:
- Commit (and usually force-push, since feature commits get amended) the SDK
change to
controller-sdk-gofirst. - In workflow-cli:
go get github.com/drycc/controller-sdk-go@<new-sha>, thengo mod vendor, thengo build ./.... - Commit
go.mod+go.sum.vendor/stays untracked (gitignored).
Do not leave a replace github.com/drycc/controller-sdk-go => ../controller-sdk-go
directive in go.mod — it's a dev-only shortcut that breaks everyone else's
build. If you add it temporarily, remove it before committing.
go build ./... # compile everything
go vet ./... # static checks — run before commit
go test ./... # all tests
go test ./internal/commands/... ./pkg/... # just the layers you touched
go test -run TestAddons -v ./... # one feature's tests
go run . addons conn --help # smoke-test a command's help
LANG=zh_CN.UTF-8 LC_ALL=zh_CN.UTF-8 go run . addons conn --help # zh checkThere is no go generate step in normal builds. CI (Makefile) runs inside a
go-dev container and calls update-translations.sh -g during build-binary.
- Commit subject format:
type(scope): subject, e.g.feat(addons): add addons support,chore(api): add uid for app. Common types:feat,chore,fix. Scope is the domain (addons,gateway,cli,resources, ...). - A single feature spans both
controller-sdk-goandworkflow-cli. Land it as one commit per repo with the same subject in both. If you iterate on the feature after the initial commit,git commit --amend --no-editto fold follow-ups into that one commit rather than stacking new ones. - Force-pushing an amended feature commit to
controller-sdk-gois expected and accepted here — confirm the target branch first (main) and use--force-with-lease. - Never commit secrets,
vendor/, or the devreplacedirective.
- Controller — confirm the URL + viewset exist in
controller/rootfs/api/. - SDK (
controller-sdk-go): add types inapi/<domain>.go, functions in<domain>/<domain>.go, fixtures+tests in<domain>/<domain>_test.go.go build && go test ./<domain>/...there, commit, force-push. - CLI SDK bump:
go get ...@<sha> && go mod vendor. - CLI command (
internal/commands/<domain>.go):DryccCmdmethods, load settings vialoader.LoadAppSettings, render output. - CLI parser (
internal/parser/<domain>.go): cobra commands, i18n-wrapped strings, flag wiring, register subcommands in the domain'sNew<Domain>Command. - Commander interface (
internal/commands/commands.go): add every new method signature. - Root (
cmd/root.go):rootCmd.AddCommand(parser.New<Domain>Command(&cmdr)). - Completion (
internal/completion/completion.go): if the command takes a<name>arg that maps to existing resources, add a<Domain>Completion. - Coder (
pkg/coder/<domain>.go): only if the command reads/writes YAML manifests. - i18n:
bash scripts/update-translations.sh -xkg, translate zh_CN,-g. - Tests:
internal/commands/<domain>_test.go(+pkg/coder/<domain>_test.go). - Verify:
go vet ./... && go test ./...before committing.
- Forgetting to add the method to the
Commanderinterface (step 6 above). - Leaving the
replacedirective ingo.mod(breaks CI / other clones). - Hardcoding
LANGin a test that conflicts withmain_test.go. - Committing
vendor/(it's gitignored — ifgit statusshows vendor files, your.gitignoreis wrong). - Asserting exact table output strings — the tablewriter pads unpredictably; use
assert.Contains. - Abbreviating a
Usename that should be a full word (see Naming). - Adding a
msgstrbut forgetting to re-run-gto rebuild the.mo.