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:
+137
-63
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user