@danypops/tickets
v0.16.5
Published
Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.
Maintainers
Readme
tickets
A unified CLI, daemon, and TypeScript library for issue tracking across
GitHub, GitLab, and Jira — plus a pi-tickets extension so a
coding agent can query and mutate issues the same way the CLI does.
Why a daemon
Every backend adapter pools issues into a local SQLite ledger on its own
schedule, independent of whether anything is currently asking for data —
tickets ledger search/ledger stats and the ledger ops still answer from
the last successful sync even if a backend is slow, rate-limited, or
temporarily unreachable. The CLI and the pi-tickets extension are both thin,
interchangeable clients of one authenticated RPC daemon; neither talks to
GitHub/GitLab/Jira or opens the SQLite ledger directly. See
RESEARCH.md for the sources this was built against.
Requirements
- Bun 1.1+. The daemon uses
bun:sqliteandBun.servethrough@danypops/vehicle-server; the CLI, library, and pi-tickets extension are plain TypeScript but currently ship as source and run through Bun rather than a compiled Node build. - The published
@danypops/vehicle-*packages provide the daemon, authenticated RPC/Vehicle contracts, resilient client, Pi projection, and Armada service integration. No local Vehicle checkout orfile:dependency is required.
Install
bun installRun
# Start the daemon (binds 127.0.0.1 on an ephemeral port; writes a handle +
# auth token under $XDG_RUNTIME_DIR/tickets and $XDG_STATE_HOME/tickets).
bun run daemon
# Or let the CLI manage it — every issue/ledger command below auto-starts the
# daemon on first use if it isn't already running.
bun run src/cli/index.ts daemon status # never auto-starts; just checks
bun run src/cli/index.ts daemon start
bun run src/cli/index.ts daemon stop # asks it to shut down gracefully
bun run src/cli/index.ts daemon restart
# Reports capabilities plus local read/write readiness and missing setting names.
# It never probes provider connectivity and never returns credential values.
bun run src/cli/index.ts backends
bun run src/cli/index.ts list -b github --status todo
# get includes fixVersions, issueLinks, externalLinks (Jira "Web Links", e.g.
# the PR that fixed a bug), and customFields (by display name -- run
# `discover fields` first, see below) whenever the backend has them.
bun run src/cli/index.ts get jira:PROJ-42
bun run src/cli/index.ts create -b github "Fix the thing" --label bug
bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
bun run src/cli/index.ts ledger search "login bug"
bun run src/cli/index.ts ledger stats
# Track the single ticket you're currently working on, with its full URL —
# survives daemon restarts, resolves the ref via the ledger first (no live
# call if it's already cached) and falls back to the backend otherwise.
bun run src/cli/index.ts focus set jira:PROJ-42
bun run src/cli/index.ts focus get
bun run src/cli/index.ts focus pause "waiting on review"
bun run src/cli/index.ts focus unpause
bun run src/cli/index.ts focus clear
# Discover and persist backend-specific mappings (Jira only today): custom
# field display names -> IDs, and status names -> domain status. Read once
# from the backend, cached to ~/.config/tickets/{fields,statuses}/<backend>.yaml
# so a later lookup needs no network call and survives a daemon restart.
bun run src/cli/index.ts discover fields -b jira
bun run src/cli/index.ts discover statuses -b jira
# Sample recent issues for a project/issue-type and extract a reusable
# description template from the section headers common to all of them.
bun run src/cli/index.ts discover template -b jira --project PROJ --issue-type BugRunning the daemon persistently (Armada)
daemon start is the on-demand path. For a daemon owned by the native service
manager and reconciled from desired state, register it with Armada:
bun run src/cli/index.ts service install # Armada upsert + reconcile
bun run src/cli/index.ts service uninstall # Armada remove
# Direct lifecycle actions currently target systemd --user (Linux):
bun run src/cli/index.ts service status
bun run src/cli/index.ts service stop
bun run src/cli/index.ts service restartservice install records the exact Bun binary, CLI entry path, version, handle
path, restart policy, and readiness probe in Armada's fleet manifest. Armada
then projects that declaration through systemd, launchd, or Windows Task
Scheduler. Re-run service install after upgrading or moving the package.
Once installed as a package, the same commands are available as tickets
and tickets-daemon (see bin in package.json).
Configuration
Environment variables
# GitHub (token optional for public-repo reads)
export GITHUB_TOKEN=ghp_xxx
export GITHUB_OWNER=your-org
export GITHUB_REPO=your-repo
# GitLab (token optional for public-project reads)
export GITLAB_TOKEN=glpat-xxx
export GITLAB_PROJECT=namespace/project
export GITLAB_URL=https://gitlab.example.com # optional, defaults to gitlab.com
# Jira
export JIRA_API_TOKEN=xxx
export JIRA_URL=https://yourcompany.atlassian.net
export [email protected]
export JIRA_PROJECT=PROJConfig file (multi-instance)
$XDG_CONFIG_HOME/tickets/config.yaml (default ~/.config/tickets/config.yaml):
backends:
github:
owner: your-org
repo: your-repo
token_env: GITHUB_TOKEN
gitlab:
project: namespace/project
token_env: GITLAB_TOKEN
jira:
url: https://yourcompany.atlassian.net
email: [email protected]
token_env: JIRA_API_TOKEN
project: PROJ
jira-staging: # multi-instance: same type, different name
type: jira
url: https://staging.atlassian.net
email: [email protected]
token_env: JIRA_STAGING_TOKENDelegated OAuth login (instead of a static token)
Each backend supports a different real delegated-auth flow — see RESEARCH.md for exactly which, and why Jira's is shaped differently from GitHub/GitLab's:
# GitHub / GitLab: device flow — opens a browser, prints a short code.
tickets auth login --backend github --client-id <your-github-oauth-app-client-id>
tickets auth login --backend gitlab --client-id <your-gitlab-application-id>
# Jira: authorization code grant — opens a browser, receives the callback
# on a local loopback server. Atlassian's 3LO apps are confidential clients
# (no PKCE, no device flow), so a client secret is required here.
tickets auth login --backend jira \
--client-id <your-atlassian-oauth-client-id> \
--client-secret <your-atlassian-oauth-client-secret>
tickets auth status
tickets auth logout githubStatic token instead (API key/PAT, no OAuth)
For a backend with no OAuth app to register against (e.g. a plain Atlassian
API token from id.atlassian.com/manage-profile/security/api-tokens),
store it directly -- same 0600 local file auth login writes to, checked
ahead of any token/token_env config-file or plain env-var fallback:
tickets auth set-token jira
# Paste the "jira" token (input hidden): ****
# or non-interactively, e.g. from a password manager (piped stdin works too,
# since a non-TTY stdin is read as-is with nothing to mask):
pass show jira-api-token | tickets auth set-token jiraGitHub: reuse an already-authenticated gh CLI session
tickets auth login --backend github --gh-cli [account] skips the device
flow (and the GITHUB_OAUTH_CLIENT_ID App registration it needs) entirely
by reading gh auth token instead — never re-implement a vendor CLI's own
auth, just consume its result via its own documented, stable interface.
Works whether gh stores its token in the OS keyring or a legacy
plaintext file. Omit account for gh's current active account, or name
one of gh's own multiple authenticated accounts (gh auth status lists
them) — pair with a distinct --backend name to register each as its own
tickets backend:
tickets auth login --backend github-personal --gh-cli DanyPops
tickets auth login --backend github-work --gh-cli work-accountA stored, still-fresh delegated token always takes precedence over a static
config/env token for that backend. Tokens are written to
$XDG_STATE_HOME/tickets/oauth/<backend>.json, mode 0600, and are never
printed by any command. Restart the daemon after logging in so it picks
up the new credential — buildRepositories() runs once at daemon startup.
Optional: credentials via Enigma
If an Enigma vault is running, tickets checks it first on every request, ahead of a stored delegated token and any static config/env token — a credential Enigma rotates is picked up on the very next call, no daemon restart needed. Purely additive: tickets works identically with no Enigma running at all.
Register tickets as a scoped Enigma client (once), then pass the printed
token to the daemon via ENIGMA_CLIENT_TOKEN:
enigma client add tickets --backends github,gitlab,jira
# -> prints a token once; export it wherever the tickets daemon is started
export ENIGMA_CLIENT_TOKEN=<printed token>Without ENIGMA_CLIENT_TOKEN, tickets falls back to Enigma's shared
admin-token file if one exists at $XDG_STATE_HOME/enigma/token — fine for
a single-user machine where every local daemon is equally trusted, but a
scoped client token is the least-privilege default.
The pi-tickets extension
Published as @danypops/pi-tickets. ../pi-tickets/ (this repo's workspace member) registers one real Pi tool per operation—issue_list,
issue_get, issue_create, issue_update, issue_search,
issue_children, issue_comments, issue_comment_add, backends_list,
ledger_search, ledger_stats, focus_set, focus_get, focus_pause,
focus_unpause, focus_clear, discover_fields, discover_statuses,
discover_template—not a single action-dispatch mega-tool. Each talks to
the same daemon through the same authenticated RPC client the CLI uses —
never a direct backend call or a direct SQLite open. OAuth login and daemon lifecycle
control are deliberately not exposed here (neither as a tool action nor
as the /tickets command below): approving OAuth access requires a human in
a browser, and stopping a shared daemon is an operational decision, not
something an LLM tool call or a casual keypress should trigger. Use
tickets auth login/tickets daemon stop from a terminal for those.
It also registers a /tickets [query] interactive TUI command (for the
human, not the LLM): a browsable list of every issue the daemon's ledger has
pooled across every configured backend in one flat list (no backend picker
needed). ↑↓ navigate, enter sets focus on the highlighted issue, o
opens its real web URL in a browser without closing the dialog, and esc
cancels. When a focus is already set, a "Clear current focus" row appears
first. A persistent footer status (🎯 backend:key, or ⏸ when paused)
shows the current focus at all times, refreshed on session start and after
every tickets tool call — so a focus the LLM sets via focus_set mid-
conversation shows up in the footer too, and vice versa.
To use it, add it to pi's settings.json:
{ "packages": ["npm:@danypops/pi-tickets"] }Or, for local development against this monorepo, point at the workspace
member directory instead: { "packages": ["/path/to/tickets/packages/pi-tickets"] }.
Development
bun install # from the repo root -- links both workspace members
bun run typecheck # both packages
bun run test # both packages, sequential isolated test processesTests never hit real GitHub/GitLab/Jira/Atlassian: adapters take injectable
transport implementations, and the daemon tests (test/rpc/, test/sqlite/,
test/process/) run the real @danypops/vehicle-server daemon/SQLite/HTTP
stack against a scratch XDG root with a fake IssueRepository.
Architecture
Driver (inbound) Application Driven (outbound)
┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ CLI (commander)│──RPC──▶│ │ │ GitHub adapter │
│ pi-tickets │──RPC──▶│ tickets-daemon │───────▶│ GitLab adapter │
│ (Pi tool) │ │ (TicketService │───────▶│ Jira adapter │
└───────────────┘ │ + Ledger │───────▶│ SQLite (Ledger) │
│ + Poller) │ └──────────────────┘
└─────────────────┘
built on @danypops/vehicle-server
(Vehicle registry, paths, storage, HTTP, logging,
daemon lifecycle, Armada service integration)Hexagonal architecture: src/domain has zero I/O, src/ports defines the
outbound contract, src/adapters implement it per backend, src/application
orchestrates by parsing backend:key refs and routing to the named
repository, and src/daemon is the only place that owns the SQLite ledger,
wraps it in a Bearer-authenticated HTTP/Vehicle surface, and runs the pooling
poller as a Vehicle maintenance task.
