diff --git a/cmd/attach.go b/cmd/attach.go new file mode 100644 index 0000000..39a7cf5 --- /dev/null +++ b/cmd/attach.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var attachCmd = &cobra.Command{ + Use: "attach ", + Short: "Attach to a workspace via tmux (always uses persistent session, regardless of CTASK_SESSION_MODE)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: runAttach, +} + +var ( + attachAgent string + attachForce bool +) + +func init() { + attachCmd.Flags().StringVarP(&attachAgent, "agent", "a", "", "Override agent command") + attachCmd.Flags().BoolVar(&attachForce, "force", false, "Skip active-session and stale-workspace warnings (owner-create path only)") + attachCmd.ValidArgsFunction = completeWorkspaces(completionActive) + rootCmd.AddCommand(attachCmd) +} + +func runAttach(cmd *cobra.Command, args []string) error { + roots := config.SearchRoots() + // Active-only resolution per spec ยง9 (matches resume's completion filter). + ws := resolveOne(roots, args[0], false) + + // updated_at bump (existing v0.4 behavior). + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMetaLocked(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + agent := attachAgent + if agent == "" { + agent = ws.Meta.Agent + } + + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + Agent: agent, + Shell: false, // attach defaults to agent + Force: attachForce, + AlwaysPersistent: true, // attach is always tmux, regardless of env + CommandName: "attach", + }) +} diff --git a/cmd/attach_test.go b/cmd/attach_test.go new file mode 100644 index 0000000..c6903b9 --- /dev/null +++ b/cmd/attach_test.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Tests in this file mutate runWorkspaceEntry. Do not run with t.Parallel(). + +func TestAttachCommandRegistered(t *testing.T) { + var found *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "attach" { + found = c + break + } + } + if found == nil { + t.Fatal("attach command not registered") + } +} + +func TestAttachRefusesDirectFlag(t *testing.T) { + for _, c := range rootCmd.Commands() { + if c.Name() != "attach" { + continue + } + if c.Flags().Lookup("direct") != nil { + t.Error("--direct flag must NOT exist on attach (always-tmux)") + } + if c.ValidArgsFunction == nil { + t.Error("attach must have ValidArgsFunction for tab completion") + } + return + } + t.Fatal("attach command not registered") +} + +// attach must call runWorkspaceEntry with AlwaysPersistent: true, +// Shell: false (defaults to agent), and CommandName: "attach". We use a +// fresh workspace fixture so resolveOne returns a real workspace, then +// stub runWorkspaceEntry to capture the opts. +func TestAttachForwardsToEntryHelperWithAlwaysPersistent(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-05-08_attach-fwd-demo") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "attach-fwd-demo", Title: "attach-fwd-demo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + prevAgent, prevForce := attachAgent, attachForce + attachAgent, attachForce = "", false + t.Cleanup(func() { + attachAgent = prevAgent + attachForce = prevForce + }) + + if err := runAttach(attachCmd, []string{"attach-fwd-demo"}); err != nil { + t.Fatalf("runAttach: %v", err) + } + if !captured.AlwaysPersistent { + t.Error("attach must set AlwaysPersistent=true") + } + if captured.Shell { + t.Error("attach defaults to agent, Shell must be false") + } + if captured.CommandName != "attach" { + t.Errorf("CommandName: got %q, want %q", captured.CommandName, "attach") + } +}