Files
ctask/cmd/new.go
T
typebasedio 103f2cd33e feat(v0.5): launch agent inside project subdirectory via launch_dir
LaunchOpts gains LaunchDir. session.Run resolves it via
workspace.ResolveLaunch, prints any fallback warning, and passes the
absolute path as the child process's working directory. Security
violations (absolute paths, .. escape) abort the session. The banner
gains a 'project dir: <name>/' line when launch_dir is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:49:29 -04:00

135 lines
3.6 KiB
Go

package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/session"
"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
)
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")
rootCmd.AddCommand(newCmd)
}
func runNew(cmd *cobra.Command, args []string) error {
if newContainer {
fmt.Println(shell.ContainerNotice())
return nil
}
agent := newAgent
if agent == "" {
agent = 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",
Agent: agent,
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
}
envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir)
return session.Run(session.LaunchOpts{
WsDir: ws.Path,
EnvVars: envVars,
Agent: agent,
Mode: ws.Meta.Mode,
Slug: ws.Meta.Slug,
Shell: newShell,
LaunchDir: ws.Meta.LaunchDir,
NewlyCreated: true,
})
}