vikunja-taskman
A desktop task manager that puts Vikunja and Jira tasks in one native window, plus
taskman, a CLI over the same Vikunja instance.
Two binaries are built from this repo:
| what | built with | |
|---|---|---|
| app (repo root) | Wails v2 desktop app — Go backend, React frontend in WebView2 | wails build |
taskman (cmd/taskman) |
cobra CLI for Vikunja | go install ./cmd/taskman |
They share internal/vikunja, a Go client for the Vikunja REST API. The app currently still
talks to Vikunja through its own TypeScript driver (frontend/src/lib/drivers/vikunja.ts) rather
than that client; unifying them is not done.
Attachments are shared for real, though: both sides keep a task's files in the same S3 bucket under a prefix named after the task id, so a file attached from the CLI shows up in the app.
The desktop app
Hierarchical task tree, task detail pane, Vikunja stage labels, Jira workflow transitions and worklogs, S3 per-task attachments, Typesense full-text search, agenda panel.
wails dev # live development
wails build # or task
It is a desktop app and does not run in a browser — all frontend/backend traffic goes over the
Wails Go bridge, which is also how it avoids CORS. See AGENTS.md for the details.
The CLI
go install ./cmd/taskman
Configuration comes from the environment (flags override it):
export VIKUNJA_TASKMAN_URL=https://vikunja.example.com/api/v1 # must include /api/v1
export VIKUNJA_TASKMAN_TOKEN=tk_...
export VIKUNJA_TASKMAN_PROJECT=1 # default project
export VIKUNJA_TASKMAN_BUCKET=vikunja-docs # attachment bucket
Attachments additionally need RUSTFS_ENDPOINT, RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY; the
S3 layer is rustfs-cli's, which reads those itself.
The token is read from the environment rather than committed, because this repo is public. A
.env in the working directory or its parent is also read.
taskman projects # list projects and their ids
taskman open # open tasks, newest first, minus the hidden stages
taskman open -s Review # only tasks with that label
taskman open -p 2 --all # another project, including done
taskman open --under 259 # one task and everything beneath it
taskman find actioner # search titles and descriptions
taskman show 419 # one task in full, with comments
taskman new "title" -d @notes.md # body from a file, or "-" for stdin
taskman new "title" --parent 259 # hang it off an epic
taskman new "epic" --subtasks @kids # and its children, one title per line
taskman unparent 513 # detach a task from whatever it hangs under
taskman move 513 2 # put it in another project, subtree and all
taskman edit 419 --title "better title" -s Waiting
taskman note 419 - # comment from stdin
taskman label 419 Waiting
taskman done 419
taskman attachments 419 # list attached files
taskman attach 419 diagnostic.zip # upload
taskman fetch 419 -C ./downloads # download all of them
Subtasks render under their parent, indented, at whatever depth the relations go — open and
find group the listing rather than printing it flat. A task whose parent is not in the same
listing, because the parent is done or carries a hidden stage, shows at top level with
(under #259) in front of its title. show lists a task's children with their titles, and done
refuses to close a task that still has open children and names them; --force closes it anyway.
open --under <task> scopes a listing to one subtree, and new --subtasks files a parent and its
children in one invocation instead of five with ids copied between them.
parent <task> <parent> moves a task: a task is in one place in the tree, so whatever it hung
under is detached first and printed. The relation endpoint itself appends rather than replaces,
which is why the move is explicit here — a task left under two parents renders one hierarchy in the
listing and a different one in the app, because both place it under a single parent.
unparent <task> [<parent>] is the inverse. Naming a parent detaches that one; naming none
detaches the task from every parent it has.
move <task> <project> puts a task on another board, naming the project either by the number
projects prints or by enough of its title to pick out one. Everything under the task moves with
it: a listing and the app both scope to a single project, so a parent left behind would read as
childless there while its children read as roots in the project they landed in.
-o json works on every command. A task has one number: the id its URL ends with, which is
what the API takes and what the desktop app renders and copies as #419. 419 and #419 are the
same reference, and listings print it the way the app does.
Vikunja also keeps a per-project index and shows it in its own web UI. taskman does not speak it:
it is a second small integer from the same range, and accepting both meant a number copied from one
place acted on a different task in the other. It is in -o json as identifier for
cross-referencing the web UI.
The stage labels are TODO (outstanding), Waiting (blocked on someone else), Review (done and
only needing confirmation), Inconclusive (reported, investigated, not reproduced), Deferred
(someday) and INFO (never was work — a note, a decision and why, a finding needing nothing done).
taskman open hides Review, Inconclusive and INFO, since none of them is work anyone can
pick up; -s <stage> and --all show them.
Bodies are light markup, converted to the HTML Vikunja stores - blank lines are paragraphs, fenced
blocks are code, - is a list, and **bold**, backtick code and bare URLs are picked up.
--html passes text through as-is. Reading from a file or stdin means never quoting a description
through the shell.
Two deliberate omissions: there is no command that rewrites a description, and no delete.
Descriptions are treated as append-only — additions go in a comment — and deletes in Vikunja are
unrecoverable.
Notes worth knowing
- Updating a task sends every column.
POST /tasks/{id}is a replace, so a partial body zeroes whatever it omits — a bare{"done": true}blanks the description.Client.UpdateTaskreads the task, merges, and writes it back whole; use it rather than posting fields directly. per_pageis capped at 50 by the server whatever you ask for.AllTaskspages for you.- Filters use Vikunja's syntax and run server-side before pagination, e.g.
done = false,done = false && labels in 1,created > '2026-09-01'.
Both halves have a vet.sh: the one at the root covers the Go app and the CLI (gofmt, build, vet,
tests, nag), and frontend/vet.sh covers the React app (nag, tsc). Each runs on its own; neither
calls the other.
AGENTS.md is the long-form reference for both halves.