Add local JSON daemon and route TUI/CLI through it.

A per-DataDir background process owns ticket JSON, history, and sync so
multiple TUI instances stay consistent; MCP token APIs are ready for phase 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 16:32:13 +01:00
co-authored by Cursor
parent 98fcb57298
commit 2dbf0918de
23 changed files with 2107 additions and 93 deletions
+70 -26
View File
@@ -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)
+11 -1
View File
@@ -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.