diff --git a/specstory-cli/main.go b/specstory-cli/main.go index 894b0826..5f68a7f9 100644 --- a/specstory-cli/main.go +++ b/specstory-cli/main.go @@ -564,14 +564,16 @@ Provide a specific agent ID to sync a specific provider.` slog.Info("Running sync command") registry := factory.GetRegistry() - // Check if user specified a provider - if len(args) > 0 { - // Sync specific provider - return syncSingleProvider(registry, args[0], cmd) - } else { - // Sync all providers with activity - return syncAllProviders(registry, cmd) + providersFlag, _ := cmd.Flags().GetStringSlice("providers") + resolvedIDs, err := cmdpkg.ResolveProviderIDs(registry, args, providersFlag) + if err != nil { + return err } + + if len(resolvedIDs) == 1 { + return syncSingleProvider(registry, resolvedIDs[0], cmd) + } + return syncAllProviders(registry, cmd, resolvedIDs) }, } } @@ -1024,8 +1026,9 @@ func syncProvider(provider spi.Provider, providerID string, config utils.OutputC return sessionCount, nil } -// syncAllProviders syncs all providers that have activity in the current directory -func syncAllProviders(registry *factory.Registry, cmd *cobra.Command) error { +// syncAllProviders syncs all (or a filtered subset of) providers that have activity in the current directory +// filterIDs, if non-nil, limits which providers are synced; nil means sync all registered providers. +func syncAllProviders(registry *factory.Registry, cmd *cobra.Command, filterIDs []string) error { // Get debug-raw flag value debugRaw, _ := cmd.Flags().GetBool("debug-raw") useUTC := !localTimeZone @@ -1037,6 +1040,9 @@ func syncAllProviders(registry *factory.Registry, cmd *cobra.Command) error { } providerIDs := registry.ListIDs() + if len(filterIDs) > 0 { + providerIDs = filterIDs + } providersWithActivity := []string{} // Check each provider for activity @@ -1479,6 +1485,7 @@ func main() { syncCmd.Flags().StringVar(&telemetryEndpoint, "telemetry-endpoint", "", "Open Telemetry Protocol (OTLP) gRPC collector endpoint (default is off, e.g., localhost:4317)") syncCmd.Flags().StringVar(&telemetryServiceName, "telemetry-service-name", "", "override the default service name for telemetry, if telemetry is enabled") syncCmd.Flags().BoolVar(&noTelemetryPrompts, "no-telemetry-prompts", noTelemetryPrompts, "exclude prompt text from telemetry spans, if telemetry is enabled") + syncCmd.Flags().StringSlice("providers", []string{}, "comma-separated list of provider IDs to limit the operation to (e.g., claude,cursor)") runCmd.Flags().BoolVar(&provenanceEnabled, "provenance", false, "enable AI provenance tracking (correlate file changes to agent activity)") _ = runCmd.Flags().MarkHidden("provenance") // Hidden flag diff --git a/specstory-cli/pkg/cmd/check.go b/specstory-cli/pkg/cmd/check.go index 198cda18..fb1980f2 100644 --- a/specstory-cli/pkg/cmd/check.go +++ b/specstory-cli/pkg/cmd/check.go @@ -54,18 +54,28 @@ Specify a specific agent ID to check only a specific coding agent.`, // Get custom command if provided via flag customCmd, _ := cmd.Flags().GetString("command") + providersFlag, _ := cmd.Flags().GetStringSlice("providers") - // Validate that -c flag requires a provider - if customCmd != "" && len(args) == 0 { - registry := factory.GetRegistry() - ids := registry.ListIDs() - example := "specstory check -c \"/custom/path/to/agent\"" - if len(ids) > 0 { - example = fmt.Sprintf("specstory check %s -c \"/custom/path/to/agent\"", ids[0]) + resolvedIDs, err := ResolveProviderIDs(registry, args, providersFlag) + if err != nil { + return err + } + + // Validate that -c flag requires exactly one provider + if customCmd != "" { + if len(resolvedIDs) == 0 { + ids := registry.ListIDs() + example := "specstory check -c \"/custom/path/to/agent\"" + if len(ids) > 0 { + example = fmt.Sprintf("specstory check %s -c \"/custom/path/to/agent\"", ids[0]) + } + return utils.ValidationError{ + Message: "The -c/--command flag requires a provider to be specified.\n" + + "Example: " + example, + } } - return utils.ValidationError{ - Message: "The -c/--command flag requires a provider to be specified.\n" + - "Example: " + example, + if len(resolvedIDs) > 1 { + return utils.ValidationError{Message: "The -c/--command flag can only be used with a single provider ID"} } } @@ -74,12 +84,10 @@ Specify a specific agent ID to check only a specific coding agent.`, printDivider() var providerErr error - if len(args) == 0 { - // Check all providers - providerErr = checkAllProviders(registry) + if len(resolvedIDs) == 1 { + providerErr = checkSingleProvider(registry, resolvedIDs[0], customCmd) } else { - // Check specific provider - providerErr = checkSingleProvider(registry, args[0], customCmd) + providerErr = checkAllProviders(registry, resolvedIDs) } // Fail if either config or provider checks failed @@ -94,6 +102,7 @@ Specify a specific agent ID to check only a specific coding agent.`, } cmd.Flags().StringP("command", "c", "", "custom agent execution command for the provider") + cmd.Flags().StringSlice("providers", []string{}, "comma-separated list of provider IDs to limit the operation to (e.g., claude,cursor)") return cmd } @@ -152,10 +161,13 @@ func checkSingleProvider(registry *factory.Registry, providerID, customCmd strin } } -// checkAllProviders checks all registered providers -func checkAllProviders(registry *factory.Registry) error { - // Sort for consistent output +// checkAllProviders checks all (or a filtered subset of) registered providers. +// filterIDs, if non-empty, limits which providers are checked; nil or empty means check all. +func checkAllProviders(registry *factory.Registry, filterIDs []string) error { ids := registry.ListIDs() + if len(filterIDs) > 0 { + ids = filterIDs + } // Collect all provider names for analytics var providerNames []string diff --git a/specstory-cli/pkg/cmd/list.go b/specstory-cli/pkg/cmd/list.go index 6ba92a6e..f5f9e9ed 100644 --- a/specstory-cli/pkg/cmd/list.go +++ b/specstory-cli/pkg/cmd/list.go @@ -86,14 +86,21 @@ Provide a specific agent ID to list sessions from only that provider.` RunE: func(cmd *cobra.Command, args []string) error { slog.Info("Running list command") - if len(args) > 0 { - return listSingleProvider(registry, args[0]) + providersFlag, _ := cmd.Flags().GetStringSlice("providers") + resolvedIDs, err := ResolveProviderIDs(registry, args, providersFlag) + if err != nil { + return err } - return listAllProviders(registry) + + if len(resolvedIDs) == 1 { + return listSingleProvider(registry, resolvedIDs[0]) + } + return listAllProviders(registry, resolvedIDs) }, } cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON (default is human-readable table)") + cmd.Flags().StringSlice("providers", []string{}, "comma-separated list of provider IDs to limit the operation to (e.g., claude,cursor)") return cmd } @@ -157,8 +164,9 @@ func listSingleProvider(registry *factory.Registry, providerID string) error { return nil } -// listAllProviders lists sessions from all providers that have activity. -func listAllProviders(registry *factory.Registry) error { +// listAllProviders lists sessions from all (or a filtered subset of) providers that have activity. +// filterIDs, if non-empty, limits which providers are checked; nil or empty means check all registered providers. +func listAllProviders(registry *factory.Registry, filterIDs []string) error { cwd, err := os.Getwd() if err != nil { slog.Error("Failed to get current working directory", "error", err) @@ -166,6 +174,9 @@ func listAllProviders(registry *factory.Registry) error { } providerIDs := registry.ListIDs() + if len(filterIDs) > 0 { + providerIDs = filterIDs + } providersWithActivity := []string{} for _, id := range providerIDs { diff --git a/specstory-cli/pkg/cmd/utils.go b/specstory-cli/pkg/cmd/utils.go index 156ed467..293a4ac0 100644 --- a/specstory-cli/pkg/cmd/utils.go +++ b/specstory-cli/pkg/cmd/utils.go @@ -1,12 +1,62 @@ package cmd import ( + "fmt" "log/slog" + "strings" "github.com/specstoryai/getspecstory/specstory-cli/pkg/cloud" "github.com/specstoryai/getspecstory/specstory-cli/pkg/log" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/factory" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/utils" ) +// ResolveProviderIDs resolves the effective list of provider IDs from a positional +// arg and/or --providers flag. Returns nil to indicate "use all providers" when +// neither is specified. Returns an error if both are specified simultaneously or +// if a provider ID in --providers is invalid. +func ResolveProviderIDs(registry *factory.Registry, args []string, providersFlag []string) ([]string, error) { + hasPositionalArg := len(args) > 0 + hasProvidersFlag := len(providersFlag) > 0 + + if hasPositionalArg && hasProvidersFlag { + return nil, utils.ValidationError{Message: "cannot use both a positional provider argument and --providers flag; use one or the other"} + } + + if hasPositionalArg { + // Return without validating — callers handle validation with tailored error messages + return []string{args[0]}, nil + } + + if hasProvidersFlag { + ids := make([]string, 0, len(providersFlag)) + seen := make(map[string]bool, len(providersFlag)) + for _, id := range providersFlag { + id = strings.TrimSpace(strings.ToLower(id)) + if id == "" { + continue + } + if _, err := registry.Get(id); err != nil { + return nil, utils.ValidationError{ + Message: fmt.Sprintf("'%s' is not a valid provider ID.\nAvailable providers: %s", id, registry.GetProviderList()), + } + } + // Deduplicate while preserving the order of first occurrence + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, utils.ValidationError{Message: "--providers requires at least one provider ID"} + } + return ids, nil + } + + // Neither specified: caller should use all providers + return nil, nil +} + // CheckAndWarnAuthentication warns the user if cloud sync is enabled but authentication // is missing or has failed. Uses log.IsSilent() to respect silent mode. func CheckAndWarnAuthentication(noCloudSync bool) { diff --git a/specstory-cli/pkg/cmd/utils_test.go b/specstory-cli/pkg/cmd/utils_test.go new file mode 100644 index 00000000..403ff26a --- /dev/null +++ b/specstory-cli/pkg/cmd/utils_test.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/factory" +) + +func TestResolveProviderIDs(t *testing.T) { + registry := factory.GetRegistry() + + tests := []struct { + name string + args []string + providersFlag []string + wantIDs []string // nil means "all providers" + wantErrSubstr string // non-empty means an error is expected containing this substring + }{ + // ── Neither specified ─────────────────────────────────────────────────── + { + name: "neither arg nor flag returns nil", + wantIDs: nil, + }, + + // ── Positional arg ────────────────────────────────────────────────────── + { + name: "positional arg returned as-is without validation", + args: []string{"claude"}, + wantIDs: []string{"claude"}, + }, + { + // Callers handle validation for positional args, so even unknown values + // should pass through. + name: "unknown positional arg passed through without error", + args: []string{"unknown-provider"}, + wantIDs: []string{"unknown-provider"}, + }, + + // ── Conflict ──────────────────────────────────────────────────────────── + { + name: "positional arg and providers flag together is an error", + args: []string{"claude"}, + providersFlag: []string{"codex"}, + wantErrSubstr: "cannot use both", + }, + + // ── --providers flag: happy paths ──────────────────────────────────────── + { + name: "single valid provider", + providersFlag: []string{"claude"}, + wantIDs: []string{"claude"}, + }, + { + name: "multiple valid providers preserves order", + providersFlag: []string{"codex", "claude"}, + wantIDs: []string{"codex", "claude"}, + }, + { + name: "mixed case is normalised to lower", + providersFlag: []string{"Claude", "CODEX"}, + wantIDs: []string{"claude", "codex"}, + }, + { + name: "leading and trailing whitespace is trimmed", + providersFlag: []string{" claude ", " codex"}, + wantIDs: []string{"claude", "codex"}, + }, + + // ── Deduplication ──────────────────────────────────────────────────────── + { + name: "exact duplicate is removed keeping first occurrence", + providersFlag: []string{"claude", "codex", "claude"}, + wantIDs: []string{"claude", "codex"}, + }, + { + name: "case-variant duplicate is removed after normalisation", + providersFlag: []string{"Claude", "claude"}, + wantIDs: []string{"claude"}, + }, + { + name: "whitespace-variant duplicate is removed after trimming", + providersFlag: []string{"claude", " claude "}, + wantIDs: []string{"claude"}, + }, + { + name: "all duplicates collapsed to single entry", + providersFlag: []string{"gemini", "GEMINI", " gemini "}, + wantIDs: []string{"gemini"}, + }, + { + name: "three providers with one duplicate preserves remaining order", + providersFlag: []string{"cursor", "claude", "cursor", "codex"}, + wantIDs: []string{"cursor", "claude", "codex"}, + }, + + // ── Empty / blank entries ───────────────────────────────────────────────── + { + name: "blank entries in flag slice are silently skipped", + providersFlag: []string{"", " ", "claude"}, + wantIDs: []string{"claude"}, + }, + { + name: "only blank entries is an error", + providersFlag: []string{"", " "}, + wantErrSubstr: "--providers requires at least one", + }, + + // ── Invalid provider ID ─────────────────────────────────────────────────── + { + name: "unknown provider ID is an error", + providersFlag: []string{"notaprovider"}, + wantErrSubstr: "not a valid provider ID", + }, + { + name: "unknown provider mixed with valid is still an error", + providersFlag: []string{"claude", "notaprovider"}, + wantErrSubstr: "not a valid provider ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ids, err := ResolveProviderIDs(registry, tt.args, tt.providersFlag) + + if tt.wantErrSubstr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErrSubstr) + return + } + if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErrSubstr) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(ids) != len(tt.wantIDs) { + t.Errorf("got %v, want %v", ids, tt.wantIDs) + return + } + for i := range tt.wantIDs { + if ids[i] != tt.wantIDs[i] { + t.Errorf("ids[%d] = %q, want %q", i, ids[i], tt.wantIDs[i]) + } + } + }) + } +} diff --git a/specstory-cli/pkg/cmd/watch.go b/specstory-cli/pkg/cmd/watch.go index 81bab5a2..e2046a30 100644 --- a/specstory-cli/pkg/cmd/watch.go +++ b/specstory-cli/pkg/cmd/watch.go @@ -138,12 +138,18 @@ By default, 'watch' is for activity from all registered agent providers. Specify } defer provenanceCleanup() - providerIDs := registry.ListIDs() - if len(providerIDs) == 0 { + if len(registry.ListIDs()) == 0 { return fmt.Errorf("no providers registered") } - if len(args) > 0 { - providerIDs = []string{args[0]} + + providersFlag, _ := cmd.Flags().GetStringSlice("providers") + resolvedIDs, err := ResolveProviderIDs(registry, args, providersFlag) + if err != nil { + return err + } + providerIDs := registry.ListIDs() + if len(resolvedIDs) > 0 { + providerIDs = resolvedIDs } // Collect provider names for analytics @@ -329,6 +335,7 @@ By default, 'watch' is for activity from all registered agent providers. Specify watchCmd.Flags().String("telemetry-endpoint", "", "Open Telemetry Protocol (OTLP) gRPC collector endpoint (default is off, e.g., localhost:4317)") watchCmd.Flags().String("telemetry-service-name", "", "override the default service name for telemetry, if telemetry is enabled") watchCmd.Flags().Bool("no-telemetry-prompts", false, "exclude prompt text from telemetry spans, if telemetry is enabled") + watchCmd.Flags().StringSlice("providers", []string{}, "comma-separated list of provider IDs to limit the operation to (e.g., claude,cursor)") return watchCmd }