package cmd import ( "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/session" ) var infoCmd = &cobra.Command{ Use: "info ", Short: "Display metadata and path for a workspace", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: runInfo, } func init() { infoCmd.ValidArgsFunction = completeWorkspaces(completionAny) rootCmd.AddCommand(infoCmd) } func runInfo(cmd *cobra.Command, args []string) error { // v0.5.2: info is a read-only direct lookup. Always include archived // workspaces — if the user types a name, they want to find it whether // or not it's archived. The status field in the output makes the state // obvious. roots := config.SearchRoots() ws := resolveOne(roots, args[0], true) m := ws.Meta // v0.6: load the resolver once and reuse it across this info // invocation. The Agent line gains a workspace-vs-default source // label; a new Launch session mode line surfaces the configured // launch default with its own source attribution. resolver := config.LoadResolver() 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", agentLineWithSource(m.Agent, resolver)) fmt.Printf("Launch session mode: %s (%s)\n", resolver.SessionMode().Value, infoSourceLabel(resolver.SessionMode())) // v0.5.1: display timestamps in local time. task.yaml stores UTC; // info converts for friendliness so the shown time matches the user's // wall clock. fmt.Printf("Created: %s\n", m.CreatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Printf("Updated: %s\n", m.UpdatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Printf("Path: %s\n", ws.Path) printSessionBlock(ws.Path, m.Slug) if m.LaunchDir != "" { // Per spec amendment: stat the expected path directly instead of // inferring existence from ResolveLaunch's fallback behavior. info // is a display command, not a launch command — a permission error // here is the same user-facing outcome as "not a directory", so // we just report whether the stat succeeded with a directory. launchAbs := filepath.Join(ws.Path, m.LaunchDir) dirExists := "no" if info, err := os.Stat(launchAbs); err == nil && info.IsDir() { dirExists = "yes" } fmt.Printf("Launch dir: %s/\n", m.LaunchDir) fmt.Printf("Launch path: %s\n", launchAbs) fmt.Printf("Dir exists: %s\n", dirExists) } if m.ArchivedAt != nil { fmt.Printf("Archived: %s\n", m.ArchivedAt.Local().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 } // agentLineWithSource builds the value portion of info's Agent line // with v0.6 source attribution. When task.yaml has a non-empty agent // field, that value is workspace state and surfaces as // " (workspace)". When the field is empty (legacy or // hand-crafted task.yaml), the line falls through to the user-level // default chain and is labelled " (default)" plus the source // when the default did NOT come from the built-in. // // The fallback path is informational only: ctask new always writes // the resolved default into task.yaml, so the legacy branch never // fires for workspaces created by recent versions. func agentLineWithSource(workspaceAgent string, r *config.Resolver) string { if workspaceAgent != "" { return workspaceAgent + " (workspace)" } s := r.DefaultAgent() if s.Source == config.Builtin { return s.Value + " (default)" } return fmt.Sprintf("%s (default — %s)", s.Value, infoSourceLabel(s)) } // infoSourceLabel renders a setting's source for info output. Mirrors // formatSettingSource in doctor.go but without the override-chain // suffix; info lines are single-row and cannot fit the extra "(...)" // payload cleanly. Env-var sources surface their CTASK_X env-var name // (more specific than the bare "env var" label) so the user can spot // which shell variable to inspect. func infoSourceLabel(s config.ResolvedSetting) string { switch s.Source { case config.EnvVar: if s.EnvName != "" { return s.EnvName + " env var" } return "env var" case config.PlatformOverride: return "platform override" default: return s.Source.String() } } // printSessionBlock renders the v0.5.4 Session block for `ctask info`. // // Layout (values align at column 14 across the block): // // Session: // Mode: (omitted when malformed) // Owner: [host / ]pid N (Active; "Last owner:" when stale) // Attach: attach (Active + persistent only) // Note: (stale or malformed only) // // The hostname is omitted from the Owner/Last-owner line when it matches // the local machine, matching the spec's "omit when local" rule. // // All command-form text uses invocationName() so the hint reflects how // the user actually invoked the binary (./ctask vs ctask.exe vs ctask). // SessionStatus itself stays neutral and never builds a command string. func printSessionBlock(wsPath, slug string) { s := session.SessionStatus(wsPath) fmt.Println() switch s.State { case session.SessionStateNone: fmt.Println("Session: none") return case session.SessionStateStale: // Malformed lease: SessionStatus reports state=stale, mode empty, // diagnostic set. Render only the Note so we don't pretend to // know the mode/owner when the file couldn't be parsed. if s.Diagnostic != "" { fmt.Println("Session: stale") fmt.Printf(" Note: %s\n", s.Diagnostic) return } } fmt.Printf("Session: %s\n", s.State) fmt.Printf(" Mode: %s\n", s.Mode) ownerValue := fmt.Sprintf("pid %d", s.PID) if s.Hostname != "" && s.Hostname != session.CurrentHostname() { ownerValue = s.Hostname + " / " + ownerValue } if s.State == session.SessionStateActive { fmt.Printf(" Owner: %s\n", ownerValue) } else { fmt.Printf(" Last owner: %s\n", ownerValue) } if s.State == session.SessionStateActive && s.Mode == "persistent" { fmt.Printf(" Attach: %s attach %s\n", invocationName(), slug) } if s.State == session.SessionStateStale { fmt.Println(" Note: lease expired; workspace may be available") } }