diff --git a/acceptance/sidecar_snapshot_test.go b/acceptance/sidecar_snapshot_test.go index 5571f485..3e8b6711 100644 --- a/acceptance/sidecar_snapshot_test.go +++ b/acceptance/sidecar_snapshot_test.go @@ -220,6 +220,7 @@ func TestSidecarSnapshotMissingToken(t *testing.T) { }{ {"create", []string{"sidecar", "snapshot", "create", "--sidecar-id", "sb-111", "--name", "snap"}}, {"get", []string{"sidecar", "snapshot", "get", "snap-abc"}}, + {"list", []string{"sidecar", "snapshot", "list", "--org-id", "org-abc"}}, } for _, tt := range tests { @@ -232,3 +233,42 @@ func TestSidecarSnapshotMissingToken(t *testing.T) { }) } } + +func TestSidecarSnapshotListHappyPath(t *testing.T) { + cci := fakes.NewFakeCircleCI() + cci.Snapshots = []fakes.Snapshot{ + {ID: "snap-1", OrgID: "org-abc", Name: "baseline"}, + {ID: "snap-2", OrgID: "org-abc", Name: "with-deps"}, + } + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "sidecar", "snapshot", "list", "--org-id", "org-abc", + }, env, env.HomeDir) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stdout, "baseline"), "expected 'baseline' in output: %s", result.Stdout) + assert.Assert(t, strings.Contains(result.Stdout, "snap-1"), "expected 'snap-1' in output: %s", result.Stdout) + assert.Assert(t, strings.Contains(result.Stdout, "with-deps"), "expected 'with-deps' in output: %s", result.Stdout) + assert.Assert(t, strings.Contains(result.Stdout, "snap-2"), "expected 'snap-2' in output: %s", result.Stdout) +} + +func TestSidecarSnapshotListEmpty(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "sidecar", "snapshot", "list", "--org-id", "org-abc", + }, env, env.HomeDir) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stderr, "No snapshots found"), "expected 'No snapshots found' in stderr: %s", result.Stderr) +} diff --git a/internal/circleci/client.go b/internal/circleci/client.go index 5939b24e..c1fac6db 100644 --- a/internal/circleci/client.go +++ b/internal/circleci/client.go @@ -146,6 +146,18 @@ func (c *Client) GetSnapshot(ctx context.Context, id string) (*Snapshot, error) return &resp, nil } +func (c *Client) ListSnapshots(ctx context.Context, orgID string) ([]Snapshot, error) { + var resp listSnapshotsResponse + _, err := c.cl.Call(ctx, hc.NewRequest(http.MethodGet, "/api/v2/sidecar/snapshots", + hc.QueryParam("org_id", orgID), + hc.JSONDecoder(&resp), + )) + if err != nil { + return nil, mapErr("list snapshots", err) + } + return resp.Items, nil +} + func (c *Client) TriggerRun(ctx context.Context, orgID, projectID string, body TriggerRunRequest) (*RunResponse, error) { var resp RunResponse _, err := c.cl.Call(ctx, hc.NewRequest(http.MethodPost, "/api/v2/agents/org/%s/project/%s/runs", diff --git a/internal/circleci/types.go b/internal/circleci/types.go index b5a2987f..197df300 100644 --- a/internal/circleci/types.go +++ b/internal/circleci/types.go @@ -11,6 +11,10 @@ type listSidecarsResponse struct { Items []Sidecar `json:"items"` } +type listSnapshotsResponse struct { + Items []Snapshot `json:"items"` +} + type ExecRequest struct { Command string `json:"command"` Args []string `json:"args,omitempty"` diff --git a/internal/cmd/sidecar.go b/internal/cmd/sidecar.go index 987e3287..7ff7df27 100644 --- a/internal/cmd/sidecar.go +++ b/internal/cmd/sidecar.go @@ -28,6 +28,8 @@ func randomSidecarName() string { return petname.Generate(3, "-") } +const cmdList = "list" + func newSidecarCmd() *cobra.Command { cmd := &cobra.Command{ Use: "sidecar", @@ -123,7 +125,7 @@ func newSidecarListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List sidecars", RunE: func(cmd *cobra.Command, _ []string) error { io := iostream.FromCmd(cmd) @@ -663,6 +665,7 @@ func newSidecarSnapshotCmd() *cobra.Command { } cmd.AddCommand(newSidecarSnapshotCreateCmd()) cmd.AddCommand(newSidecarSnapshotGetCmd()) + cmd.AddCommand(newSidecarSnapshotListCmd()) return cmd } @@ -753,6 +756,51 @@ func newSidecarSnapshotGetCmd() *cobra.Command { return cmd } +func newSidecarSnapshotListCmd() *cobra.Command { + var orgID string + var jsonOut bool + + cmd := &cobra.Command{ + Use: cmdList, + Short: "List snapshots", + RunE: func(cmd *cobra.Command, _ []string) error { + io := iostream.FromCmd(cmd) + client, err := ensureCircleCIClient(cmd.Context(), io, tui.PromptHidden) + if err != nil { + return err + } + resolvedOrgID, err := resolveOrgID(orgID, orgPicker(cmd.Context(), client)) + if err != nil { + return err + } + snapshots, err := client.ListSnapshots(cmd.Context(), resolvedOrgID) + if err != nil { + return &userError{ + msg: "Could not list snapshots.", + suggestion: suggestionNetworkRetry, + err: err, + } + } + if jsonOut { + return iostream.PrintJSON(io.Out, snapshots) + } + if len(snapshots) == 0 { + io.ErrPrintln(ui.Dim("No snapshots found")) + return nil + } + for _, s := range snapshots { + io.Printf("%s %s\n", s.Name, s.ID) + } + return nil + }, + } + + cmd.Flags().StringVar(&orgID, "org-id", "", "Organization ID") + cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") + + return cmd +} + func newSidecarSetupCmd() *cobra.Command { var sidecarID, orgID, name, identityFile, dir string var skipSync, force bool diff --git a/internal/cmd/skills.go b/internal/cmd/skills.go index fcc441ec..cd1da9af 100644 --- a/internal/cmd/skills.go +++ b/internal/cmd/skills.go @@ -69,7 +69,7 @@ func newSkillListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List bundled skills and their per-agent installation status", RunE: func(cmd *cobra.Command, _ []string) error { home := os.Getenv(config.EnvHome) diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index 99dd92d5..1672b279 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -77,6 +77,52 @@ func runValidateList(workDir string, jsonOut bool, streams iostream.Streams, sta return validate.List(cfg, statusFn) } +// applyHookContext sets up the context and streams for hook invocations. +// It returns the updated context and streams, and resets per-session state +// when the hook is running for the first time (not a retry). +func applyHookContext(ctx context.Context, hook *hookContext, streams iostream.Streams) (context.Context, iostream.Streams) { + ctx = session.WithID(ctx, hook.sessionID) + if !hook.stopHookActive { + validate.ResetAttempts(hook.sessionID) + } + // Route stdout to stderr so all output appears in the Stop + // hook feedback block that Claude Code shows the agent. + streams = iostream.Streams{Out: streams.Err, Err: streams.Err} + return ctx, streams +} + +// checkHookEarlyExit returns true when a hook invocation should exit without +// running validation, along with the error (if any) to return to the caller. +func checkHookEarlyExit(workDir string, streams iostream.Streams) (bool, error) { + envDisabled := os.Getenv(config.EnvChunkHooksDisabled) != "" + if validate.HooksDisabled(workDir, envDisabled) { + streams.ErrPrintln("chunk validate: hooks are disabled — skipping validation") + return true, validate.NewHookExitError(1) + } + if !validate.HasGitChanges(workDir) { + return true, nil + } + return false, nil +} + +// resolveSidecarForRun sets up the sidecar ID for the validate run and +// returns whether a new sidecar was freshly created. +func resolveSidecarForRun(ctx context.Context, remote bool, sidecarID *string, orgID, image, workDir string, hook *hookContext, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) (bool, error) { + if remote { + // --remote: force all commands to sidecar, creating one if needed. + freshlyCreated, err := resolveOrCreateSidecarID(ctx, sidecarID, orgID, image, workDir, streams) + if err != nil { + return false, err + } + statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", *sidecarID)) + return freshlyCreated, nil + } + if cfg.HasRemoteCommands() { + return resolveSidecar(ctx, sidecarID, orgID, image, workDir, hook, streams), nil + } + return false, nil +} + func newValidateCmd() *cobra.Command { var sidecarID, identityFile, workdir, orgID string var dryRun, list, save, remote, jsonOut bool @@ -102,26 +148,14 @@ func newValidateCmd() *cobra.Command { hook := detectHook(cmd.InOrStdin()) ctx := cmd.Context() if hook != nil { - ctx = session.WithID(ctx, hook.sessionID) - if !hook.stopHookActive { - validate.ResetAttempts(hook.sessionID) - } - // Route stdout to stderr so all output appears in the Stop - // hook feedback block that Claude Code shows the agent. - streams = iostream.Streams{Out: streams.Err, Err: streams.Err} + ctx, streams = applyHookContext(ctx, hook, streams) } statusFn := newStatusFunc(streams) - // Hook: exit 1 with a message when hooks are disabled. - envDisabled := os.Getenv(config.EnvChunkHooksDisabled) != "" - if hook != nil && validate.HooksDisabled(workDir, envDisabled) { - streams.ErrPrintln("chunk validate: hooks are disabled — skipping validation") - return validate.NewHookExitError(1) - } - - // Hook: skip entirely when the working tree is clean. - if hook != nil && !validate.HasGitChanges(workDir) { - return nil + if hook != nil { + if shouldExit, exitErr := checkHookEarlyExit(workDir, streams); shouldExit { + return exitErr + } } var name string @@ -178,17 +212,9 @@ func newValidateCmd() *cobra.Command { image := resolveImage(name, cfg) - freshlyCreated := false - if remote { - // --remote: force all commands to sidecar, creating one if needed. - var err error - freshlyCreated, err = resolveOrCreateSidecarID(ctx, &sidecarID, orgID, image, workDir, streams) - if err != nil { - return err - } - statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", sidecarID)) - } else if cfg.HasRemoteCommands() { - freshlyCreated = resolveSidecar(ctx, &sidecarID, orgID, image, workDir, hook, streams) + freshlyCreated, err := resolveSidecarForRun(ctx, remote, &sidecarID, orgID, image, workDir, hook, cfg, statusFn, streams) + if err != nil { + return err } execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams) diff --git a/internal/testing/fakes/circleci.go b/internal/testing/fakes/circleci.go index 26d9b2a7..50e2535a 100644 --- a/internal/testing/fakes/circleci.go +++ b/internal/testing/fakes/circleci.go @@ -77,6 +77,7 @@ type FakeCircleCI struct { AddKeyStatusCode int // override for POST /sidecar/instances/:id/ssh/add-key CreateSnapshotStatusCode int // override for POST /sidecar/snapshots GetSnapshotStatusCode int // override for GET /sidecar/snapshots/:id + ListSnapshotsStatusCode int // override for GET /sidecar/snapshots } func NewFakeCircleCI() *FakeCircleCI { @@ -100,6 +101,7 @@ func NewFakeCircleCI() *FakeCircleCI { r.POST("/api/v2/sidecar/instances/:id/exec", f.handleExec) // Snapshot endpoints + r.GET("/api/v2/sidecar/snapshots", f.handleListSnapshots) r.POST("/api/v2/sidecar/snapshots", f.handleCreateSnapshot) r.GET("/api/v2/sidecar/snapshots/:id", f.handleGetSnapshot) @@ -328,6 +330,29 @@ func (f *FakeCircleCI) handleGetSnapshot(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"message": "snapshot not found"}) } +func (f *FakeCircleCI) handleListSnapshots(c *gin.Context) { + if !f.requireToken(c) { + return + } + f.mu.RLock() + defer f.mu.RUnlock() + if f.ListSnapshotsStatusCode != 0 { + c.JSON(f.ListSnapshotsStatusCode, gin.H{"message": "API error"}) + return + } + orgID := c.Query("org_id") + var filtered []Snapshot + for _, s := range f.Snapshots { + if s.OrgID == orgID { + filtered = append(filtered, s) + } + } + if filtered == nil { + filtered = []Snapshot{} + } + c.JSON(http.StatusOK, gin.H{"items": filtered}) +} + func (f *FakeCircleCI) handleTriggerRun(c *gin.Context) { if !f.requireToken(c) { return