Compare commits
4
Commits
69c6747a31
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846bb31dc9 | ||
|
|
2dbf0918de | ||
|
|
98fcb57298 | ||
|
|
229f7a2fe1 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tm": {
|
||||
"command": "${workspaceFolder}/bin/tm",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/bin/
|
||||
.dev-home/
|
||||
.tm_token
|
||||
**/.tm_token
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent notes — task-master
|
||||
|
||||
## Tickets über MCP
|
||||
|
||||
Tickets lesen und bearbeiten nur über den lokalen MCP-Server von task-master — nicht über JSON-Dateien oder die TUI.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Binary: `./bin/tm` (oder `tm` im PATH)
|
||||
2. Projekt-MCP: [`.cursor/mcp.json`](.cursor/mcp.json) (`tm` → `args: ["mcp"]`)
|
||||
3. Im Repo (oder darüber) `.tm_token` mit dem Projekt, für das die TUI ein Token erzeugt hat (`p` → Projekt wählen → `g`):
|
||||
|
||||
```
|
||||
project=<projekt-id>
|
||||
token=tm_mcp_…
|
||||
```
|
||||
|
||||
`.tm_token` nicht committen.
|
||||
|
||||
### Verhalten
|
||||
|
||||
- Ohne gültiges `.tm_token` darf der MCP-Server den Daemon nicht nutzen.
|
||||
- Mit Token sind alle Tools auf genau dieses eine TM-Projekt beschränkt.
|
||||
- TUI/`tm sync` brauchen kein Token; die KI schon.
|
||||
|
||||
### Typische Tools
|
||||
|
||||
`list_projects`, `list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, `move_ticket`, `delete_ticket`, `list_time`, `add_time`, `undo`, `redo`, `sync_pull`, `sync_push`
|
||||
|
||||
Mehr Details: [README.md](README.md) (Abschnitt MCP).
|
||||
@@ -1,6 +1,6 @@
|
||||
# task-master
|
||||
|
||||
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea).
|
||||
Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Checklisten, Zeiterfassung und Reports. Gebaut mit [Bubble Tea](https://github.com/charmbracelet/bubbletea). Dazu der optionale Web-Server `tm_server` mit Svelte-UI und SQLite.
|
||||
|
||||

|
||||
|
||||
@@ -8,16 +8,31 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
|
||||
|
||||
## Features
|
||||
|
||||
- Projekte & Tickets lokal als JSON (kein Server) — Binary `tm`
|
||||
- Web-Server `tm_server` mit eingebettetem Svelte-UI, SQLite, Auth und Rechte pro Projekt
|
||||
**TUI (`tm`)**
|
||||
|
||||
- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket)
|
||||
- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon
|
||||
- Board-Filter: All / Backlog / In Progress / Done
|
||||
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
||||
- Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten
|
||||
- Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren
|
||||
- Report mit Group-by (Ticket / Tag)
|
||||
- Undo / Redo über eine Command-History (TUI)
|
||||
- Vim-Style Keybindings (TUI)
|
||||
- Undo / Redo über eine Command-History (im Daemon)
|
||||
- Vim-Style Keybindings
|
||||
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
||||
- Server: Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
|
||||
- Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens)
|
||||
- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff
|
||||
|
||||
**Web (`tm_server`)**
|
||||
|
||||
- Eingebettetes Svelte-UI, SQLite, Auth und Rechte pro Projekt
|
||||
- Projektgruppen, Sidebar-Navigation, Hash-Routes
|
||||
- Drag & Drop auf dem Board (OPEN / WIP / DONE)
|
||||
- Lesbare Ticket-Keys aus Projektcode + Sequenz (z. B. `WEB-12`)
|
||||
- Visual- und Markdown-Editor für Ticket-Beschreibungen
|
||||
- Datei-Anhänge; Tabellen (XLSX/ODS/…) im Browser editierbar
|
||||
- Report mit Datums- und Ticket-Filtern sowie Eintragsliste
|
||||
- Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
|
||||
- Members, Settings (Tokens), Users (Admin)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -30,19 +45,45 @@ make install # → $(go env GOPATH)/bin/tm
|
||||
# oder nur bauen:
|
||||
make build # → ./bin/tm
|
||||
make build-server # Frontend + → ./bin/tm_server
|
||||
make build-all # beides
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
|
||||
```bash
|
||||
tm # TUI starten
|
||||
tm sync pull # Issues von YouTrack holen
|
||||
tm sync push # lokale Änderungen nach YouTrack
|
||||
tm # TUI starten (startet Daemon bei Bedarf)
|
||||
tm sync pull # Issues von YouTrack / tm_server holen
|
||||
tm sync push # lokale Änderungen pushen
|
||||
tm sync pull -p inbox # anderes Projekt
|
||||
tm status # Daemon-Status + Socket-Pfad
|
||||
tm stop # Hintergrund-Daemon beenden
|
||||
tm daemon # Daemon im Vordergrund (Debug)
|
||||
tm version # Build-Version (Git-Hash)
|
||||
tm help
|
||||
```
|
||||
|
||||
### Lokaler JSON-Daemon
|
||||
|
||||
`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-<hash>/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`).
|
||||
|
||||
### MCP (`tm mcp`)
|
||||
|
||||
1. In der TUI-Projektliste `g` → Token erzeugen (Klartext einmal in der Statuszeile); `G` widerruft.
|
||||
2. Im Repo-Root (oder darüber) `.tm_token` anlegen — **nicht committen**:
|
||||
|
||||
```
|
||||
project=inbox
|
||||
token=tm_mcp_…
|
||||
```
|
||||
|
||||
3. Cursor / MCP-Client:
|
||||
|
||||
```json
|
||||
{ "command": "tm", "args": ["mcp"] }
|
||||
```
|
||||
|
||||
Ohne gültiges `.tm_token` macht der MCP-Server keine Daemon-Aufrufe. Mit Token sind Lesen und Schreiben nur für dieses eine TM-Projekt erlaubt.
|
||||
|
||||
### Web-Server (`tm_server`)
|
||||
|
||||
```bash
|
||||
@@ -60,11 +101,12 @@ export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
|
||||
./bin/tm_server import-json -owner admin
|
||||
```
|
||||
|
||||
UI: http://localhost:8080 — Dashboard, Board, Report, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||
Beim Seed werden für **Test** und **Website Relaunch** Write-Tokens erzeugt und unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (Y → Pull).
|
||||
UI: http://localhost:8080 — Dashboard, Board, Report, Attachments, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||
|
||||
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
|
||||
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||
Beim Seed (`TM_SEED_DEMO=1`) werden Demo-Projekte angelegt und für **Test** sowie **Website Relaunch** Write-Tokens unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (`Y` → Pull). Login: `admin`/`admin` (Member: `alice`/`alice`).
|
||||
|
||||
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`. Frontend neu bauen ohne Go-Rebuild: `make web-build`.
|
||||
|
||||
Sandbox ohne echte Config/Daten:
|
||||
|
||||
@@ -76,10 +118,16 @@ TASK_MASTER_HOME=/tmp/tm-sandbox tm
|
||||
|
||||
Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~/.config/task-master`.
|
||||
|
||||
| Variable | Wirkung |
|
||||
|----------------------|----------------------------------------------|
|
||||
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
||||
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
|
||||
| Variable | Wirkung |
|
||||
|------------------------|---------------------------------------------------|
|
||||
| `TASK_MASTER_HOME` | Basis für Config + Daten (Dev/Demo) |
|
||||
| `TASK_MASTER_DATA_DIR` | Nur Datenverzeichnis überschreiben |
|
||||
| `TASK_MASTER_SECRET` | Verschlüsselt Remote-Tokens in SQLite |
|
||||
| `TM_ADMIN_USER` / `TM_ADMIN_PASSWORD` | Bootstrap des ersten Admins |
|
||||
| `TM_SEED_DEMO=1` | Demo-Projekte + TUI-Verdrahtung (Test / Website Relaunch) |
|
||||
| `TM_SEED_BASE_URL` | Basis-URL in den geseedeten TUI-`meta.json` |
|
||||
|
||||
Server-Flags: `-listen`, `-db`, `-secure` (siehe `tm_server help`).
|
||||
|
||||
## Keybindings (Auszug)
|
||||
|
||||
@@ -87,13 +135,12 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
||||
|---------|------------------------|
|
||||
| `j`/`k` | Navigieren |
|
||||
| `n` | Neues Ticket/Projekt |
|
||||
| `e` | Ticket im Editor |
|
||||
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack / Sync-Backend) |
|
||||
| `s` | Status ändern |
|
||||
| `l` | Board-Filter |
|
||||
| `/` | Suche |
|
||||
| `p`/`P` | Projekt / Ticket verschieben |
|
||||
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) |
|
||||
| `Y` | YouTrack-Menü: Pull / Push |
|
||||
| `Y` | Sync-Menü: Pull / Push |
|
||||
| `t`/`T` | Timer / Zeit buchen |
|
||||
| `m` | Zeiteinträge |
|
||||
| `r` | Report |
|
||||
@@ -103,15 +150,15 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
||||
|
||||
Checklisten im Preview: `J`/`K` + `Space`.
|
||||
|
||||
### YouTrack
|
||||
### YouTrack / Sync
|
||||
|
||||
1. `p` → Projekt wählen → `e`
|
||||
2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects/<id>/meta.json`, Mode `0600`)
|
||||
2. Backend wählen: YouTrack (Remote-URL, Permanent Token, Project-Key) oder task-master (Server-URL + Projekt-Token)
|
||||
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
||||
4. `Y` → **Pull** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge)
|
||||
4. `Y` → **Pull** oder **Push**
|
||||
CLI: `tm sync pull` / `tm sync push`
|
||||
|
||||
Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||
Zeit: lokale Einträge ohne `externalId` werden remote als Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||
|
||||
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
||||
|
||||
@@ -120,9 +167,12 @@ Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fi
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
make build-server # braucht Node (npm ci + Vite-Build)
|
||||
make version
|
||||
```
|
||||
|
||||
CI (Gitea): Tests, `tm` + `tm_server`-Build, Demo-GIF als Artifact.
|
||||
|
||||
## Demo aufnehmen
|
||||
|
||||
Die Walkthrough-Aufnahme steckt in [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)):
|
||||
|
||||
+160
-63
@@ -8,14 +8,13 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/config"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/daemon"
|
||||
"task/internal/daemon/api"
|
||||
"task/internal/daemon/client"
|
||||
mcpserver "task/internal/mcp"
|
||||
"task/internal/ui"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -33,6 +32,30 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "daemon":
|
||||
if err := runDaemon(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "stop":
|
||||
if err := runStop(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "status":
|
||||
if err := runStatus(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "mcp":
|
||||
if err := runMCP(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printHelp()
|
||||
@@ -46,35 +69,104 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
func loadConfig() (*config.Config, error) {
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
abs, err := daemon.AbsoluteDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.DataDir = abs
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
store := ticket.NewJSONStore(cfg.DataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
||||
projects := project.NewDirStore(cfg.DataDir)
|
||||
if err := projects.Ensure(cfg.ActiveProject); err != nil {
|
||||
func ensureClient(kind string) (*config.Config, *client.Client, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
paths, err := daemon.Ensure(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("daemon: %w", err)
|
||||
}
|
||||
cli := client.Dial(client.Options{Socket: paths.Socket, Kind: kind})
|
||||
return cfg, cli, nil
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, cli, err := ensureClient(api.ClientTUI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cli.ProjectStore().Ensure(cfg.ActiveProject); err != nil {
|
||||
return fmt.Errorf("ensure project: %w", err)
|
||||
}
|
||||
|
||||
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: %w", err)
|
||||
}
|
||||
model := ui.New(cfg, store, projects, timeStore, history)
|
||||
model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli)
|
||||
prog := tea.NewProgram(model, tea.WithAltScreen())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
_ = cli.SubscribeEvents(ctx, func(ev api.Event) {
|
||||
prog.Send(ui.DaemonChangedMsg{ProjectID: ev.ProjectID})
|
||||
})
|
||||
}()
|
||||
|
||||
if _, err := prog.Run(); err != nil {
|
||||
return fmt.Errorf("tui: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
cfg, err := config.LoadOrCreate()
|
||||
func runDaemon() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
return err
|
||||
}
|
||||
return daemon.Run(cfg)
|
||||
}
|
||||
|
||||
func runStop() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := daemon.Stop(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("daemon stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStatus() error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := daemon.StatusOf(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := "stopped"
|
||||
if st.Running {
|
||||
state = "running"
|
||||
}
|
||||
fmt.Printf("status: %s\n", state)
|
||||
fmt.Printf("socket: %s\n", st.Socket)
|
||||
fmt.Printf("dataDir: %s\n", st.DataDir)
|
||||
if st.PID > 0 {
|
||||
fmt.Printf("pid: %d\n", st.PID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
cfg, cli, err := ensureClient(api.ClientCLI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID := cfg.ActiveProject
|
||||
action := ""
|
||||
@@ -105,56 +197,61 @@ func runSync(args []string) error {
|
||||
return fmt.Errorf("project id required")
|
||||
}
|
||||
|
||||
store := ticket.NewJSONStore(cfg.DataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
||||
projects := project.NewDirStore(cfg.DataDir)
|
||||
if err := projects.Ensure(projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := projects.LoadMeta(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res ytsync.Result
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
|
||||
return fmt.Errorf("taskmaster not configured for %s", projectID)
|
||||
}
|
||||
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
|
||||
if action == "push" {
|
||||
res, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
|
||||
} else {
|
||||
res, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
if action == "push" {
|
||||
res, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
} else {
|
||||
res, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == "push" {
|
||||
out, err := cli.SyncPush(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||
projectID, res.Created, res.Updated, res.TimePushed)
|
||||
} else {
|
||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
|
||||
projectID, out.Created, out.Updated, out.TimePushed)
|
||||
return nil
|
||||
}
|
||||
out, err := cli.SyncPull(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||
projectID, out.Created, out.Updated, out.TimePulled, out.Total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMCP() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tf, err := mcpserver.FindTokenFile(cwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths, err := daemon.Ensure(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: %w", err)
|
||||
}
|
||||
cli := client.Dial(client.Options{
|
||||
Socket: paths.Socket,
|
||||
Kind: api.ClientMCP,
|
||||
Token: tf.Token,
|
||||
Project: tf.Project,
|
||||
})
|
||||
return mcpserver.ServeStdio(cli, tf)
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Printf(`%s — offline-first terminal task manager
|
||||
|
||||
Usage:
|
||||
%s Start the TUI
|
||||
%s sync pull [-p ID] Pull from configured backend (YouTrack or task-master)
|
||||
%s sync push [-p ID] Push to configured backend
|
||||
%s Start the TUI (starts local daemon if needed)
|
||||
%s sync pull [-p ID] Pull via daemon from configured backend
|
||||
%s sync push [-p ID] Push via daemon to configured backend
|
||||
%s daemon Run the JSON daemon in the foreground
|
||||
%s stop Stop the background daemon
|
||||
%s status Show daemon status and socket path
|
||||
%s mcp MCP stdio server (requires .tm_token)
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
|
||||
@@ -162,9 +259,9 @@ Env:
|
||||
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||
TASK_MASTER_DATA_DIR Override data directory only
|
||||
|
||||
Sync backends:
|
||||
In the TUI: p → project → e, choose youtrack or taskmaster, set URL/token/id.
|
||||
Then Y → Pull or Push (manual only; edits stay local until you push).
|
||||
The TUI talks to a per-DataDir background daemon over a Unix socket so multiple
|
||||
instances stay consistent. Create an MCP token from the project list (key g);
|
||||
place project= and token= in a repo .tm_token file, then run: tm mcp
|
||||
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mark3labs/mcp-go v0.32.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.61.0
|
||||
@@ -42,7 +43,9 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
|
||||
@@ -38,12 +38,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -54,10 +60,16 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
|
||||
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
@@ -77,16 +89,26 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
@@ -110,6 +132,8 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/daemon/api"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) (*api.Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
timeStore := ticket.NewJSONTimeStore(dir)
|
||||
projects := project.NewDirStore(dir)
|
||||
_ = projects.Ensure("inbox")
|
||||
hist, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, timeStore)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return api.New(store, timeStore, projects, hist), dir
|
||||
}
|
||||
|
||||
func TestHealthAndCRUD(t *testing.T) {
|
||||
srv, _ := setup(t)
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("health: %d", rec.Code)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(ticket.Ticket{Title: "Hi", ProjectID: "inbox", Status: ticket.StatusTodo})
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/tickets", bytes.NewReader(body))
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created ticket.Ticket
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.ID == "" {
|
||||
t.Fatal("missing id")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("list: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAuthRequired(t *testing.T) {
|
||||
srv, _ := setup(t)
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected auth failure, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Create token via TUI client
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/projects/inbox/mcp-token", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("token create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tok struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
|
||||
if tok.Token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("Authorization", "Bearer "+tok.Token)
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("mcp list: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 200 {
|
||||
t.Fatal("wrong token should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxClientKind ctxKey = iota
|
||||
ctxMCPProject
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderClient = "X-TM-Client"
|
||||
ClientTUI = "tui"
|
||||
ClientCLI = "cli"
|
||||
ClientMCP = "mcp"
|
||||
)
|
||||
|
||||
// ClientKind returns the authenticated client kind from context.
|
||||
func ClientKind(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxClientKind).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// MCPProjectID returns the project bound to a validated MCP token, if any.
|
||||
func MCPProjectID(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxMCPProject).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient)))
|
||||
if kind == "" {
|
||||
kind = ClientTUI
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxClientKind, kind)
|
||||
|
||||
if kind == ClientMCP {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer <token>")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
|
||||
if token == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "empty bearer token")
|
||||
return
|
||||
}
|
||||
projectID := strings.TrimSpace(r.Header.Get("X-TM-Project"))
|
||||
if projectID == "" {
|
||||
projectID = extractProjectID(r)
|
||||
}
|
||||
if projectID == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(projectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" {
|
||||
writeErr(w, http.StatusForbidden, "no mcp token configured for project")
|
||||
return
|
||||
}
|
||||
got := hashToken(token)
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 {
|
||||
writeErr(w, http.StatusForbidden, "invalid mcp token")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxMCPProject, projectID)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// extractProjectID best-effort for auth before body is consumed.
|
||||
// Path patterns: /v1/projects/{id}/...
|
||||
func extractProjectID(r *http.Request) string {
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" {
|
||||
return q
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
// v1 projects {id} ...
|
||||
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requireMCPProject ensures MCP calls stay on the token's project.
|
||||
func requireMCPProject(r *http.Request, projectID string) bool {
|
||||
if ClientKind(r.Context()) != ClientMCP {
|
||||
return true
|
||||
}
|
||||
bound := MCPProjectID(r.Context())
|
||||
return bound != "" && bound == projectID
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleHistoryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"canUndo": s.history.CanUndo(),
|
||||
"canRedo": s.history.CanRedo(),
|
||||
"lastUndoName": s.history.LastUndoName(),
|
||||
"lastRedoName": s.history.LastRedoName(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
name := s.history.LastUndoName()
|
||||
if err := s.history.Undo(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func (s *Server) handleRedo(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
name := s.history.LastRedoName()
|
||||
if err := s.history.Redo(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": name})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event is a change notification pushed to SSE clients.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
}
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan Event]struct{}
|
||||
}
|
||||
|
||||
func newHub() *hub {
|
||||
return &hub{clients: map[chan Event]struct{}{}}
|
||||
}
|
||||
|
||||
func (h *hub) subscribe() chan Event {
|
||||
ch := make(chan Event, 16)
|
||||
h.mu.Lock()
|
||||
h.clients[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *hub) unsubscribe(ch chan Event) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, ch)
|
||||
h.mu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (h *hub) publish(ev Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.clients {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := s.hub.subscribe()
|
||||
defer s.hub.unsubscribe(ch)
|
||||
|
||||
fmt.Fprintf(w, ": ok\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, dst any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.projects.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
pid := MCPProjectID(r.Context())
|
||||
filtered := make([]project.Project, 0, 1)
|
||||
for _, p := range list {
|
||||
if p.ID == pid {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleEnsureProject(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if err := s.projects.Ensure(body.ID); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(body.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"id": body.ID})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if err := s.timeStore.DeleteByProject(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t, err := s.timeStore.LoadActiveTimer(); err == nil && t != nil && t.ProjectID == id {
|
||||
_ = s.timeStore.SaveActiveTimer(nil)
|
||||
}
|
||||
if err := s.projects.Delete(id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveMeta(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var meta project.Meta
|
||||
if err := readJSON(r, &meta); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Preserve existing mcpAuth if client omitted it (TUI sync edit).
|
||||
prev, _ := s.projects.LoadMeta(id)
|
||||
if meta.McpAuth == nil && prev.McpAuth != nil {
|
||||
meta.McpAuth = prev.McpAuth
|
||||
}
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(id)
|
||||
writeJSON(w, http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot inspect token status")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
configured := meta.McpAuth != nil && meta.McpAuth.TokenHash != ""
|
||||
created := ""
|
||||
if meta.McpAuth != nil {
|
||||
created = meta.McpAuth.CreatedAt
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"configured": configured,
|
||||
"createdAt": created,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot create tokens")
|
||||
return
|
||||
}
|
||||
if err := s.projects.Ensure(id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
raw := make([]byte, 24)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
token := "tm_mcp_" + hex.EncodeToString(raw)
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
created := time.Now().UTC().Format(time.RFC3339)
|
||||
meta.McpAuth = &project.McpAuth{TokenHash: hashToken(token), CreatedAt: created}
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"token": token,
|
||||
"project": id,
|
||||
"createdAt": created,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot revoke tokens")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
meta.McpAuth = nil
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// Server is the daemon HTTP API.
|
||||
type Server struct {
|
||||
store ticket.Store
|
||||
timeStore ticket.TimeStore
|
||||
projects project.Store
|
||||
history command.Executor
|
||||
hub *hub
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New constructs the API server.
|
||||
func New(store ticket.Store, timeStore ticket.TimeStore, projects project.Store, history command.Executor) *Server {
|
||||
s := &Server{
|
||||
store: store,
|
||||
timeStore: timeStore,
|
||||
projects: projects,
|
||||
history: history,
|
||||
hub: newHub(),
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler with auth middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.withAuth(s.mux)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/projects", s.handleListProjects)
|
||||
s.mux.HandleFunc("POST /v1/projects", s.handleEnsureProject)
|
||||
s.mux.HandleFunc("DELETE /v1/projects/{id}", s.handleDeleteProject)
|
||||
s.mux.HandleFunc("GET /v1/projects/{id}/meta", s.handleGetMeta)
|
||||
s.mux.HandleFunc("PUT /v1/projects/{id}/meta", s.handleSaveMeta)
|
||||
s.mux.HandleFunc("GET /v1/projects/{id}/mcp-token", s.handleMCPTokenStatus)
|
||||
s.mux.HandleFunc("POST /v1/projects/{id}/mcp-token", s.handleMCPTokenCreate)
|
||||
s.mux.HandleFunc("DELETE /v1/projects/{id}/mcp-token", s.handleMCPTokenRevoke)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/tickets", s.handleListTickets)
|
||||
s.mux.HandleFunc("GET /v1/tickets/{id}", s.handleGetTicket)
|
||||
s.mux.HandleFunc("POST /v1/tickets", s.handleCreateTicket)
|
||||
s.mux.HandleFunc("PATCH /v1/tickets/{id}", s.handleUpdateTicket)
|
||||
s.mux.HandleFunc("DELETE /v1/tickets/{id}", s.handleDeleteTicket)
|
||||
s.mux.HandleFunc("POST /v1/tickets/{id}/move", s.handleMoveTicket)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/time", s.handleListTime)
|
||||
s.mux.HandleFunc("GET /v1/time/by-ticket/{ticketId}", s.handleListTimeByTicket)
|
||||
s.mux.HandleFunc("GET /v1/time/sum/{ticketId}", s.handleSumTime)
|
||||
s.mux.HandleFunc("POST /v1/time", s.handleAddTime)
|
||||
s.mux.HandleFunc("PATCH /v1/time/{id}", s.handleUpdateTime)
|
||||
s.mux.HandleFunc("DELETE /v1/time/{id}", s.handleRemoveTime)
|
||||
s.mux.HandleFunc("GET /v1/timer", s.handleGetTimer)
|
||||
s.mux.HandleFunc("PUT /v1/timer", s.handleSetTimer)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/history", s.handleHistoryStatus)
|
||||
s.mux.HandleFunc("POST /v1/undo", s.handleUndo)
|
||||
s.mux.HandleFunc("POST /v1/redo", s.handleRedo)
|
||||
|
||||
s.mux.HandleFunc("POST /v1/sync/pull", s.handleSyncPull)
|
||||
s.mux.HandleFunc("POST /v1/sync/push", s.handleSyncPush)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/events", s.handleEvents)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) notify(projectID string) {
|
||||
s.hub.publish(Event{Type: "changed", ProjectID: projectID})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, push bool) {
|
||||
var body struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if body.ProjectID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "projectId required")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(body.ProjectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var res ytsync.Result
|
||||
ctx := context.Background()
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
|
||||
writeErr(w, http.StatusBadRequest, "taskmaster not configured")
|
||||
return
|
||||
}
|
||||
syncer := &tmsync.Syncer{Store: s.store, TimeStore: s.timeStore}
|
||||
if push {
|
||||
res, err = syncer.Push(ctx, body.ProjectID, *meta.TaskMaster)
|
||||
} else {
|
||||
res, err = syncer.Pull(ctx, body.ProjectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
if push {
|
||||
res, err = ytsync.PushProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
|
||||
} else {
|
||||
res, err = ytsync.PullProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(body.ProjectID)
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := r.URL.Query().Get("projectId")
|
||||
if projectID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "projectId query required")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, projectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
list, err := s.store.List(projectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
t, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var body ticket.Ticket
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.CreateTicket{Store: s.store, Ticket: body}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(cmd.Ticket.ProjectID)
|
||||
writeJSON(w, http.StatusCreated, cmd.Ticket)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var after ticket.Ticket
|
||||
if err := readJSON(r, &after); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
after.ID = id
|
||||
if after.ProjectID == "" {
|
||||
after.ProjectID = before.ProjectID
|
||||
}
|
||||
if !requireMCPProject(r, after.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.UpdateTicket{Store: s.store, Before: before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(after.ProjectID)
|
||||
writeJSON(w, http.StatusOK, after)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.DeleteTicket{Store: s.store, Snapshot: before}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(before.ProjectID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// MCP may only move within its project (no cross-project).
|
||||
if ClientKind(r.Context()) == ClientMCP && body.ToProjectID != MCPProjectID(r.Context()) {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot move tickets to another project")
|
||||
return
|
||||
}
|
||||
after := before
|
||||
after.ProjectID = body.ToProjectID
|
||||
cmd := &command.MoveTicket{Store: s.store, TimeStore: s.timeStore, Before: before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(before.ProjectID)
|
||||
s.notify(body.ToProjectID)
|
||||
writeJSON(w, http.StatusOK, cmd.After)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTime(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
pid := MCPProjectID(r.Context())
|
||||
filtered := make([]ticket.TimeEntry, 0)
|
||||
for _, e := range list {
|
||||
if e.ProjectID == pid {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTimeByTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticketID := r.PathValue("ticketId")
|
||||
t, err := s.store.Get(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
list, err := s.timeStore.ListByTicket(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleSumTime(w http.ResponseWriter, r *http.Request) {
|
||||
ticketID := r.PathValue("ticketId")
|
||||
t, err := s.store.Get(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
d, err := s.timeStore.SumByTicket(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"duration": int64(d)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAddTime(w http.ResponseWriter, r *http.Request) {
|
||||
var body ticket.TimeEntry
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.AddTimeEntry{Store: s.timeStore, Entry: body}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(cmd.Entry.ProjectID)
|
||||
writeJSON(w, http.StatusCreated, cmd.Entry)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
entries, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var before *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == id {
|
||||
before = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if before == nil {
|
||||
writeErr(w, http.StatusNotFound, "time entry not found")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var after ticket.TimeEntry
|
||||
if err := readJSON(r, &after); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
after.ID = id
|
||||
if after.ProjectID == "" {
|
||||
after.ProjectID = before.ProjectID
|
||||
}
|
||||
cmd := &command.UpdateTimeEntry{Store: s.timeStore, Before: *before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(after.ProjectID)
|
||||
writeJSON(w, http.StatusOK, after)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
entries, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var snap *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == id {
|
||||
snap = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if snap == nil {
|
||||
writeErr(w, http.StatusNotFound, "time entry not found")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, snap.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.RemoveTimeEntry{Store: s.timeStore, Snapshot: *snap}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(snap.ProjectID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.timeStore.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t != nil && ClientKind(r.Context()) == ClientMCP && t.ProjectID != MCPProjectID(r.Context()) {
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var body *ticket.ActiveTimer
|
||||
if raw != nil {
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var t ticket.ActiveTimer
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if t.TicketID == "" && t.ProjectID == "" {
|
||||
body = nil
|
||||
} else {
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if t.StartedAt.IsZero() {
|
||||
t.StartedAt = time.Now()
|
||||
}
|
||||
body = &t
|
||||
}
|
||||
}
|
||||
if err := s.timeStore.SaveActiveTimer(body); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if body != nil {
|
||||
s.notify(body.ProjectID)
|
||||
} else {
|
||||
s.notify("")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/daemon/api"
|
||||
)
|
||||
|
||||
// Client talks to the local JSON daemon over a Unix socket.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
kind string // tui | cli | mcp
|
||||
token string // bearer for mcp
|
||||
project string // mcp bound project
|
||||
}
|
||||
|
||||
// Options configures a daemon client.
|
||||
type Options struct {
|
||||
Socket string
|
||||
Kind string
|
||||
Token string
|
||||
Project string
|
||||
}
|
||||
|
||||
// Dial creates a client for the given Unix socket.
|
||||
func Dial(opts Options) *Client {
|
||||
kind := opts.Kind
|
||||
if kind == "" {
|
||||
kind = api.ClientTUI
|
||||
}
|
||||
return &Client{
|
||||
kind: kind,
|
||||
token: opts.Token,
|
||||
project: opts.Project,
|
||||
http: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", opts.Socket)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, "http://daemon"+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set(api.HeaderClient, c.kind)
|
||||
if c.kind == api.ClientMCP {
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.project != "" {
|
||||
req.Header.Set("X-TM-Project", c.project)
|
||||
}
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
var er struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &er)
|
||||
if er.Error == "" {
|
||||
er.Error = strings.TrimSpace(string(data))
|
||||
}
|
||||
if er.Error == "" {
|
||||
er.Error = resp.Status
|
||||
}
|
||||
return fmt.Errorf("%s", er.Error)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
// Health checks /v1/health.
|
||||
func (c *Client) Health() error {
|
||||
return c.do(http.MethodGet, "/v1/health", nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/daemon/api"
|
||||
)
|
||||
|
||||
// SubscribeEvents streams SSE change events until ctx is cancelled.
|
||||
func (c *Client) SubscribeEvents(ctx context.Context, emit func(api.Event)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://daemon/v1/events", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set(api.HeaderClient, c.kind)
|
||||
if c.kind == api.ClientMCP {
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.project != "" {
|
||||
req.Header.Set("X-TM-Project", c.project)
|
||||
}
|
||||
}
|
||||
// No overall timeout for long-lived SSE.
|
||||
httpClient := *c.http
|
||||
httpClient.Timeout = 0
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("events: %s", resp.Status)
|
||||
}
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
var ev api.Event
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
emit(ev)
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// Executor implements command.Executor via daemon mutation endpoints.
|
||||
type Executor struct{ c *Client }
|
||||
|
||||
func (c *Client) Executor() *Executor { return &Executor{c: c} }
|
||||
|
||||
func (e *Executor) Do(cmd command.Command) error {
|
||||
switch c := cmd.(type) {
|
||||
case *command.CreateTicket:
|
||||
var out ticket.Ticket
|
||||
if err := e.c.do(http.MethodPost, "/v1/tickets", c.Ticket, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Ticket = out
|
||||
return nil
|
||||
case *command.UpdateTicket:
|
||||
return e.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(c.After.ID), c.After, nil)
|
||||
case *command.DeleteTicket:
|
||||
return e.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(c.Snapshot.ID), nil, nil)
|
||||
case *command.MoveTicket:
|
||||
var out ticket.Ticket
|
||||
if err := e.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(c.Before.ID)+"/move", map[string]string{
|
||||
"toProjectId": c.After.ProjectID,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.After = out
|
||||
return nil
|
||||
case *command.AddTimeEntry:
|
||||
var out ticket.TimeEntry
|
||||
if err := e.c.do(http.MethodPost, "/v1/time", c.Entry, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Entry = out
|
||||
return nil
|
||||
case *command.RemoveTimeEntry:
|
||||
return e.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(c.Snapshot.ID), nil, nil)
|
||||
case *command.UpdateTimeEntry:
|
||||
return e.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(c.After.ID), c.After, nil)
|
||||
default:
|
||||
return fmt.Errorf("unsupported command type %T", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) Undo() error {
|
||||
return e.c.do(http.MethodPost, "/v1/undo", map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (e *Executor) Redo() error {
|
||||
return e.c.do(http.MethodPost, "/v1/redo", map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (e *Executor) CanUndo() bool {
|
||||
st, err := e.status()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.CanUndo
|
||||
}
|
||||
|
||||
func (e *Executor) CanRedo() bool {
|
||||
st, err := e.status()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.CanRedo
|
||||
}
|
||||
|
||||
func (e *Executor) LastUndoName() string {
|
||||
st, _ := e.status()
|
||||
return st.LastUndoName
|
||||
}
|
||||
|
||||
func (e *Executor) LastRedoName() string {
|
||||
st, _ := e.status()
|
||||
return st.LastRedoName
|
||||
}
|
||||
|
||||
type historyStatus struct {
|
||||
CanUndo bool `json:"canUndo"`
|
||||
CanRedo bool `json:"canRedo"`
|
||||
LastUndoName string `json:"lastUndoName"`
|
||||
LastRedoName string `json:"lastRedoName"`
|
||||
}
|
||||
|
||||
func (e *Executor) status() (historyStatus, error) {
|
||||
var st historyStatus
|
||||
err := e.c.do(http.MethodGet, "/v1/history", nil, &st)
|
||||
return st, err
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TicketStore implements ticket.Store against the daemon (reads + MarkSynced).
|
||||
// Mutations used by the TUI go through Executor; Create/Update/Delete/Move here
|
||||
// call the daemon mutation endpoints (with undo) for completeness.
|
||||
type TicketStore struct{ c *Client }
|
||||
|
||||
func (c *Client) TicketStore() *TicketStore { return &TicketStore{c: c} }
|
||||
|
||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
||||
var out []ticket.Ticket
|
||||
q := url.Values{"projectId": {projectID}}.Encode()
|
||||
err := s.c.do(http.MethodGet, "/v1/tickets?"+q, nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.Ticket{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodGet, "/v1/tickets/"+url.PathEscape(id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodPost, "/v1/tickets", t, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
return s.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(t.ID), t, nil)
|
||||
}
|
||||
|
||||
func (s *TicketStore) MarkSynced(id string) error {
|
||||
return fmt.Errorf("MarkSynced is daemon-internal (use sync endpoints)")
|
||||
}
|
||||
|
||||
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(id)+"/move", map[string]string{
|
||||
"toProjectId": toProjectID,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
// TimeStore implements ticket.TimeStore.
|
||||
type TimeStore struct{ c *Client }
|
||||
|
||||
func (c *Client) TimeStore() *TimeStore { return &TimeStore{c: c} }
|
||||
|
||||
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
err := s.c.do(http.MethodGet, "/v1/time", nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
err := s.c.do(http.MethodGet, "/v1/time/by-ticket/"+url.PathEscape(ticketID), nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
|
||||
var out ticket.TimeEntry
|
||||
err := s.c.do(http.MethodPost, "/v1/time", e, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) Update(e ticket.TimeEntry) error {
|
||||
return s.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(e.ID), e, nil)
|
||||
}
|
||||
|
||||
func (s *TimeStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (s *TimeStore) DeleteByProject(projectID string) error {
|
||||
// Composite delete is done server-side on project delete; not exposed alone.
|
||||
return fmt.Errorf("DeleteByProject is daemon-internal")
|
||||
}
|
||||
|
||||
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||
return fmt.Errorf("RetargetTicket is daemon-internal")
|
||||
}
|
||||
|
||||
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
|
||||
var out struct {
|
||||
Duration int64 `json:"duration"`
|
||||
}
|
||||
err := s.c.do(http.MethodGet, "/v1/time/sum/"+url.PathEscape(ticketID), nil, &out)
|
||||
return time.Duration(out.Duration), err
|
||||
}
|
||||
|
||||
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
|
||||
var out *ticket.ActiveTimer
|
||||
err := s.c.do(http.MethodGet, "/v1/timer", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
|
||||
if t == nil {
|
||||
return s.c.do(http.MethodPut, "/v1/timer", map[string]any{}, nil)
|
||||
}
|
||||
return s.c.do(http.MethodPut, "/v1/timer", t, nil)
|
||||
}
|
||||
|
||||
// ProjectStore implements project.Store.
|
||||
type ProjectStore struct{ c *Client }
|
||||
|
||||
func (c *Client) ProjectStore() *ProjectStore { return &ProjectStore{c: c} }
|
||||
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
var out []project.Project
|
||||
err := s.c.do(http.MethodGet, "/v1/projects", nil, &out)
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ProjectStore) Ensure(id string) error {
|
||||
return s.c.do(http.MethodPost, "/v1/projects", map[string]string{"id": id}, nil)
|
||||
}
|
||||
|
||||
func (s *ProjectStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
|
||||
var out project.Meta
|
||||
err := s.c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(id)+"/meta", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
|
||||
return s.c.do(http.MethodPut, "/v1/projects/"+url.PathEscape(id)+"/meta", meta, nil)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// SyncPull runs daemon sync pull for projectID.
|
||||
func (c *Client) SyncPull(projectID string) (ytsync.Result, error) {
|
||||
var out ytsync.Result
|
||||
err := c.do(http.MethodPost, "/v1/sync/pull", map[string]string{"projectId": projectID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SyncPush runs daemon sync push for projectID.
|
||||
func (c *Client) SyncPush(projectID string) (ytsync.Result, error) {
|
||||
var out ytsync.Result
|
||||
err := c.do(http.MethodPost, "/v1/sync/push", map[string]string{"projectId": projectID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateMCPToken creates a new MCP token for projectID (plaintext once).
|
||||
func (c *Client) CreateMCPToken(projectID string) (token, createdAt string, err error) {
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
err = c.do(http.MethodPost, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", map[string]any{}, &out)
|
||||
return out.Token, out.CreatedAt, err
|
||||
}
|
||||
|
||||
// RevokeMCPToken removes the MCP token for projectID.
|
||||
func (c *Client) RevokeMCPToken(projectID string) error {
|
||||
return c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, nil)
|
||||
}
|
||||
|
||||
// MCPTokenStatus returns whether a token is configured.
|
||||
func (c *Client) MCPTokenStatus(projectID string) (configured bool, createdAt string, err error) {
|
||||
var out struct {
|
||||
Configured bool `json:"configured"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
err = c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, &out)
|
||||
return out.Configured, out.CreatedAt, err
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"task/internal/config"
|
||||
)
|
||||
|
||||
// Status describes whether a daemon is reachable for cfg.DataDir.
|
||||
type Status struct {
|
||||
Running bool
|
||||
PID int
|
||||
Socket string
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// StatusOf returns daemon status for the given config.
|
||||
func StatusOf(cfg *config.Config) (Status, error) {
|
||||
p, err := PathsForDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
st := Status{Socket: p.Socket, DataDir: cfg.DataDir}
|
||||
pid, err := readPID(p.PIDFile)
|
||||
if err == nil && processAlive(pid) {
|
||||
st.PID = pid
|
||||
}
|
||||
if Healthy(p.Socket) {
|
||||
st.Running = true
|
||||
return st, nil
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Healthy reports whether the daemon HTTP health endpoint responds on socket.
|
||||
func Healthy(socket string) bool {
|
||||
cli := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", socket)
|
||||
},
|
||||
},
|
||||
Timeout: 500 * time.Millisecond,
|
||||
}
|
||||
resp, err := cli.Get("http://daemon/v1/health")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// Ensure starts the daemon if needed and waits until it is healthy.
|
||||
func Ensure(cfg *config.Config) (Paths, error) {
|
||||
p, err := PathsForDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
if Healthy(p.Socket) {
|
||||
return p, nil
|
||||
}
|
||||
cleanupStale(p)
|
||||
if err := Spawn(cfg, p); err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if Healthy(p.Socket) {
|
||||
return p, nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return Paths{}, fmt.Errorf("daemon did not become healthy at %s", p.Socket)
|
||||
}
|
||||
|
||||
// Stop terminates the daemon for cfg.DataDir if running.
|
||||
func Stop(cfg *config.Config) error {
|
||||
p, err := PathsForDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := readPID(p.PIDFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cleanupStale(p)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if processAlive(pid) {
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("signal daemon: %w", err)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if processAlive(pid) {
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
cleanupStale(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Spawn starts a detached daemon process for cfg.
|
||||
func Spawn(cfg *config.Config, p Paths) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("executable: %w", err)
|
||||
}
|
||||
logFile, err := os.OpenFile(p.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log: %w", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
cmd := exec.Command(exe, "daemon")
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("spawn daemon: %w", err)
|
||||
}
|
||||
// Detach: do not wait; child writes its own PID file when listening.
|
||||
_ = cmd.Process.Release()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupStale(p Paths) {
|
||||
_ = os.Remove(p.Socket)
|
||||
_ = os.Remove(p.PIDFile)
|
||||
}
|
||||
|
||||
// AbsoluteDataDir ensures DataDir is absolute for path hashing.
|
||||
func AbsoluteDataDir(dataDir string) (string, error) {
|
||||
if filepath.IsAbs(dataDir) {
|
||||
return filepath.Clean(dataDir), nil
|
||||
}
|
||||
abs, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
sockName = "daemon.sock"
|
||||
pidName = "daemon.pid"
|
||||
logName = "daemon.log"
|
||||
)
|
||||
|
||||
// Paths holds filesystem locations for one DataDir's daemon instance.
|
||||
type Paths struct {
|
||||
RuntimeDir string
|
||||
Socket string
|
||||
PIDFile string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// PathsForDataDir returns runtime paths unique to dataDir.
|
||||
func PathsForDataDir(dataDir string) (Paths, error) {
|
||||
dataDir = filepath.Clean(dataDir)
|
||||
sum := sha256.Sum256([]byte(dataDir))
|
||||
id := hex.EncodeToString(sum[:8])
|
||||
|
||||
dir, err := mkdirRuntime(id, dataDir)
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
return Paths{
|
||||
RuntimeDir: dir,
|
||||
Socket: filepath.Join(dir, sockName),
|
||||
PIDFile: filepath.Join(dir, pidName),
|
||||
LogFile: filepath.Join(dir, logName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mkdirRuntime(id, dataDir string) (string, error) {
|
||||
candidates := []string{}
|
||||
if r := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); r != "" {
|
||||
candidates = append(candidates, filepath.Join(r, "task-master-"+id))
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join(os.TempDir(), fmt.Sprintf("task-master-%d-%s", os.Getuid(), id)),
|
||||
filepath.Join(dataDir, ".runtime"),
|
||||
)
|
||||
var last error
|
||||
for _, dir := range candidates {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
if last == nil {
|
||||
last = fmt.Errorf("no runtime dir candidate")
|
||||
}
|
||||
return "", fmt.Errorf("create runtime dir: %w", last)
|
||||
}
|
||||
|
||||
func writePID(path string, pid int) error {
|
||||
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600)
|
||||
}
|
||||
|
||||
func readPID(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s := strings.TrimSpace(string(data))
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty pid file")
|
||||
}
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/config"
|
||||
"task/internal/daemon/api"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// Run starts the foreground daemon for cfg until signal or context cancel.
|
||||
func Run(cfg *config.Config) error {
|
||||
abs, err := AbsoluteDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DataDir = abs
|
||||
|
||||
p, err := PathsForDataDir(cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStale(p)
|
||||
|
||||
store := ticket.NewJSONStore(cfg.DataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
||||
projects := project.NewDirStore(cfg.DataDir)
|
||||
history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: %w", err)
|
||||
}
|
||||
|
||||
srvAPI := api.New(store, timeStore, projects, history)
|
||||
server := &http.Server{Handler: srvAPI.Handler()}
|
||||
|
||||
ln, err := net.Listen("unix", p.Socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", p.Socket, err)
|
||||
}
|
||||
if err := os.Chmod(p.Socket, 0o600); err != nil {
|
||||
_ = ln.Close()
|
||||
return fmt.Errorf("chmod socket: %w", err)
|
||||
}
|
||||
if err := writePID(p.PIDFile, os.Getpid()); err != nil {
|
||||
_ = ln.Close()
|
||||
return fmt.Errorf("pid file: %w", err)
|
||||
}
|
||||
defer cleanupStale(p)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Serve(ln)
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-sigCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(ctx)
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"task/internal/daemon/client"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// ServeStdio runs the MCP server on stdin/stdout using auth from .tm_token.
|
||||
func ServeStdio(cli *client.Client, tf TokenFile) error {
|
||||
s := server.NewMCPServer(
|
||||
"task-master",
|
||||
"1.0.0",
|
||||
server.WithToolCapabilities(true),
|
||||
)
|
||||
registerTools(s, cli, tf)
|
||||
return server.ServeStdio(s)
|
||||
}
|
||||
|
||||
func registerTools(s *server.MCPServer, cli *client.Client, tf TokenFile) {
|
||||
jsonResult := func(v any) (*mcp.CallToolResult, error) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
}
|
||||
fail := func(err error) (*mcp.CallToolResult, error) {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
s.AddTool(mcp.NewTool("list_projects",
|
||||
mcp.WithDescription("List the TM project bound to .tm_token"),
|
||||
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
list, err := cli.ProjectStore().List()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(list)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("list_tickets",
|
||||
mcp.WithDescription("List tickets in the token project"),
|
||||
mcp.WithString("status", mcp.Description("Optional status filter: todo, in_progress, done")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
list, err := cli.TicketStore().List(tf.Project)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
status := req.GetString("status", "")
|
||||
if status != "" {
|
||||
filtered := list[:0]
|
||||
for _, t := range list {
|
||||
if string(t.Status) == status {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
return jsonResult(list)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("get_ticket",
|
||||
mcp.WithDescription("Get one ticket by id"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
t, err := cli.TicketStore().Get(id)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(t)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("create_ticket",
|
||||
mcp.WithDescription("Create a ticket in the token project"),
|
||||
mcp.WithString("title", mcp.Required(), mcp.Description("Ticket title")),
|
||||
mcp.WithString("description", mcp.Description("Markdown body")),
|
||||
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
title, err := req.RequireString("title")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
st := ticket.Status(req.GetString("status", string(ticket.StatusTodo)))
|
||||
t := ticket.Ticket{
|
||||
Title: title,
|
||||
Description: req.GetString("description", ""),
|
||||
Status: st,
|
||||
ProjectID: tf.Project,
|
||||
}
|
||||
created, err := cli.TicketStore().Create(t)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(created)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("update_ticket",
|
||||
mcp.WithDescription("Update ticket title, description, and/or status"),
|
||||
mcp.WithString("id", mcp.Required(), mcp.Description("Ticket id")),
|
||||
mcp.WithString("title", mcp.Description("New title")),
|
||||
mcp.WithString("description", mcp.Description("New description")),
|
||||
mcp.WithString("status", mcp.Description("todo|in_progress|done")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
before, err := cli.TicketStore().Get(id)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
after := before
|
||||
if v := req.GetString("title", ""); v != "" {
|
||||
after.Title = v
|
||||
}
|
||||
if _, ok := req.GetArguments()["description"]; ok {
|
||||
after.Description = req.GetString("description", "")
|
||||
}
|
||||
if v := req.GetString("status", ""); v != "" {
|
||||
after.Status = ticket.Status(v)
|
||||
}
|
||||
if err := cli.TicketStore().Update(after); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(after)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("move_ticket",
|
||||
mcp.WithDescription("Move a ticket (MCP only allows same project id)"),
|
||||
mcp.WithString("id", mcp.Required()),
|
||||
mcp.WithString("to_project_id", mcp.Required()),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
to, err := req.RequireString("to_project_id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
moved, err := cli.TicketStore().Move(id, to)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(moved)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("delete_ticket",
|
||||
mcp.WithDescription("Delete a ticket by id"),
|
||||
mcp.WithString("id", mcp.Required()),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
id, err := req.RequireString("id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := cli.TicketStore().Delete(id); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return mcp.NewToolResultText("deleted"), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("list_time",
|
||||
mcp.WithDescription("List time entries for the token project"),
|
||||
mcp.WithString("ticket_id", mcp.Description("Optional ticket filter")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
ticketID := req.GetString("ticket_id", "")
|
||||
var list []ticket.TimeEntry
|
||||
var err error
|
||||
if ticketID != "" {
|
||||
list, err = cli.TimeStore().ListByTicket(ticketID)
|
||||
} else {
|
||||
list, err = cli.TimeStore().List()
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(list)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("add_time",
|
||||
mcp.WithDescription("Add a manual time entry"),
|
||||
mcp.WithString("ticket_id", mcp.Required()),
|
||||
mcp.WithNumber("minutes", mcp.Required(), mcp.Description("Duration in minutes")),
|
||||
mcp.WithString("note", mcp.Description("Optional note")),
|
||||
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
ticketID, err := req.RequireString("ticket_id")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
mins, err := req.RequireFloat("minutes")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
tk, err := cli.TicketStore().Get(ticketID)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
dur := time.Duration(mins * float64(time.Minute))
|
||||
end := time.Now()
|
||||
start := end.Add(-dur)
|
||||
entry := ticket.TimeEntry{
|
||||
TicketID: ticketID,
|
||||
ProjectID: tk.ProjectID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Duration: dur,
|
||||
Source: ticket.SourceManual,
|
||||
Note: req.GetString("note", ""),
|
||||
}
|
||||
created, err := cli.TimeStore().Create(entry)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(created)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("undo",
|
||||
mcp.WithDescription("Undo the last daemon command"),
|
||||
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
if err := cli.Executor().Undo(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return mcp.NewToolResultText("ok"), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("redo",
|
||||
mcp.WithDescription("Redo the last undone daemon command"),
|
||||
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
if err := cli.Executor().Redo(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return mcp.NewToolResultText("ok"), nil
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("sync_pull",
|
||||
mcp.WithDescription("Pull tickets/time from the configured backend for the token project"),
|
||||
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
res, err := cli.SyncPull(tf.Project)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(res)
|
||||
})
|
||||
|
||||
s.AddTool(mcp.NewTool("sync_push",
|
||||
mcp.WithDescription("Push tickets/time to the configured backend for the token project"),
|
||||
), func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
_ = ctx
|
||||
res, err := cli.SyncPush(tf.Project)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return jsonResult(res)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tokenFileName = ".tm_token"
|
||||
|
||||
// TokenFile holds credentials from a workspace .tm_token file.
|
||||
type TokenFile struct {
|
||||
Project string
|
||||
Token string
|
||||
Path string
|
||||
}
|
||||
|
||||
// FindTokenFile walks from start (usually cwd) upward looking for .tm_token.
|
||||
func FindTokenFile(start string) (TokenFile, error) {
|
||||
dir, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
for {
|
||||
path := filepath.Join(dir, tokenFileName)
|
||||
if st, err := os.Stat(path); err == nil && !st.IsDir() {
|
||||
tf, err := ParseTokenFile(path)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return TokenFile{}, fmt.Errorf("no %s found (create an MCP token in the TUI with g, then place project=… and token=… in %s)", tokenFileName, tokenFileName)
|
||||
}
|
||||
|
||||
// ParseTokenFile reads project= and token= lines from path.
|
||||
func ParseTokenFile(path string) (TokenFile, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
tf := TokenFile{Path: path}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "project":
|
||||
tf.Project = val
|
||||
case "token":
|
||||
tf.Token = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return TokenFile{}, err
|
||||
}
|
||||
if tf.Project == "" || tf.Token == "" {
|
||||
return TokenFile{}, fmt.Errorf("%s must contain project= and token= lines", path)
|
||||
}
|
||||
return tf, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAndFindTokenFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "a", "b")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, ".tm_token")
|
||||
content := "# comment\nproject=inbox\ntoken=tm_mcp_abc\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tf, err := FindTokenFile(sub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tf.Project != "inbox" || tf.Token != "tm_mcp_abc" {
|
||||
t.Fatalf("got %+v", tf)
|
||||
}
|
||||
|
||||
_, err = FindTokenFile(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing token error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenFileIncomplete(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".tm_token")
|
||||
if err := os.WriteFile(path, []byte("project=only\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseTokenFile(path); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,18 @@ func (t TaskMaster) Configured() bool {
|
||||
strings.TrimSpace(t.ProjectID) != ""
|
||||
}
|
||||
|
||||
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
|
||||
type McpAuth struct {
|
||||
TokenHash string `json:"tokenHash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is per-project metadata stored beside tickets.json.
|
||||
type Meta struct {
|
||||
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||
|
||||
+70
-26
@@ -19,11 +19,23 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// DaemonChangedMsg is sent when the daemon publishes a change event (SSE).
|
||||
type DaemonChangedMsg struct {
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
// RemoteOps is the optional daemon client surface for sync and MCP tokens.
|
||||
type RemoteOps interface {
|
||||
SyncPull(projectID string) (ytsync.Result, error)
|
||||
SyncPush(projectID string) (ytsync.Result, error)
|
||||
CreateMCPToken(projectID string) (token, createdAt string, err error)
|
||||
RevokeMCPToken(projectID string) error
|
||||
}
|
||||
|
||||
// timerTickMsg refreshes the header while a timer is running.
|
||||
type timerTickMsg time.Time
|
||||
|
||||
@@ -106,10 +118,11 @@ type Model struct {
|
||||
projectEdit views.ProjectEditForm
|
||||
projectEditMeta project.Meta // snapshot when opening the form
|
||||
syncing bool
|
||||
remote RemoteOps
|
||||
}
|
||||
|
||||
// New creates the root UI model.
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model {
|
||||
styles := DefaultStyles()
|
||||
if exec == nil {
|
||||
exec = command.NewHistory()
|
||||
@@ -121,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto
|
||||
timeStore: timeStore,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
remote: remote,
|
||||
mode: ModeList,
|
||||
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||
reportGroupBy: ticket.GroupByProject,
|
||||
@@ -167,6 +181,15 @@ func timerTickCmd() tea.Cmd {
|
||||
// Update implements tea.Model.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case DaemonChangedMsg:
|
||||
_ = m.reloadTickets()
|
||||
if m.activeTimer != nil && m.timeStore != nil {
|
||||
if t, err := m.timeStore.LoadActiveTimer(); err == nil {
|
||||
m.activeTimer = t
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
@@ -920,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
m.setStatus("sync already running…")
|
||||
return m, nil
|
||||
}
|
||||
if m.remote == nil {
|
||||
m.setError(fmt.Errorf("daemon client not available"))
|
||||
return m, nil
|
||||
}
|
||||
m.clearStatus()
|
||||
m.syncing = true
|
||||
switch action {
|
||||
@@ -928,33 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
default:
|
||||
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
|
||||
}
|
||||
store := m.store
|
||||
timeStore := m.timeStore
|
||||
projects := m.projects
|
||||
remote := m.remote
|
||||
return m, func() tea.Msg {
|
||||
var pull, push ytsync.Result
|
||||
var err error
|
||||
meta, merr := projects.LoadMeta(projectID)
|
||||
if merr != nil {
|
||||
err = merr
|
||||
} else {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
|
||||
default:
|
||||
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
default:
|
||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
}
|
||||
}
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = remote.SyncPush(projectID)
|
||||
default:
|
||||
pull, err = remote.SyncPull(projectID)
|
||||
}
|
||||
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
||||
}
|
||||
@@ -1031,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.openSyncMenu(item.Project.ID)
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.MCPToken):
|
||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||
if !ok {
|
||||
m.setError(fmt.Errorf("no project selected"))
|
||||
return m, nil
|
||||
}
|
||||
if m.remote == nil {
|
||||
m.setError(fmt.Errorf("daemon client not available"))
|
||||
return m, nil
|
||||
}
|
||||
token, _, err := m.remote.CreateMCPToken(item.Project.ID)
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token))
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.MCPTokenRevoke):
|
||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||
if !ok {
|
||||
m.setError(fmt.Errorf("no project selected"))
|
||||
return m, nil
|
||||
}
|
||||
if m.remote == nil {
|
||||
m.setError(fmt.Errorf("daemon client not available"))
|
||||
return m, nil
|
||||
}
|
||||
if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID))
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.DeleteForce):
|
||||
m.clearStatus()
|
||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||
|
||||
+49
-31
@@ -152,42 +152,60 @@ func (m Model) helpSep() string {
|
||||
return sep
|
||||
}
|
||||
|
||||
// wrapHelpParts packs styled help snippets into lines that fit within width.
|
||||
func wrapHelpParts(parts []string, width int, sep string) []string {
|
||||
// padHelpCell pads a styled help snippet to cellW visible columns.
|
||||
func padHelpCell(part string, cellW int) string {
|
||||
pad := cellW - lipgloss.Width(part)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
return part + strings.Repeat(" ", pad)
|
||||
}
|
||||
|
||||
// tableHelpParts lays out styled help snippets as a fixed-width column grid.
|
||||
func tableHelpParts(parts []string, width int, sep string) []string {
|
||||
if len(parts) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
sepW := lipgloss.Width(sep)
|
||||
var lines []string
|
||||
var cur string
|
||||
|
||||
cellW := 0
|
||||
for _, p := range parts {
|
||||
pw := lipgloss.Width(p)
|
||||
if cur == "" {
|
||||
cur = p
|
||||
// Oversized single entry still gets its own line so nothing is dropped.
|
||||
if pw > width {
|
||||
lines = append(lines, cur)
|
||||
cur = ""
|
||||
if w := lipgloss.Width(p); w > cellW {
|
||||
cellW = w
|
||||
}
|
||||
}
|
||||
if cellW < 1 {
|
||||
cellW = 1
|
||||
}
|
||||
|
||||
sepW := lipgloss.Width(sep)
|
||||
cols := 1
|
||||
if cellW <= width {
|
||||
cols = (width + sepW) / (cellW + sepW)
|
||||
if cols < 1 {
|
||||
cols = 1
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i := 0; i < len(parts); i += cols {
|
||||
end := i + cols
|
||||
if end > len(parts) {
|
||||
end = len(parts)
|
||||
}
|
||||
row := parts[i:end]
|
||||
cells := make([]string, len(row))
|
||||
for j, p := range row {
|
||||
// Pad every cell except a lone oversized entry on its own row.
|
||||
if len(row) == 1 && lipgloss.Width(p) > width {
|
||||
cells[j] = p
|
||||
} else {
|
||||
cells[j] = padHelpCell(p, cellW)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if lipgloss.Width(cur)+sepW+pw <= width {
|
||||
cur = cur + sep + p
|
||||
continue
|
||||
}
|
||||
lines = append(lines, cur)
|
||||
cur = p
|
||||
if pw > width {
|
||||
lines = append(lines, cur)
|
||||
cur = ""
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{""}
|
||||
lines = append(lines, strings.Join(cells, sep))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -203,8 +221,8 @@ func (m Model) renderFooter() string {
|
||||
sep := m.helpSep()
|
||||
parts := m.helpParts()
|
||||
|
||||
// Wrap help across the full width so all bindings stay visible.
|
||||
lines := wrapHelpParts(parts, width, sep)
|
||||
// Table layout so key/desc columns stay aligned across rows.
|
||||
lines := tableHelpParts(parts, width, sep)
|
||||
|
||||
// Place version on the last line to the right when it fits; otherwise
|
||||
// put it on an extra right-aligned line.
|
||||
|
||||
+11
-1
@@ -25,6 +25,8 @@ type KeyMap struct {
|
||||
TimeEntries key.Binding // m — manage time entries
|
||||
Report key.Binding // r — time report
|
||||
Sync key.Binding // Y — pull YouTrack issues
|
||||
MCPToken key.Binding // g — create MCP token for project
|
||||
MCPTokenRevoke key.Binding // G — revoke MCP token
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
@@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("Y"),
|
||||
key.WithHelp("Y", "YouTrack pull/push"),
|
||||
),
|
||||
MCPToken: key.NewBinding(
|
||||
key.WithKeys("g"),
|
||||
key.WithHelp("g", "MCP token"),
|
||||
),
|
||||
MCPTokenRevoke: key.NewBinding(
|
||||
key.WithKeys("G"),
|
||||
key.WithHelp("G", "revoke MCP token"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
@@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap {
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
|
||||
Reference in New Issue
Block a user