teamcity is written in Go.
Prerequisites:
Optional:
- GoLand or IntelliJ IDEA — both are free for open-source development
- golangci-lint (for
just lint)
Clone and build:
git clone git@github.com:JetBrains/teamcity-cli.git
cd teamcity-cli
just buildOn Windows, just build, just install, just lint, and the other simple Go recipes work in PowerShell with no extra setup. A handful of recipes use bash shebangs (clean, docs-build, docs-deploy, install-choco, install-codesign, eval, eval-diff) and require Git Bash or WSL.
just build # go build → bin/teamcity
just install # go install ./tc → $GOPATH/bin/teamcity
just lint # go fmt + go fix + golangci-lint
just unit # unit tests
just test # unit + integration (testcontainers)
just acceptance # e2e against cli.teamcity.com (-tags=acceptance)
just snapshot # goreleaser local snapshot (all platforms)
just docs-generate # regenerate CLI command reference
just record-gifs <name> # record GIF from docs/tapes/<name>.tape → docs/images/Run just with no arguments to see all available recipes.
The main package is ./tc/, not the repo root:
go build -o bin/teamcity ./tc/
go install ./tc/go build . from the repo root produces an ar archive — the root is package teamcitycli (skills embed), not main.
Unit tests run without any setup. Integration tests need a TeamCity server — by default, they spin one up via testcontainers, which requires Docker.
To use an existing server instead, copy the env template and fill in your values:
cp .env.example .envtc/ # main package
api/ # public HTTP client — don't break exported interface
interface.go # ClientInterface
client.go # HTTP implementation
types.go # request/response structs
internal/
cmd/ # one subpackage per noun: run/, agent/, project/, job/, …
root.go # root cobra command, global flags
cmdutil/ # Factory, shared helpers, client init
cmdtest/ # mock server, RunCmdWithFactory, SetupMockClient
config/ # auth (keyring + file), server detection
output/ # Printer, colors, tables, trees, status icons
errors/ # Structured error types
terminal/ # Agent WebSocket terminal
acceptance/ # .txtar e2e tests (testscript framework)
docs/ # Writerside topics + images + tapes
skills/teamcity-cli/ # AI agent skill
Data flow: tc/main.go → cmd.Execute() → cobra tree → *cmdutil.Factory → f.Client() → API → output.Printer.
Every command:
- Package under
internal/cmd/<noun>/ NewCmd(f *cmdutil.Factory)returns cobra commandrun<Verb>(f, opts)does the work — pure logic, testable- Register in
root.go
Breaking changes to exported types/functions need explicit sign-off. internal/ refactoring is free.
Search for the helper you think you need before creating a new package. internal/cmd/<sub>/git.go, internal/cmdutil/, etc. may already host it. Creating a parallel package (e.g. duplicating isGitRepo) is a common trap and gets caught in review — extract a shared package only when there's a second consumer.
Go 1.26. Follow JetBrains Go Modern Guidelines.
Hard rules:
- No CGO. Any dep requiring CGO is rejected.
- No
os.Exitin commands. Return errors; onlytc/main.goexits. []T{}notvar s []T— nil slices serialize to JSONnull.slices.SortFuncnotsort.Slice.t.Context()notcontext.Background()in tests._, _ = fmt.Fprintf(...)— satisfy errcheck in output code.
All output through *output.Printer. Never fmt.Printf in commands.
p.Info(),p.Success()— suppressed by--quietp.Warn(),p.Debug()— stderr onlyp.PrintTable(),p.PrintJSON()— always print, never suppressedfmt.Fprintln(p.Out, ...)— for primary output that must always appear- Never
cmd.OutOrStdout()— usep.Out
- API errors → typed (
api.NotFoundError,api.PermissionError) - Commands →
tcerrors.UserErrorwith suggestions viatcerrors.WithSuggestion(msg, hint) - Root
Execute()printsError: <msg>\nHint: <suggestion>
Error strings: lowercase, no trailing punctuation. Wrap with %w, not bare return err.
One-line doc comments on funcs. No multi-line restate-the-code text. Inline comments only for non-obvious things (magic numbers, OS quirks, why-not-the-obvious-approach).
All new features and bug fixes must include tests. We have a solid integration test setup with testcontainers that spins up a real TeamCity server — please use it. If your change touches API behavior or user-facing commands, an integration test is expected, not just unit tests.
- Prefer testcontainers integration tests over mocks for
api/behavior. - Every new command gets an acceptance test in
acceptance/testdata/<noun>/. requirefor setup,assertfor assertions,t.Parallel()where safe.internal/cmdtest/:SetupMockClient,RunCmdWithFactory,RunCmdWithFactoryExpectErr.
- Test env vars:
t.Setenv(k, v)only; uset.Setenv(k, "")to clear. Neveros.Unsetenv— it doesn't restore. - Test cwd: small
chdir(t, dir)helper usingt.Cleanupto restore. Don't return defer functions. - Test surface split: unit tests cover internal helpers (parsers, cascades, etc.); acceptance scripts (
acceptance/testdata/<sub>/*.txtar) cover the user-facing binary surface. Don't duplicate — if a.txtarasserts--clearremoves a file, no parallel unit test for the same. - Test isolation:
cmdtest.NewTestServercallsFactory.SkipLinkLookup()so unit tests don't pick up the host'steamcity.toml. Pattern this for any future per-cwd config you add.
All commands that produce data output must support --json. When --json is active:
- Success output goes to stdout as the resource data (object or array).
- Error output goes to stderr using the structured
{"error": {"code": "...", "message": "...", "suggestion": "..."}}envelope. Error classification happens automatically inroot.gofor any command with a--jsonflag. - No field removals or renames without a deprecation period. Additive fields are always safe.
- New commands must include
--jsonfrom day one if they produce data output.
See internal/output/json_error.go for the error codes and docs/topics/teamcity-cli-scripting.md for the full policy.
Acceptance tests are end-to-end blackbox tests that exercise the real CLI binary against a live TeamCity server (cli.teamcity.com). They use the testscript framework with declarative .txtar scripts in acceptance/testdata/.
just acceptance # in-process, guest auth
just snapshot # goreleaser snapshot (builds binary + runs acceptance tests)With authentication (runs all tests including write operations):
TC_ACCEPTANCE_TOKEN=<your-token> just acceptanceTo run a single test:
TC_ACCEPTANCE_SCRIPT=agent-cloud go test -tags=acceptance -v ./acceptance/ -count=1 -timeout 10mEach .txtar file is a self-contained test script. Key patterns:
[!has_token] skip 'requires authentication token'
exec teamcity run list --no-input
stdout '.'
! stderr 'Error'
extract '"id":\s*(\d+)' BUILD_ID
Custom commands: extract, wait_for_agent, stdout2env, env2upper, sleep.
Conditions: [has_token], [guest].
Acceptance tests are embedded in the goreleaser build pipeline as a post-build hook (.goreleaser.yaml). They run automatically after building the CLI binary for the native platform:
- Snapshot builds (every push): guest-auth tests — no token needed
- Release builds (tagged): token-auth tests using
TEAMCITY_TOKENsecret — failures block publishing
Every CLI command and subcommand has acceptance test coverage. The following is intentionally excluded:
--webflags (open a browser, no headless assertion possible)run watch --logs(starts a full-screen TUI, needs a terminal)agent term(WebSocket terminal session, needs an interactive TTY)agent enable/disable,authorize/deauthorize,move,reboot(need admin privileges and a live agent)run start --personal,--local-changes,--no-push(need a VCS-connected checkout)project settings validate(needs Maven installed locally)completion <shell>(cobra has it tested)
Flags tested implicitly (same code path as tested flags):
--secureonparam set(identical to a regular set, just marks value encrypted server-side)run start --rebuild-deps,--agent,--rebuild-failed-deps,--clean(build queue options, same API path as--branch)
- Server:
cli.teamcity.com(TeamCity Cloud, configurable viaTC_ACCEPTANCE_HOST) - Sandbox project: use
Sandboxfor any write operations (param set/delete, token put, run start) - Cloud agents: ephemeral — tests that need agents must start a build, wait for assignment, then clean up
- Isolation: each test gets its own
HOMEdirectory, no cross-test state leakage
Run just lint before pushing. The CI lint job uses golangci-lint with
.golangci.yml (includes gocritic, misspell, among others).
Watch for:
gocritic/ifElseChain— rewrite toswitchmisspell— US locale (canceled, color)errcheck— excluded in test files only
Run, in order:
go test ./...just lint(golangci-lint +go fmt+go fix)git statusafter lint —go fmt ./...may touch unrelated files (e.g.internal/gallery/); revert those withgit checkout -- <path>so they don't bleed into your PRgo test -tags=acceptance ./acceptance/...if you touched acceptance scripts
Update all three:
docs/topics/— Writerside topics + GIF if neededskills/teamcity-cli/— SKILL.md + references/commands.md + references/workflows.mdREADME.md— commands table
Grep the flag/command name across all three before closing the PR.
The canonical documentation lives in JetBrains/teamcity-documentation and is published at jb.gg/tc/docs. A local copy is kept in docs/topics/ for reference and editing convenience.
Use the sync recipes to keep local and upstream docs in sync:
just docs-pull # fetch latest from teamcity-documentation
just docs-push # open a PR to teamcity-documentation with local changes
just docs-generate # regenerate the CLI command reference tableGIFs: Terminal recordings (in docs/images/) illustrate key workflows. If your change visibly alters CLI output for an existing GIF, re-record it. Use vhs with tape files in docs/tapes/. Always set TEAMCITY_NO_UPDATE "1" in tapes. Use cli.teamcity.com + TEAMCITY_GUEST "1" for public demos, buildserver.labs.intellij.net for richer dependency trees.
Follow these rules when adding flags:
Reserved short flags. These are taken globally and must never be reused by subcommands:
| Short | Global flag |
|---|---|
-q |
--quiet |
-v |
--version (Cobra built-in) |
Don't shadow globals. A subcommand flag like --verbose with -v shadows Cobra's built-in --version. A subcommand -q shadows the global --quiet. If in doubt, skip the short flag entirely — a long flag with no shorthand is always safe.
Avoid ambiguous shorthands. If a command has both --limit (-n) and --dry-run, don't give -n to --dry-run — it conflicts. When two flags could reasonably claim the same letter, neither gets it.
Use standard flag names. Prefer these established names for consistency across commands:
| Meaning | Flag name | Short |
|---|---|---|
| Limit number of results | --limit |
-n |
| Filter by branch | --branch |
-b |
| Skip confirmation prompt | --force |
-f |
| JSON output | --json |
— |
| Suppress non-essential output | --quiet |
-q (global) |
When renaming or retiring a flag, use cmdutil.DeprecateFlag:
cmd.Flags().StringVar(&opts.job, "job", "", "Filter by job")
cmd.Flags().StringVar(&opts.job, "build-type", "", "")
cmdutil.DeprecateFlag(cmd, "build-type", "job", "v2.0")Stderr when --build-type is used:
Flag --build-type has been deprecated, use --job instead (will be removed in v2.0)
Rules:
- Register the old flag before calling
DeprecateFlag— it panics if the flag is not found (catches typos at startup) - Bind the old flag to the same variable as the new flag so both work
- Set the old flag's usage to
""— Cobra hides deprecated flags from--helpautomatically - Pick a removal version at least one minor release out
When retiring or replacing a command, use cmdutil.DeprecateCommand:
cmd := &cobra.Command{Use: "old-cmd", ...}
cmdutil.DeprecateCommand(cmd, "new-cmd", "v2.0")Stderr when old-cmd is invoked:
Command old-cmd is deprecated, use "new-cmd" instead (will be removed in v2.0)
The command still runs — users are warned but not broken. Remove it in the target version.
No flags or commands are deprecated today; these are the patterns for when the first deprecation is needed.
Push your branch and open a PR against main. The PR template will guide you through describing the change — fill in every section it defines.
We're fine with AI tools — Junie, Claude Code, Copilot, whatever helps you move faster. But you must understand the code you're submitting. teamcity is a tool where we prioritize security and reliability. PRs with AI-generated code that the author can't explain or defend during review will not be merged.
Use https://cli.teamcity.com (guest auth) when debugging this project's own pipeline — not GitHub Actions.
TEAMCITY_URL=https://cli.teamcity.com teamcity run list --status failureThis section is for maintainers.
Releases are handled by goreleaser and publish to Homebrew, Scoop, Chocolatey, Winget, and GitHub Releases.
just snapshot # build a local snapshot
just release-dry-run # full release process without publishingTag and push — the release pipeline on TeamCity handles everything else automatically (build, acceptance test, sign, publish to all package managers):
git tag -a v1.1.1 -m "Release v1.1.1"
git push origin v1.1.1Chocolatey pushes can fail with 503 Service Unavailable or other transient errors. When this happens, upload the package manually:
-
Check out the release tag and build the package locally:
git checkout v0.9.0
-
Download the existing
.nupkgfor the previous version to use as a template:curl -L -o old.nupkg 'https://community.chocolatey.org/api/v2/package/TeamCityCLI/<previous-version>' unzip old.nupkg -d old/ -
Create a new package directory with updated
TeamCityCLI.nuspec(bump<version>and<releaseNotes>URL) andtools/chocolateyinstall.ps1(update the download URL andchecksum64fromchecksums.txton the GitHub release page). -
Pack and push:
choco apikey --key <YOUR_API_KEY> --source https://push.chocolatey.org/ choco pack choco push TeamCityCLI.<version>.nupkg --source https://push.chocolatey.org/
If a release needs to be reverted:
- Revert the formula/manifest commits in jetbrains/homebrew-utils and jetbrains/scoop-utils
- Close the auto-created winget PR in microsoft/winget-pkgs
- Cancel the Chocolatey submission (if still pending moderation) on chocolatey.org
- Delete the tag and release it from the GitHub repository:
Then delete the release from the Releases page.
git tag -d v1.1.1 git push origin --delete v1.1.1