Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions specstory-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
bago2k4 marked this conversation as resolved.

if len(resolvedIDs) == 1 {
return syncSingleProvider(registry, resolvedIDs[0], cmd)
}
return syncAllProviders(registry, cmd, resolvedIDs)
},
}
}
Expand Down Expand Up @@ -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.

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says filterIDs, if non-nil, limits which providers are synced; nil means sync all, but the implementation uses len(filterIDs) > 0. An empty-but-non-nil slice will behave like nil (sync all), which contradicts the comment. Update the comment to match the actual behavior (e.g., “if non-empty”).

Suggested change
// filterIDs, if non-nil, limits which providers are synced; nil means sync all registered providers.
// filterIDs, if non-empty, limits which providers are synced; empty or nil means sync all registered providers.

Copilot uses AI. Check for mistakes.
func syncAllProviders(registry *factory.Registry, cmd *cobra.Command, filterIDs []string) error {
// Get debug-raw flag value
debugRaw, _ := cmd.Flags().GetBool("debug-raw")
useUTC := !localTimeZone
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 30 additions & 18 deletions specstory-cli/pkg/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider> -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 <provider> -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"}
}
}

Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions specstory-cli/pkg/cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -157,15 +164,19 @@ 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)
return err
}

providerIDs := registry.ListIDs()
if len(filterIDs) > 0 {
providerIDs = filterIDs
}
providersWithActivity := []string{}

for _, id := range providerIDs {
Expand Down
50 changes: 50 additions & 0 deletions specstory-cli/pkg/cmd/utils.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
bago2k4 marked this conversation as resolved.
}

// Neither specified: caller should use all providers
return nil, nil
}
Comment thread
bago2k4 marked this conversation as resolved.

// 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) {
Expand Down
153 changes: 153 additions & 0 deletions specstory-cli/pkg/cmd/utils_test.go
Original file line number Diff line number Diff line change
@@ -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,
},
Comment on lines +20 to +24

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “neither arg nor flag returns nil” case doesn’t actually assert that ResolveProviderIDs returns a nil slice; it only compares lengths, so nil and []string{} both pass. If callers rely on the documented “nil means use all providers” contract, consider explicitly checking ids == nil for that test case (and/or distinguishing nil vs empty in assertions).

Copilot generated this review using guidance from repository custom instructions.

// ── 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])
}
}
})
}
}
Loading
Loading