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
+137 -63
View File
@@ -8,14 +8,12 @@ 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"
"task/internal/ui"
"task/internal/version"
"task/internal/tmsync"
"task/internal/ytsync"
)
func main() {
@@ -33,6 +31,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 +68,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 +196,39 @@ 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 {
return fmt.Errorf("mcp server not implemented yet — coming in phase 2")
}
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 +236,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 it in a repo as .tm_token for AI access.
`, 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)
}