@apytel/youtrack-cli
v0.6.0
Published
Thin YouTrack CLI for humans and AI agents — one dependency, compact output
Maintainers
Readme
youtrack-cli
A thin YouTrack CLI built for AI agents and humans at the same time.
Every command below runs against a fake YouTrack in CI with no secrets, and the browser OAuth flow and every write path have been exercised against a live instance.
Getting started
npm install -g @apytel/youtrack-cli
yt login # browser OAuth; the credential goes to the OS keychain
yt lsFor CI, containers and headless Linux, skip yt login entirely:
export YT_URL=https://example.youtrack.cloud
export YT_TOKEN=perm:…yt login --status says which of the two is in use. yt login --token stores a
permanent token in the keychain instead of running the browser flow.
The browser flow needs Allow automatic OAuth client registration via CIMD
enabled on the instance — Access Management > OAuth Clients. It is off by
default. Without it yt login explains the switch and falls back to a token.
What it can do
--json on any command returns YouTrack's raw response, unmodified. yt help
prints the same list; exit codes are 0 ok, 1 auth, 2 not found,
3 command rejected, 4 usage.
Authentication
yt login [--url URL] browser OAuth; the credential goes to the OS keychain
yt login --token store a permanent token instead, for CI and headless hosts
yt login --status which instance, and which of the two credentials is in use
yt logout clear the stored credentialIssues
yt ls [query] [--fields F] [--top N] [--all] YouTrack query syntax; state included
yt view <id> [--comments]
yt new <project> <summary> [-d text | -f file] -d and -f omitted: read stdin
yt edit <id> [-s summary] [-d text | -f file] summary and/or description
yt comment <id> <text>
yt attach <id> <file...>
yt attach --download <id> [name] save them back into the current directoryyt attach --download closes the loop the upload half leaves open: an agent in a
later session can read what an earlier one attached. Without a name it saves
every attachment of the issue and prints the file names; with one it saves that
attachment alone. An issue with nothing attached, or a name that is not there,
exits 2 rather than writing nothing and claiming success.
-d, -f and stdin are three spellings of the same argument, so a multi-line
body never has to survive shell quoting:
yt new DEMO "Rework the export" -f draft.md
git log -1 --format=%B | yt edit DEMO-42yt ls shows 50 issues. When more matched, it says so on stderr — stdout
stays exactly the rows, so yt ls | wc -l and yt ls --json | jq are unaffected
by the warning, and nothing is silently cut without a word. --top N widens the
window; --all pages through every match and prints no warning at all (with
--json, one flat array). --all and --top together are a usage error.
Every other list — yt view --comments, yt art ls, yt board ls,
yt project team — is exhaustive without asking, and so is every name lookup
behind the scenes. A truncated list a decision is made from is not a short
answer, it is a wrong one.
Every other mutation
Mutations go through YouTrack's own command language, so custom fields, tags, links, priorities and milestones work without this tool knowing they exist:
yt cmd <id...> "<command>" [--as user] [--dry-run]yt cmd DEMO-42 "state In Progress assignee me Fix versions 2026.1"
yt cmd DEMO-1 DEMO-2 "tag urgent" several issues at once
yt cmd DEMO-42 "subtask of DEMO-7" hierarchy, links, anything the field accepts
yt cmd DEMO-42 "state Fixed" --as agent-bot attribute the work to another user
yt cmd DEMO-42 "state Frozen" --dry-run validate syntax *and* values, apply nothingyt cmd --help carries the worked examples.
Knowledge base
yt art ls [--project P] [--grep text] an indented tree, ordered as YouTrack orders it
yt art view <id>
yt art new <project> <title> [-f file | -c text] [--parent <id>]
yt art edit <id> [-s title] [-f file | -c text]--project is served by the per-project endpoint, so the result is complete
rather than whatever fits in one page of a global list. --grep matches article
titles case-insensitively and filters client-side — the REST API exposes no
server-side article search.
Project configuration
yt state ls <project> [--all] the State value set; --all shows archived too
yt state add <project> <name> [--resolved] [--archived] [--after "Other"]
yt state edit <project> <name> [--rename X] [--resolved|--no-resolved]
[--archived|--no-archived]
yt state order <project> "Open,In Progress,Fixed"
yt type ls|add|edit|order <project> ... the same, for issue types
yt field ls <project> <field> [--all] the value set of any field, named as an argument
yt field add <project> <field> <name> [--description X] [--after "Other"]
yt field edit <project> <field> <name> [--rename X] [--description X]
yt field order <project> <field> "First,Second,Third"
a version field also takes --release-date
2026-09-01 and --released|--no-released
yt board ls
yt board new <project> <name> [--columns "A,B,C"]
yt board add <project...> <board> put projects on a board that already exists
yt board rm <project...> <board> take them off again
yt sprint ls <board> [--all] the board's sprints; --all lists the closed ones too
yt sprint new <board> <name> open a sprint
yt sprint close <board> archive the current one
yt sprint current <board> the name of the current sprint, and nothing elseThe current sprint is the last open one, not the one today's date falls into:
a sprint is closed by hand whenever the work is done, so its dates are decoration
and a schedule-based answer would be a guess. yt sprint current prints the name
alone, so it feeds straight back in — yt cmd DEMO-42 "add Board Delivery
$(yt sprint current Delivery)" — and exits 2 when the last sprint has been
closed and no new one opened.
A sprint belongs to its board, not to a project. A board covering several
projects has one sprint across all of them; two boards over the same project have
two unrelated sprint lists, even where the names match. The one exception is a
board configured to take its sprints from a sync field — there the sprints
mirror that field's values and every board on the same field shows them, so
yt sprint new names those boards instead of leaving the reach unsaid. Stock
boards have no sync field and it says nothing. A name already on the board is
refused rather than opened twice, and a board with sprints switched off answers
the API with one permanent active sprint — yt refuses it with exit 3 instead
of passing that fiction on.
yt board add|rm names the board, never an id — yt board ls prints no id to
copy. Board names are not unique in YouTrack, so a name matching several boards
lists them and changes nothing. Both are idempotent, and rm refuses to leave a
board with no projects at all. Board columns reference state values by name, so
adding a project whose states do not cover every column still succeeds, and the
columns that stay empty for it are named on stderr.
state, type and field are one command under three names: the first two fix
the field they work on, field takes it as an argument and so reaches any field
backed by a value set — Subsystem, Fix versions, Fixed in build, or a
State this instance calls something else. Values can be added, renamed,
reordered, archived, described, and — for states — marked as resolving the issue.
yt field add DEMO Subsystem "CLI" --description "the command line client"
yt field order DEMO Subsystem "CLI,Docs"
yt field add DEMO "Fix versions" 1.0.0 --release-date 2026-09-01
yt field edit DEMO "Fix versions" 1.0.0 --released--description is taken by every field, because it lives on the value type they
all share. --released and --release-date belong to version values alone and
are refused elsewhere, the way --resolved is refused outside State. There is
no --field flag: yt field ls DEMO Status is how an instance that renamed its
State field is reached, and passing the old flag says so.
Deleting is deliberately absent: archiving hides a value without touching the issues that already carry it.
A value set can belong to several projects at once — the stock Type field
usually does — so yt type add names the other projects a new value just
appeared in.
Setting an instance up
yt project new <shortName> <name> [-d description] [--leader login] [--org name]
yt project team <shortName> the project team, login and full name
yt project assign <shortName> <login...> add users to the project team
yt org new <name> [-d description] an organization to hold projects
yt user new <login> [--name "Full Name"] [--email [email protected]]--leader resolves a login to a database id; without it the leader is the
authenticated user. --org is optional — a project without an organization is
complete and usable — and a project created this way comes up with the full
default field set, so yt state ls and yt board new work on it immediately.
yt project team lists everyone who reaches the project, whether directly or
through a group; yt project assign adds direct members. YouTrack replaces the
member list wholesale on write, so the current members are read and posted back
with the new ones — assigning someone twice changes nothing.
yt user new generates the password and writes it to stderr, once. It is
never accepted as a flag: argv is visible in ps and lands in shell history.
Design
CONTEXT.md— domain glossary- ADR-0001 — why mutations use the command language instead of typed subcommands
- ADR-0002 — why there is no fallback file for the token
- ADR-0003 — why the CLI used to register its own OAuth client (superseded)
- ADR-0004 — why the client id is a URL we host, and how that removed the token bootstrap
- ADR-0005 — why
yt statemanages a value set and not the transitions between them - ADR-0006 — why
yt user newgenerates the password and prints it on stderr
License
MIT
