From 50e7333e848aa005abb56d198d6cdc144162edf0 Mon Sep 17 00:00:00 2001 From: warren Date: Sun, 5 Apr 2026 18:34:40 -0400 Subject: [PATCH] feat: all six CLI commands (new, list, resume, open, info, archive) Complete command implementations with all flags per spec. Shared query resolution helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/archive.go | 42 +++++++++++++++++++++++++ cmd/helpers.go | 33 ++++++++++++++++++++ cmd/info.go | 60 ++++++++++++++++++++++++++++++++++++ cmd/list.go | 70 +++++++++++++++++++++++++++++++++++++++++ cmd/new.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/open.go | 44 ++++++++++++++++++++++++++ cmd/resume.go | 68 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 401 insertions(+) create mode 100644 cmd/archive.go create mode 100644 cmd/helpers.go create mode 100644 cmd/info.go create mode 100644 cmd/list.go create mode 100644 cmd/new.go create mode 100644 cmd/open.go create mode 100644 cmd/resume.go diff --git a/cmd/archive.go b/cmd/archive.go new file mode 100644 index 0000000..08a4b98 --- /dev/null +++ b/cmd/archive.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var archiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Mark a workspace as archived", + Args: cobra.ExactArgs(1), + RunE: runArchive, +} + +func init() { + rootCmd.AddCommand(archiveCmd) +} + +func runArchive(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + ws := resolveOne(root, args[0], false) + + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.Status = "archived" + ws.Meta.ArchivedAt = &now + ws.Meta.UpdatedAt = now + + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + relPath := workspace.RelativePath(root, ws.Path) + fmt.Printf("[ctask] archived: %s\n", relPath) + + return nil +} diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..c16e72b --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// resolveOne resolves a query to exactly one workspace. Prints errors and exits on 0 or >1 matches. +func resolveOne(root, query string, includeArchived bool) *workspace.QueryResult { + results, err := workspace.ResolveQuery(root, query, includeArchived) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(results) == 0 { + fmt.Fprintf(os.Stderr, "No workspace matches %q.\n", query) + os.Exit(1) + } + + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "Multiple workspaces match %q:\n", query) + for _, r := range results { + fmt.Fprintf(os.Stderr, " %s\n", workspace.RelativePath(root, r.Path)) + } + fmt.Fprintln(os.Stderr, "Specify a more precise query.") + os.Exit(1) + } + + return &results[0] +} diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..014a476 --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" +) + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Display metadata and path for a workspace", + Args: cobra.ExactArgs(1), + RunE: runInfo, +} + +var infoAll bool + +func init() { + infoCmd.Flags().BoolVarP(&infoAll, "all", "a", false, "Include archived workspaces in query resolution") + rootCmd.AddCommand(infoCmd) +} + +func runInfo(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + ws := resolveOne(root, args[0], infoAll) + m := ws.Meta + + fmt.Printf("Task: %s\n", m.Slug) + fmt.Printf("Title: %s\n", m.Title) + fmt.Printf("Category: %s\n", m.Category) + fmt.Printf("Status: %s\n", m.Status) + fmt.Printf("Mode: %s\n", m.Mode) + fmt.Printf("Agent: %s\n", m.Agent) + fmt.Printf("Created: %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", m.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Path: %s\n", ws.Path) + + if m.ArchivedAt != nil { + fmt.Printf("Archived: %s\n", m.ArchivedAt.Format("2006-01-02 15:04:05")) + } + + // List contents + fmt.Println() + fmt.Println("Contents:") + entries, err := os.ReadDir(ws.Path) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + fmt.Printf(" %s\n", name) + } + + return nil +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..313db2c --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Show recent workspaces in reverse-chronological order", + Args: cobra.NoArgs, + RunE: runList, +} + +var ( + listAll bool + listCategory string + listLimit int +) + +func init() { + listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "Include archived workspaces") + listCmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") + listCmd.Flags().IntVarP(&listLimit, "limit", "n", 20, "Maximum entries to show") + rootCmd.AddCommand(listCmd) +} + +func runList(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + + results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: listAll, + Category: listCategory, + Limit: listLimit, + }) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Println("No workspaces found.") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + for _, ws := range results { + dirName := filepath.Base(ws.Path) + date := "" + if len(dirName) >= 10 { + date = dirName[:10] + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + ws.Meta.Status, + ws.Meta.Mode, + ws.Meta.Category, + date, + ws.Meta.Slug, + ) + } + w.Flush() + + return nil +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..58d7114 --- /dev/null +++ b/cmd/new.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var newCmd = &cobra.Command{ + Use: "new [title]", + Short: "Create a new task workspace and launch the agent", + Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.", + Args: cobra.MaximumNArgs(1), + RunE: runNew, +} + +var ( + newCategory string + newContainer bool + newShell bool + newAgent string + newNoLaunch bool +) + +func init() { + newCmd.Flags().StringVarP(&newCategory, "category", "c", "general", "Workspace category subdirectory") + newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (v0.2)") + newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") + newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") + newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") + rootCmd.AddCommand(newCmd) +} + +func runNew(cmd *cobra.Command, args []string) error { + if newContainer { + fmt.Println(shell.ContainerNotice()) + return nil + } + + root := config.ResolveRoot() + agent := newAgent + if agent == "" { + agent = config.ResolveAgent() + } + + title := "" + if len(args) > 0 { + title = args[0] + } + + ws, err := workspace.Create(workspace.CreateOpts{ + Root: root, + Title: title, + Category: newCategory, + Mode: "local", + Agent: agent, + }) + if err != nil { + return err + } + + relPath := workspace.RelativePath(root, ws.Path) + fmt.Printf("[ctask] created %s\n", relPath) + + if newNoLaunch { + return nil + } + + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + + if newShell { + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) + } + + // Agent mode: print banner and exec + for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { + fmt.Println(line) + } + + return shell.ExecAgent(agent, ws.Path, envVars) +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..cadcd5c --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var openCmd = &cobra.Command{ + Use: "open ", + Short: "Open a workspace directory without launching the agent", + Args: cobra.ExactArgs(1), + RunE: runOpen, +} + +var openAll bool + +func init() { + openCmd.Flags().BoolVarP(&openAll, "all", "a", false, "Include archived workspaces in query resolution") + rootCmd.AddCommand(openCmd) +} + +func runOpen(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + ws := resolveOne(root, args[0], openAll) + + // Update updated_at + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + + // Spawn interactive subshell (not agent, not cd in parent) + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) +} diff --git a/cmd/resume.go b/cmd/resume.go new file mode 100644 index 0000000..eaaf571 --- /dev/null +++ b/cmd/resume.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var resumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Reopen an existing workspace and launch the agent", + Args: cobra.ExactArgs(1), + RunE: runResume, +} + +var ( + resumeContainer bool + resumeShell bool + resumeAgent string +) + +func init() { + resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (v0.2)") + resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent") + resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command") + rootCmd.AddCommand(resumeCmd) +} + +func runResume(cmd *cobra.Command, args []string) error { + if resumeContainer { + fmt.Println(shell.ContainerNotice()) + return nil + } + + root := config.ResolveRoot() + ws := resolveOne(root, args[0], false) + + // Update updated_at + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + agent := resumeAgent + if agent == "" { + agent = ws.Meta.Agent + } + + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + + if resumeShell { + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) + } + + // Agent mode + for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { + fmt.Println(line) + } + + return shell.ExecAgent(agent, ws.Path, envVars) +}