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
40 changes: 40 additions & 0 deletions acceptance/sidecar_snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
12 changes: 12 additions & 0 deletions internal/circleci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions internal/circleci/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
50 changes: 49 additions & 1 deletion internal/cmd/sidecar.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func randomSidecarName() string {
return petname.Generate(3, "-")
}

const cmdList = "list"

func newSidecarCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sidecar",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -663,6 +665,7 @@ func newSidecarSnapshotCmd() *cobra.Command {
}
cmd.AddCommand(newSidecarSnapshotCreateCmd())
cmd.AddCommand(newSidecarSnapshotGetCmd())
cmd.AddCommand(newSidecarSnapshotListCmd())
return cmd
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 54 additions & 28 deletions internal/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why is this included?

Copy link
Copy Markdown
Author

@stiyyagura0901 stiyyagura0901 May 19, 2026

Choose a reason for hiding this comment

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

It got added to fix a lint failure on the original commit. newValidateCmd was pushed over the gocyclo complexity limit.

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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions internal/testing/fakes/circleci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down