b75b82e676
LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are replaced by *agent.Resolved, carrying Command, Args, and Env. The five entry commands (new, resume, last, open, attach) each construct an AgentSpec from the workspace metadata, apply --agent as a one-shot agent.command override (Open Q 1 — keeps muscle memory for users passing executable paths), call agent.Resolve, and pass the result through. resolveEntryAgent centralises the resume/last/open/attach path. shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env is merged into the env map at the session.Run launch switch, AFTER ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision). mergeAgentEnv is the centralised merge. Lease, manifest, write lock, heartbeat, summary, and provisional cleanup are unchanged. The Agent string fields on Lease, SessionSummary, and SessionInfo continue to record the launched command for diagnostics.
178 lines
5.4 KiB
Go
178 lines
5.4 KiB
Go
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")
|
|
}
|