Unify session-aware CLI parsing and add session-id compatibility

This commit is contained in:
2026-05-23 13:47:39 +00:00
parent 96b886e711
commit a6b0c33e9f
9 changed files with 137 additions and 307 deletions

View File

@@ -16,7 +16,6 @@ import (
// Clean removes local workspace/spool state while preserving durable cache // Clean removes local workspace/spool state while preserving durable cache
// state unless cache cleanup is explicitly requested. // state unless cache cleanup is explicitly requested.
func Clean(ctx context.Context, args []string, out io.Writer) error { func Clean(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("clean", flag.ContinueOnError) fs := flag.NewFlagSet("clean", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -27,20 +26,8 @@ func Clean(ctx context.Context, args []string, out io.Writer) error {
fs.BoolVar(&all, "all", false, "clean all local session work/spool state") fs.BoolVar(&all, "all", false, "clean all local session work/spool state")
fs.BoolVar(&dryRun, "dry-run", false, "print cleanup targets without deleting") fs.BoolVar(&dryRun, "dry-run", false, "print cleanup targets without deleting")
fs.BoolVar(&clearCache, "clear-cache", false, "also clear durable S3 audio cache entries") fs.BoolVar(&clearCache, "clear-cache", false, "also clear durable S3 audio cache entries")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("clean", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("clean: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("clean", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("clean: unexpected positional arguments")
}
if err := applyPositionalSessionID("clean", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if all { if all {
return cleanAllLocal(flags, dryRun, clearCache, out) return cleanAllLocal(flags, dryRun, clearCache, out)

View File

@@ -47,6 +47,7 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
} }
@@ -111,25 +112,12 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error {
// SessionValidate performs a read-only session preflight. // SessionValidate performs a read-only session preflight.
func SessionValidate(ctx context.Context, args []string, out io.Writer) error { func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session validate", flag.ContinueOnError) fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("session validate", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("session validate: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("session validate", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("session validate: unexpected positional arguments")
}
if err := applyPositionalSessionID("session validate", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("session validate: session_id is required") return fmt.Errorf("session validate: session_id is required")
@@ -193,25 +181,12 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
// Status reports effective local/remote session state. // Status reports effective local/remote session state.
func Status(ctx context.Context, args []string, out io.Writer) error { func Status(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("status", flag.ContinueOnError) fs := flag.NewFlagSet("status", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("status", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("status: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("status", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("status: unexpected positional arguments")
}
if err := applyPositionalSessionID("status", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("status: session_id is required") return fmt.Errorf("status: session_id is required")
@@ -282,7 +257,6 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
// SessionInit creates a local or remote session.yml skeleton. // SessionInit creates a local or remote session.yml skeleton.
func SessionInit(ctx context.Context, args []string, out io.Writer) error { func SessionInit(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
@@ -290,6 +264,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "campaign ID") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date") fs.StringVar(&date, "date", "", "session date")
fs.StringVar(&title, "title", "", "session title") fs.StringVar(&title, "title", "", "session title")
@@ -298,20 +273,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory") fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
fs.BoolVar(&force, "force", false, "overwrite existing target") fs.BoolVar(&force, "force", false, "overwrite existing target")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
return fmt.Errorf("session init: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("session init", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("session init: unexpected positional arguments")
}
if err := applyPositionalSessionID("session init", positionalSessionID, &sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("session init: session_id is required") return fmt.Errorf("session init: session_id is required")
@@ -405,27 +368,14 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
// ArtifactsList lists effective artifact sources. // ArtifactsList lists effective artifact sources.
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError) fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
var remote bool var remote bool
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
fs.BoolVar(&remote, "remote", false, "inspect remote publish availability") fs.BoolVar(&remote, "remote", false, "inspect remote publish availability")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("artifacts list", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("artifacts list: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("artifacts list", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("artifacts list: unexpected positional arguments")
}
if err := applyPositionalSessionID("artifacts list", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("artifacts list: session_id is required") return fmt.Errorf("artifacts list: session_id is required")
@@ -463,25 +413,12 @@ func Locks(ctx context.Context, args []string, out io.Writer) error {
// LocksList lists effective publish locks. // LocksList lists effective publish locks.
func LocksList(ctx context.Context, args []string, out io.Writer) error { func LocksList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs := flag.NewFlagSet("locks", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("locks", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("locks: invalid flags: %w", err) return err
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("locks", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("locks: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks: session_id is required") return fmt.Errorf("locks: session_id is required")
@@ -515,11 +452,18 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("locks add: invalid flags: %w", err) return fmt.Errorf("locks add: invalid flags: %w", err)
} }
if source == "" { if source == "" {
if fs.NArg() != 2 { switch fs.NArg() {
case 2:
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks add: expected session_id and source id")
}
source = strings.TrimSpace(fs.Arg(0))
default:
return fmt.Errorf("locks add: expected session_id and source id") return fmt.Errorf("locks add: expected session_id and source id")
} }
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
} else if fs.NArg() != 0 { } else if fs.NArg() != 0 {
return fmt.Errorf("locks add: unexpected positional arguments") return fmt.Errorf("locks add: unexpected positional arguments")
} }
@@ -572,11 +516,18 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("locks remove: invalid flags: %w", err) return fmt.Errorf("locks remove: invalid flags: %w", err)
} }
if source == "" { if source == "" {
if fs.NArg() != 2 { switch fs.NArg() {
case 2:
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks remove: expected session_id and source id")
}
source = strings.TrimSpace(fs.Arg(0))
default:
return fmt.Errorf("locks remove: expected session_id and source id") return fmt.Errorf("locks remove: expected session_id and source id")
} }
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
} else if fs.NArg() != 0 { } else if fs.NArg() != 0 {
return fmt.Errorf("locks remove: unexpected positional arguments") return fmt.Errorf("locks remove: unexpected positional arguments")
} }

View File

@@ -7,7 +7,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -17,46 +16,21 @@ import (
// Plan validates configuration, prepares the local workdir, and prints stage order. // Plan validates configuration, prepares the local workdir, and prints stage order.
func Plan(ctx context.Context, args []string, out io.Writer) error { func Plan(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("plan", flag.ContinueOnError) fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("plan: invalid flags: %w", err) return err
} }
if positionalSessionID == "" { if flags.sessionID == "" {
if err := applyParsedSessionIDArg("plan", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("plan: unexpected positional arguments")
}
if err := applyPositionalSessionID("plan", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("plan: session_id is required") return fmt.Errorf("plan: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil { if err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)
} }

View File

@@ -27,20 +27,11 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(out) fs.SetOutput(out)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var dryRun bool var dryRun bool
var force bool var force bool
var includeAudio bool var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
@@ -57,25 +48,13 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
} }
return fmt.Errorf("restore: invalid flags: %w", err) return fmt.Errorf("restore: invalid flags: %w", err)
} }
if positionalSessionID == "" { if err := resolveParsedSessionID("restore", positionalSessionID, fs, &flags.sessionID); err != nil {
if err := applyParsedSessionIDArg("restore", fs, &sessionID); err != nil { return err
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("restore: unexpected positional arguments")
}
if err := applyPositionalSessionID("restore", positionalSessionID, &sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("restore: session_id is required") return fmt.Errorf("restore: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil { if err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)
} }

View File

@@ -5,7 +5,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -14,48 +13,23 @@ import (
// Resume continues execution from the first non-succeeded stage in the manifest. // Resume continues execution from the first non-succeeded stage in the manifest.
func Resume(ctx context.Context, args []string, out io.Writer) error { func Resume(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("resume", flag.ContinueOnError) fs := flag.NewFlagSet("resume", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution") fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("resume", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("resume: invalid flags: %w", err) return err
} }
if positionalSessionID == "" { if flags.sessionID == "" {
if err := applyParsedSessionIDArg("resume", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("resume: unexpected positional arguments")
}
if err := applyPositionalSessionID("resume", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("resume: session_id is required") return fmt.Errorf("resume: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil { if err != nil {
return fmt.Errorf("resume: %w", err) return fmt.Errorf("resume: %w", err)
} }

View File

@@ -5,55 +5,29 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
// Run executes the pipeline plan and persists manifest state. // Run executes the pipeline plan and persists manifest state.
func Run(ctx context.Context, args []string, out io.Writer) error { func Run(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("run", flag.ContinueOnError) fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("run: invalid flags: %w", err) return err
} }
if positionalSessionID == "" { if flags.sessionID == "" {
if err := applyParsedSessionIDArg("run", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("run: unexpected positional arguments")
}
if err := applyPositionalSessionID("run", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("run: session_id is required") return fmt.Errorf("run: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }

View File

@@ -23,19 +23,10 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError) fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)")
@@ -47,16 +38,21 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
case 2: case 2:
stageName = strings.TrimSpace(fs.Arg(0)) stageName = strings.TrimSpace(fs.Arg(0))
positionalSessionID = strings.TrimSpace(fs.Arg(1)) positionalSessionID = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("run-stage: expected stage name and session_id")
}
stageName = strings.TrimSpace(fs.Arg(0))
default: default:
return fmt.Errorf("run-stage: expected stage name and session_id") return fmt.Errorf("run-stage: expected stage name and session_id")
} }
} else if fs.NArg() != 0 { } else if fs.NArg() != 0 {
return fmt.Errorf("run-stage: unexpected positional arguments") return fmt.Errorf("run-stage: unexpected positional arguments")
} }
if err := applyPositionalSessionID("run-stage", positionalSessionID, &sessionID); err != nil { if err := applyPositionalSessionID("run-stage", positionalSessionID, &flags.sessionID); err != nil {
return err return err
} }
if strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("run-stage: session_id is required") return fmt.Errorf("run-stage: session_id is required")
} }
normalizedArtifacts, err := selectedArtifacts.Normalize() normalizedArtifacts, err := selectedArtifacts.Normalize()
@@ -70,12 +66,12 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
summary, err := runSingleStageCommand(ctx, singleStageCommand{ summary, err := runSingleStageCommand(ctx, singleStageCommand{
CommandName: "run-stage", CommandName: "run-stage",
StageName: stageName, StageName: stageName,
PipelinePath: pipelinePath, PipelinePath: flags.pipelinePath,
CampaignPath: campaignPath, CampaignPath: flags.campaignPath,
CampaignFilePath: campaignFilePath, CampaignFilePath: flags.campaignFilePath,
SessionPath: sessionPath, SessionPath: flags.sessionPath,
SessionID: sessionID, SessionID: flags.sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: flags.previousSessionID,
Force: force, Force: force,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
}) })
@@ -97,40 +93,18 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
// Analyze force-runs the analyze stage. // Analyze force-runs the analyze stage.
func Analyze(ctx context.Context, args []string, out io.Writer) error { func Analyze(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("analyze", flag.ContinueOnError) fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("analyze", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("analyze: invalid flags: %w", err) return err
} }
if positionalSessionID == "" { if strings.TrimSpace(flags.sessionID) == "" {
if err := applyParsedSessionIDArg("analyze", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("analyze: unexpected positional arguments")
}
if err := applyPositionalSessionID("analyze", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("analyze: session_id is required") return fmt.Errorf("analyze: session_id is required")
} }
normalizedArtifacts, err := selectedArtifacts.Normalize() normalizedArtifacts, err := selectedArtifacts.Normalize()
@@ -141,12 +115,12 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
summary, err := runSingleStageCommand(ctx, singleStageCommand{ summary, err := runSingleStageCommand(ctx, singleStageCommand{
CommandName: "analyze", CommandName: "analyze",
StageName: "analyze", StageName: "analyze",
PipelinePath: pipelinePath, PipelinePath: flags.pipelinePath,
CampaignPath: campaignPath, CampaignPath: flags.campaignPath,
CampaignFilePath: campaignFilePath, CampaignFilePath: flags.campaignFilePath,
SessionPath: sessionPath, SessionPath: flags.sessionPath,
SessionID: sessionID, SessionID: flags.sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: flags.previousSessionID,
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
}) })
@@ -166,40 +140,18 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
// Publish force-runs the publish stage. // Publish force-runs the publish stage.
func Publish(ctx context.Context, args []string, out io.Writer) error { func Publish(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("publish", flag.ContinueOnError) fs := flag.NewFlagSet("publish", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var flags commonConfigFlags
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") addCommonConfigFlags(fs, &flags)
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := parseSessionAwareFlags("publish", fs, args, &flags.sessionID); err != nil {
return fmt.Errorf("publish: invalid flags: %w", err) return err
} }
if positionalSessionID == "" { if strings.TrimSpace(flags.sessionID) == "" {
if err := applyParsedSessionIDArg("publish", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("publish: unexpected positional arguments")
}
if err := applyPositionalSessionID("publish", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("publish: session_id is required") return fmt.Errorf("publish: session_id is required")
} }
normalizedArtifacts, err := selectedArtifacts.Normalize() normalizedArtifacts, err := selectedArtifacts.Normalize()
@@ -210,12 +162,12 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
summary, err := runSingleStageCommand(ctx, singleStageCommand{ summary, err := runSingleStageCommand(ctx, singleStageCommand{
CommandName: "publish", CommandName: "publish",
StageName: "publish", StageName: "publish",
PipelinePath: pipelinePath, PipelinePath: flags.pipelinePath,
CampaignPath: campaignPath, CampaignPath: flags.campaignPath,
CampaignFilePath: campaignFilePath, CampaignFilePath: flags.campaignFilePath,
SessionPath: sessionPath, SessionPath: flags.sessionPath,
SessionID: sessionID, SessionID: flags.sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: flags.previousSessionID,
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
}) })

View File

@@ -41,3 +41,21 @@ func applyParsedSessionIDArg(command string, fs *flag.FlagSet, sessionID *string
return fmt.Errorf("%s: unexpected positional arguments", command) return fmt.Errorf("%s: unexpected positional arguments", command)
} }
} }
func resolveParsedSessionID(command, positionalSessionID string, fs *flag.FlagSet, sessionID *string) error {
if strings.TrimSpace(positionalSessionID) == "" {
return applyParsedSessionIDArg(command, fs, sessionID)
}
if fs.NArg() != 0 {
return fmt.Errorf("%s: unexpected positional arguments", command)
}
return applyPositionalSessionID(command, positionalSessionID, sessionID)
}
func parseSessionAwareFlags(command string, fs *flag.FlagSet, args []string, sessionID *string) error {
positionalSessionID, args := pullLeadingSessionID(args)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%s: invalid flags: %w", command, err)
}
return resolveParsedSessionID(command, positionalSessionID, fs, sessionID)
}

View File

@@ -71,15 +71,36 @@ func TestExecutePositionalSessionIDMismatchFails(t *testing.T) {
} }
} }
func TestExecuteSessionIDFlagFails(t *testing.T) { func TestExecuteSessionIDFlagMismatchFails(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr) code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
if !strings.Contains(stderr.String(), "flag provided but not defined: -session-id") { if !strings.Contains(stderr.String(), "does not match expected session id") {
t.Fatalf("stderr = %q, want invalid --session-id flag", stderr.String()) t.Fatalf("stderr = %q, want positional/flag mismatch", stderr.String())
}
}
func TestExecuteSessionIDFlagAcceptedWithoutPositional(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "status",
"--session-id", "2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Session: 2026-05-03") {
t.Fatalf("stdout = %q, want status output", stdout.String())
} }
} }