package cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/agent" "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 or project workspace and launch the agent", Long: "Create a new task or project workspace and launch the agent. If title is omitted, generates task-HHMMSS.", Args: cobra.MaximumNArgs(1), SilenceUsage: true, RunE: runNew, } var ( newCategory string newContainer bool newShell bool newAgent string newNoLaunch bool newProject bool newDirect bool ) func init() { newCmd.Flags().StringVarP(&newCategory, "category", "c", "", "Workspace category subdirectory (default: \"general\" for tasks, \"projects\" for projects)") newCmd.Flags().BoolVar(&newProject, "project", false, "Create a project workspace (longer-lived; uses CTASK_PROJECT_ROOT if set, runs git init)") newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (deferred)") 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") newCmd.Flags().BoolVar(&newDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(newCmd) } func runNew(cmd *cobra.Command, args []string) error { if newContainer { fmt.Println(shell.ContainerNotice()) return nil } // Persistent-mode preflight runs BEFORE workspace.Create — a missing // tmux install must not leave a half-initialized workspace on disk. tmuxPath, err := newPersistentPreflight(newDirect) if err != nil { return err } // Task 3: minimal wiring — the --agent type-selector rework lands in // Task 4. For now the resolved type drives both the workspace AgentSpec // and the launch-time agent.Resolve. agentType := newAgent if agentType == "" { agentType = config.ResolveAgent() } title := "" if len(args) > 0 { title = args[0] } // Determine category default and explicit-ness. categoryExplicit := cmd.Flags().Changed("category") category := newCategory if !categoryExplicit { if newProject { category = "projects" } else { category = "general" } } // Determine workspace root + skip-category behavior. taskRoot := config.ResolveRoot() root := taskRoot skipCategoryDir := false if newProject { if override := config.ResolveProjectRoot(); override != "" { root = override if !categoryExplicit { skipCategoryDir = true } } } opts := workspace.CreateOpts{ Root: root, Title: title, Category: category, Mode: "local", AgentSpec: workspace.AgentSpec{Type: agentType}, IsProject: newProject, SeedDir: config.ResolveSeedDir(), SkipCategoryDir: skipCategoryDir, } if newProject { opts.ProjectSeedDir = config.ResolveProjectSeedDir() } ws, err := workspace.Create(opts) if err != nil { return err } if newProject { if !workspace.GitAvailable() { fmt.Println("[ctask] git not found; skipped repository initialization") } else { if err := workspace.RunGitInit(ws.Path); err != nil { fmt.Fprintf(os.Stderr, "[ctask] warning: %v\n", err) } } // Seed-wins: EnsureGitignore is a no-op when a seed provided .gitignore. if err := workspace.EnsureGitignore(ws.Path); err != nil { fmt.Fprintf(os.Stderr, "[ctask] warning: failed to create .gitignore: %v\n", err) } } relPath := workspace.RelativePath(root, ws.Path) fmt.Printf("[ctask] created %s\n", relPath) if newNoLaunch { return nil } resolved, err := agent.Resolve(workspace.AgentSpec{Type: agentType}, agentType) if err != nil { return err } // Re-set the workspace's root: workspace.Create returned ws but our // computed `root` is what should be exported via CTASK_ROOT (it may // differ from ws-derived defaults when --project + CTASK_PROJECT_ROOT // are in play). return runWorkspaceEntry(WorkspaceEntryOptions{ WsPath: ws.Path, WsRoot: root, WsMeta: ws.Meta, ResolvedAgent: resolved, Shell: newShell, Direct: newDirect, CommandName: "new", TmuxPath: tmuxPath, NewlyCreated: true, }) } // newPersistentPreflight runs the persistent-mode preflight for `ctask new`, // returning the validated tmux path on success or "" when persistent mode // is not in effect (or --direct was passed). When persistent mode IS in // effect and --direct was passed, prints the bypass warning and returns // ("", nil) — the workspace can still be created in direct mode. // // `new` is the only command where preflight runs *before* the workspace // exists; a tmux failure must not leave a half-initialized directory on // disk. (resume / last / open / attach run preflight inside // runWorkspaceEntry, after their own resolution step.) func newPersistentPreflight(directFlag bool) (string, error) { mode := config.ResolveSessionMode() if mode != "persistent" { return "", nil } if directFlag { fmt.Fprintln(os.Stderr, "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") return "", nil } return preflightPersistentEntry("new") }