- Rust 98.1%
- Just 0.6%
- Shell 0.5%
- Dockerfile 0.4%
- HTML 0.3%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
Check / fmt + clippy + build + tests (push) Successful in 32s
Build Linux binary / Build yt (Linux x86_64) (push) Successful in 31m35s
Build Windows binary / Build yt (Windows x86_64) (push) Successful in 33m56s
Build macOS binary / Build yt (macOS aarch64) (push) Has been cancelled
Reviewed-on: #151 |
||
| .cargo | ||
| .devcontainer | ||
| .forgejo/workflows | ||
| .idea | ||
| common@7397c2a043 | ||
| crates | ||
| docs | ||
| oci-build | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .gitmodules | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| compose.dev.yml | ||
| compose.mcp.yml | ||
| compose.yml | ||
| justfile | ||
| LICENSE.md | ||
| README.md | ||
| TODO.md | ||
youtrack-cli
Command-line interface for YouTrack. Inspired by dukechill/youtrack-cli
Install
- Package overview (browser): https://dev.a8n.run/pandoras-box/-/packages/generic/youtrack-cli/latest
Pick the artifact for your platform:
| Platform | Artifact | Notes |
|---|---|---|
| Linux x86_64 | yt-linux-x86_64 |
musl-static |
| Windows x86_64 | yt-windows-x86_64.exe |
mingw cross-build |
| macOS arm64 | yt-macos-aarch64 |
Apple Silicon, macOS 11+ |
Linux x86_64:
http get https://dev.a8n.run/api/packages/pandoras-box/generic/youtrack-cli/latest/yt-linux-x86_64 | save yt
^chmod +x yt
^./yt --version
macOS (Apple Silicon):
http get https://dev.a8n.run/api/packages/pandoras-box/generic/youtrack-cli/latest/yt-macos-aarch64 | save yt
^chmod +x yt
^./yt --version
Versioned downloads use the same path with latest replaced by the tag, e.g. .../youtrack-cli/v0.1.0/yt-linux-x86_64.
Intel macs have no published artifact; build from source with cargo build --release. yt version update self-updates on Linux x86_64, Windows x86_64 and macOS arm64, and reports "no prebuilt binary" elsewhere.
yt version check reports whether the published build is newer than the running one and names both in the same <semver> (<commit>, built <date>) shape yt version prints:
New build available on the latest train.
running: 0.13.0 (4faf68cda29b, built 2026-08-06T11:21:05Z)
available: 0.13.0 (d6b9b625c535, built 2026-08-09T14:02:11Z)
Run `yt version update` to install.
The available build's identity comes from a <artifact>.json metadata sidecar published beside each binary and its .sha256 checksum. A build published before that sidecar existed still gets a verdict; only the available: line is omitted. The verdict never depends on the metadata: on the latest train it compares the running binary's checksum against the published one, and on the release train it compares semvers.
Build
Host build (requires a local Rust toolchain):
cargo build --release
# or
just build
Binary lands at target/release/yt.
Docker build (no host toolchain needed; uses ghcr.io/niceguyit/rust-builder-musl):
just build-docker # publishable runtime image -> youtrack-cli:local
just build-docker-export # ./dist/yt on the host
just check-docker # builder stage only, fast Dockerfile feedback
Configuration
Per-instance configurations live under $XDG_CONFIG_HOME/youtrack-cli/. Each YouTrack instance is its own file, named
after the first label of its hostname:
https://niceguyit.myjetbrains.com->config-niceguyit.ymlhttps://jetbrains.myjetbrains.com->config-jetbrains.yml
A config.yml in the same directory points at the default instance:
default: niceguyit
Directory is 0700, files are 0600. auth login to a new URL creates a new config-<instance>.yml; it does not
overwrite an existing one. The default: pointer is set on the first login and never silently changed afterwards.
Pick which instance a command uses (highest priority first):
--instance <name>global flag (per-command override)$YOUTRACK_CLI_INSTANCEenvironment variable- The
default:field inconfig.yml --config <path>bypasses all of the above and reads a single file directly (mostly for tests)
yt auth login --base-url "https://niceguyit.myjetbrains.com" # creates config-niceguyit.yml and sets default: niceguyit
# Add a second instance (separate file; default stays as niceguyit)
yt auth login --base-url "https://prod.youtrack.cloud"
# Per-command override without changing the default
yt --instance prod list
# Switch the default: edit ~/.config/youtrack-cli/config.yml to change `default:`
yt config set board "My Agile Board"
yt config set sprint "Sprint 26"
yt config show # masked token
yt config view # raw YAML
Valid config set keys: url, token, sprint (writes default_sprint), board (writes board_name). For
credentials prefer auth login, which verifies the token against the server before writing.
Articles (Knowledge Base)
YouTrack's web editor handles Markdown but does not reflow tables, which makes wide tables painful to edit. yt article provides a pull / edit / push loop so you can use any IDE for the editing step:
yt article list --project KB # browse a project's articles
yt article inspect KB-A-12 # metadata + content preview
yt article pull KB-A-12 # writes ./KB-A-12.md (frontmatter + body)
# edit KB-A-12.md in your IDE: reflow tables, lint, spell-check, anything
yt article push KB-A-12.md # pushes edits; refuses on stale remote
yt article push KB-A-12.md --ignore-stale # override the stale-snapshot guard
yt article create new-topic.md --project KB # create from a local file
yt article delete KB-A-12 --yes # delete an article
The pulled file is a YAML frontmatter block (id, summary, project, parent, tags, updated, content_sha256) followed by the article body byte-for-byte. The updated and content_sha256 fields back the stale-snapshot guard on push: if YouTrack has been edited since the pull, push refuses unless --ignore-stale is set. A push with no body changes is a no-op (the SHA matches), so pull then immediate push does not bump the article.
Projects
yt project lists and inspects projects, and stands new ones up, so a documented setup is a script instead of a click-path:
yt project list # short name, name, leader, archived, description, id (--json)
yt project show AUD # one project's metadata (--json)
yt project create --key AUD --name Audits # create, with the token's account as leader
yt project create --key AUD --name Audits --description "Audit runs" --leader sam --template kanban
yt project delete AUD --yes # delete the project and every issue in it
create needs --key (the issue id prefix) and --name; --leader takes a login and defaults to the account the token belongs to, because YouTrack refuses a project with no leader. --template is kanban or scrum and pre-populates the board and field set; omitting it creates the bare project. A --key already in use is refused before anything is posted, and the created project prints in the same table project list uses (--json emits the project itself).
delete prompts before deleting unless --yes is given, matching yt issue delete, and then re-reads the project list so a request the server accepted and then ignored is reported instead of confirmed. It takes every issue in the project with it.
The global --dry-run flag prints the endpoint and the exact request body for both without sending anything, plus a resolved block naming the leader login the body's id stands for. Creating needs the Create Project permission and deleting needs Delete Project; a 403 is reported as the missing permission by name.
Attaching the custom fields a new project needs (Type, Priority, AI Agent) is the next step after create; see Project custom fields.
Project custom fields
yt project fields is the Administration > Projects > Fields screen: which custom fields a project has attached, and with which bundle. Naming the bundle is the point. Attaching a NEW field called AI Agent instead of the existing instance-level one produces a field that looks right in the UI, carries its own empty bundle, rejects every yt issue set-field write with HTTP 400, and makes a saved search on that field match nothing.
yt project fields AUD # attached fields with their bundle name and id (--json)
yt project fields list AUD # same thing, spelled as a subcommand
yt project fields add AUD --field "AI Agent" --empty-allowed # attach, reusing the bundle other projects use
yt project fields add AUD --field "AI Agent" --bundle "PSA-Mokosh Apps: AI Agent" # or name the bundle explicitly
yt project fields add AUD --field "Blast Radius" --create-type "enum[1]" --bundle Priorities # create the field first, then attach
yt project fields remove AUD --field "AI Agent" --yes # detach
add attaches an instance-level field that already exists; it never creates one implicitly. A --field name matching more than one instance-level field is refused with the candidate ids rather than guessed at, and so is a --bundle name matching more than one bundle (pass the bundle id). With --bundle omitted, the bundle is taken from the projects that already attach the field, and only when every one of them agrees; a field nobody has attached yet has no bundle to copy and asks for --bundle. Pass --create-type <FIELD_TYPE> (enum[1], state[1], user[1], integer, ...) to create the instance-level field when no field of that name exists, which is the one case add writes outside the project.
remove prompts before detaching unless --yes is given, matching yt issue delete. Both writes re-read the project afterwards, so a request the server accepted and then ignored is reported instead of confirmed.
Reads need Read Project and writes need Update Project; a 403 is reported as the missing permission by name. The global --dry-run flag prints the endpoint and the exact request body for add / remove without sending anything, plus a resolved block naming the field id and bundle the body's ids stand for.
Project VCS integrations
yt project vcs reads and edits the Version Control project-settings screen. Mutations target a processor by its server-side id (shown by list / show):
yt project vcs list YT # id, server, path, enabled, type (--enabled-only, --json)
yt project vcs show YT 99-1 # every setting of one processor (--json)
yt project vcs add YT --server https://git.example.com --path acme/widgets # add an enabled Gitea/Forgejo processor
yt project vcs add YT --server https://git.example.com --path acme/widgets --disabled # add it disabled
yt project vcs update YT 99-1 --enabled false --path acme/gadgets --branch-spec "+:refs/heads/*"
yt project vcs delete YT 99-1 # remove a processor
add references a VCS server that is already configured globally in YouTrack (Administration > Integrations > VCS); an unknown --server URL fails without attempting to create the server entry. Only Gitea/Forgejo processors can be created; GitHub/GitLab/etc. are follow-up work. The global --dry-run flag prints the intended endpoint and payload for add / update / delete without sending any request.
The VCS surface on the Project entity is undocumented; the mutation endpoint shapes are recorded as assumptions in code comments next to each client function and revised if a server rejects them.
Breaking change: the old read-only yt project vcs <SHORT_NAME> is now yt project vcs list <SHORT_NAME>.
Project team and roles
yt project team answers "who has access to this project" and manages the Settings > People screen: the team (direct member users and member groups) and the project-scoped role assignments.
yt project team list YT # team members and their project-scoped roles (--json)
yt project team list YT --all # also list non-member holders of a project-scoped role
yt project team add YT --user sam # add a direct member
yt project team add YT --group Developers # add a member group
yt project team remove YT --user sam # remove a direct member
yt project team grant YT --user sam --role Contributor # grant a project-scoped role
yt project team revoke YT --group Developers --role Observer
list shows one row per holder: each direct member user, each member group, and the team group itself when it holds a role (that is how a role reaches every member at once). --all adds the Other People with Access section, holders of a project-scoped role who are not team members. Role names are the instance's own (Contributor, Developer, Observer, Project Admin, ...); an unknown --role fails with the available list.
Reads need Read Project and writes need Update Project; a 403 is reported as the missing permission by name. The global --dry-run flag prints the intended endpoint and payload for add / remove / grant / revoke without sending any request.
These endpoints (/api/admin/projects/<id>/team, /api/assignedRoles) arrived in YouTrack 2026.1; on an older server the commands fail with a 404 and the equivalent Hub API is not used.
Agile boards
yt board list names every board; yt board show reads one board's whole settings screen, the one the web UI keeps behind Board settings, and yt board create / update / delete write it.
yt board list # board name and id (--json)
yt board show "youtrack-cli Task Management Board" # the full settings screen
yt board show # same, for the board in `config set board`
yt board show "My Board" --json # the deserialized Agile entity
yt board create "My Board" --project YT --template kanban # --project repeats, --owner defaults to you
yt board update "My Board" --name "Renamed" # writes only the settings you name
yt board update "My Board" --wip "In Progress=1:4" --swimlane-field Priority
yt board delete "My Board" --yes # prompts without --yes
The human rendering is a labelled table per section: Overview (owner, backlog and its query, orphan handling, board status), Projects, Columns (the field driving the columns, each column's ordinal, resolved flag, WIP limits and field values), Swimlanes (attribute-based or issue-based, its field and values), Sprints (whether sprints are enabled, the explicit query, the sync field, and every sprint on the board), Estimation, Colour coding, Card fields, and Permissions. An unknown board name fails with board '<name>' not found, the same error yt sprint gives.
projects and sprints are read from their own paged subresources rather than the entity selector, so neither is capped at YouTrack's 42-row default. cardSettings comes back null from every board tested and its subresource answers 404, so the Card fields section reports that the server does not expose them rather than inventing a list.
create needs at least one --project, since a board with no project has nothing to draw from; --owner defaults to the account the token belongs to, and --template kanban|scrum pre-populates columns and sprints the way the create dialog does. update is a partial write: a setting you do not name is left out of the request body entirely, so it cannot be overwritten. --project is the exception in the other direction, replacing the board's project set rather than adding to it. --wip <COLUMN>=<MIN>:<MAX> accepts an empty bound ("Doing=:5") and clears the limit when both are empty ("Doing=:"). Any name that matches more than one project, group, field, or column is an error listing the candidates, never a silent pick. delete prompts unless --yes is given and re-reads the board list afterwards, so a request the server ignored is reported instead of confirmed. All three honour --dry-run, which prints the endpoint and the byte-exact payload with the ids it resolved in a separate resolved block and sends nothing.
Issue tags
yt tag and yt issue tag are the typed tag surface: tags are read, attached, and detached through the REST tag endpoints rather than the tag <name> command language, so every write targets a resolved tag id and can be read back.
yt tag list # every tag on the instance, name and id (--json)
yt issue tag list YT-90 # the tags attached to one issue (--json)
yt issue tag add YT-90 regression # attach an EXISTING tag, resolved by exact name
yt issue tag add YT-90 flaky --create # create the instance-wide tag first, then attach
yt issue tag remove YT-90 regression # detach by name (a tag id works too)
Breaking change: yt issue tag add no longer creates a tag that does not exist. A name that resolves to nothing fails and lists the near matches, and --create is the opt-in, because creating an instance-wide tag is a side effect well beyond what "tag this issue" reads as and there is no tag delete to undo it. A name that matches several visible tags fails naming every candidate id instead of guessing.
yt issue inspect reports an issue's tags in its Fields block, and both listings page through the whole collection. The global --dry-run flag prints the endpoint, the byte-exact request body, and a separate resolved block with the name-to-id mapping for add / remove without sending anything.
Server version
yt server version reports the version and build of the YouTrack server the active instance points at, which is how you tell whether a version-gated feature (e.g. the 2026.1 project team endpoints above) is available before running it. It sits under server rather than version because the version namespace describes the yt binary itself.
yt server version # YouTrack 2026.2 (build 17765) at https://example.youtrack.cloud
yt server version --json # {"version":"2026.2","build":"17765"}
It reads GET /api/config?fields=version,build, an endpoint absent from the published REST reference but available since YouTrack 2018.1; a server that omits either field still prints a line rather than failing.
MCP server
The yt binary bundles a YouTrack Model Context Protocol server, so an AI agent can drive YouTrack through the same REST code path as the CLI. Following the Zellij lead (zellij web starts a web server), yt mcp serve starts the web service and yt mcp stdio runs the pipe transport:
# stdio: the transport an MCP client launches itself. Credentials come from the
# active `yt` instance (or YOUTRACK_BASE_URL / YOUTRACK_TOKEN / --base-url).
yt mcp stdio
# Register it with Claude Code (stdio):
claude mcp add youtrack -- yt mcp stdio
# serve: the multi-user HTTP web service (OAuth 2.1 + per-user /mcp), behind a
# TLS-terminating reverse proxy. Env / .env driven; see docs/deployment.md.
yt mcp serve --public-base-url https://youtrack-mcp.example.com
The MCP server ships behind the default-on mcp cargo feature. Build a slim CLI without the rmcp / axum / sqlx / oauth2 stack with cargo build --no-default-features. The HTTP service exposes issues, issue tags (list_tags, list_issue_tags, add_issue_tag, remove_issue_tag), comments, work items, projects (including the team and project-scoped roles), articles, agile boards, users, and the connected server's own version as MCP tools; see docs/deployment.md, .env.example, and compose.mcp.yml for deploying it.
Every mutating tool accepts dry_run: true, the per-call equivalent of the CLI's global --dry-run: the write is skipped and the tool returns what it would have sent. Passing dry_run to a read-only tool is an error rather than a silently ignored argument. apply_command goes further and returns YouTrack's own parse of the command string (via /api/commands/assist), including any parse errors, so an unfamiliar command-language string can be checked before it runs; yt issue apply --dry-run prints the same parse.
Development
The just task runner is split between this repo and the shared psa-systems/common recipes, vendored as the common git submodule and imported by the justfile. After cloning, initialize the submodule so the import resolves:
git submodule update --init
Only youtrack-cli-specific recipes (the dev container, docker build, and pre-commit) are defined locally; the rest (checks, formatting, release) come from common. To pull a newer common: update the submodule and commit the new gitlink.
Host commands (require a local Rust toolchain):
just build # cargo build --release
just test # cargo test --all-targets
just lint # cargo clippy --all-targets -- -D warnings
just fmt # cargo fmt
just run # cargo run --release
Container-based dev (no host toolchain needed):
just dev # docker compose --file compose.dev.yml up --build
just shell # interactive bash in the dev container
just dev-clean # remove this user's cargo named volumes
The CI check workflow is mirrored in just pre-commit. Wire it into git commit:
just install-hooks
Releasing
just create-release major # vX.0.0
just create-release minor # v0.X.0
just create-release hotfix # v0.0.X
create-release comes from common. Because youtrack-cli is a cargo workspace, the justfile sets release_layout := "workspace" and release_manifest := "crates/yt-cli/Cargo.toml", so the recipe bumps that member crate's version, syncs Cargo.lock (cargo update --package youtrack-cli), pushes a release/vX.Y.Z branch, and opens the PR. After the PR merges, .forgejo/workflows/create-release.yml tags and publishes the release.
Tracing
$env.RUST_LOG = "yt=debug"; cargo run --release -- config show
Other implementations
- https://github.com/tonyzorin/youtrack-mcp
- https://github.com/pushax/youtrack-mcp
- https://github.com/GaijinEntertainment/youtrack-rocket-mcp
License
MIT. See LICENSE.md.