Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
95 changes: 95 additions & 0 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,96 @@ const (
// underlying flag name or discovery mechanics.
const externalAgentsAutoEnabledNotice = "Note: external agents are now enabled for the rest of Entire too — not just summaries."

// Checkpoint sync destination values used by the interactive picker.
const (
checkpointSyncThisRemote = "this-remote"
checkpointSyncSeparate = "separate"
checkpointSyncLocal = "local"
)

const checkpointSyncFooter = "Transcripts may contain sensitive data. Redaction is best-effort."

// promptCheckpointSync shows an interactive picker for checkpoint sync
// destination. Modifies opts based on the user's selection.
// In non-interactive or --yes mode, prints an informational notice instead.
func promptCheckpointSync(w io.Writer, opts *EnableOptions) error {
if opts.Yes || !interactive.CanPromptInteractively() {
fmt.Fprintln(w)
fmt.Fprintln(w, "Checkpoints will sync to this repo's remote.")
fmt.Fprintf(w, "To use a private checkpoint repo: entire configure --checkpoint-remote github:org/private-repo\n\n")
fmt.Fprintln(w, checkpointSyncFooter)
Comment thread
evisdren marked this conversation as resolved.
return nil
}

fmt.Fprintln(w)
fmt.Fprintln(w, checkpointSyncFooter)

choice := checkpointSyncThisRemote
form := NewAccessibleForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Where should checkpoints sync?").
Options(
Comment thread
evisdren marked this conversation as resolved.
huh.NewOption("This repo's remote", checkpointSyncThisRemote),
huh.NewOption("A separate private checkpoint repo", checkpointSyncSeparate),
huh.NewOption("Keep checkpoints local only", checkpointSyncLocal),
).
Value(&choice),
),
)

if err := form.Run(); err != nil {
Comment thread
evisdren marked this conversation as resolved.
if errors.Is(err, huh.ErrUserAborted) {
return NewSilentError(errors.New("checkpoint sync selection cancelled"))
}
return fmt.Errorf("checkpoint sync prompt: %w", err)
Comment thread
evisdren marked this conversation as resolved.
}

if choice == checkpointSyncSeparate {
var repo string
inputForm := NewAccessibleForm(
huh.NewGroup(
huh.NewInput().
Title("Checkpoint repo (format: github:owner/repo)").
Placeholder("github:org/private-checkpoints").
Value(&repo),
),
)
if err := inputForm.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return NewSilentError(errors.New("checkpoint repo input cancelled"))
}
return fmt.Errorf("checkpoint repo prompt: %w", err)
}
repo = strings.TrimSpace(repo)
if repo == "" {
return NewSilentError(errors.New("no checkpoint repo provided"))
}
Comment thread
evisdren marked this conversation as resolved.
choice = repo // pass the raw input to applyCheckpointSyncChoice for validation
}

return applyCheckpointSyncChoice(choice, opts)
}

// applyCheckpointSyncChoice applies the checkpoint sync picker result to opts.
// choice is one of the checkpointSync* constants, or a raw "provider:owner/repo"
// string when the user selected "separate" and entered a repo.
func applyCheckpointSyncChoice(choice string, opts *EnableOptions) error {
switch choice {
case checkpointSyncThisRemote:
// Default — no changes needed.
case checkpointSyncLocal:
opts.SkipPushSessions = true
Comment thread
evisdren marked this conversation as resolved.
default:
// Treat as a checkpoint remote value (e.g. "github:org/repo").
if _, _, err := parseCheckpointRemoteFlag(choice); err != nil {
return fmt.Errorf("invalid checkpoint repo: %w", err)
}
Comment thread
evisdren marked this conversation as resolved.
opts.CheckpointRemote = choice
}
return nil
}

// EnableOptions holds the flags for `entire enable`.
type EnableOptions struct {
LocalDev bool
Expand Down Expand Up @@ -909,6 +999,11 @@ To completely remove Entire integrations from this repository, use --uninstall:
// runEnableInteractive runs the interactive enable flow.
// agents must be provided by the caller (via detectOrSelectAgent).
func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions) error {
// Ask where checkpoints should sync (interactive picker or printed notice).
if err := promptCheckpointSync(w, &opts); err != nil {
return err
}

// Uninstall hooks for agents that were previously active but are no longer selected
if err := uninstallDeselectedAgentHooks(ctx, w, agents); err != nil {
return fmt.Errorf("failed to clean up deselected agents: %w", err)
Expand Down
111 changes: 111 additions & 0 deletions cmd/entire/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2984,3 +2984,114 @@ func TestConfigureCmd_FreshRepo_PointsAtEnable(t *testing.T) {
t.Errorf("expected hint pointing at 'entire enable', got stderr: %s", stderr.String())
}
}

func TestPromptCheckpointSync_NonInteractive(t *testing.T) {
t.Parallel()
// --yes mode should print the notice without prompting.
var buf bytes.Buffer
opts := EnableOptions{Yes: true}
err := promptCheckpointSync(&buf, &opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()

if !strings.Contains(out, "Checkpoints will sync") {
t.Error("notice should mention checkpoint syncing")
}
if !strings.Contains(out, "--checkpoint-remote") {
t.Error("notice should suggest --checkpoint-remote")
}
if !strings.Contains(out, "sensitive data") {
t.Error("notice should mention sensitive data")
}
if !strings.Contains(out, "best-effort") {
t.Error("notice should mention best-effort redaction")
}
// opts should remain unchanged (defaults)
if opts.SkipPushSessions {
t.Error("SkipPushSessions should not be set in --yes mode")
}
if opts.CheckpointRemote != "" {
t.Error("CheckpointRemote should not be set in --yes mode")
}
}

func TestApplyCheckpointSyncChoice_ThisRemote(t *testing.T) {
t.Parallel()
opts := EnableOptions{}
err := applyCheckpointSyncChoice(checkpointSyncThisRemote, &opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.SkipPushSessions {
t.Error("SkipPushSessions should not be set for this-remote")
}
if opts.CheckpointRemote != "" {
t.Error("CheckpointRemote should not be set for this-remote")
}
}

func TestApplyCheckpointSyncChoice_Local(t *testing.T) {
t.Parallel()
opts := EnableOptions{}
err := applyCheckpointSyncChoice(checkpointSyncLocal, &opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !opts.SkipPushSessions {
t.Error("SkipPushSessions should be set for local-only")
}
if opts.CheckpointRemote != "" {
t.Error("CheckpointRemote should not be set for local-only")
}
}

func TestApplyCheckpointSyncChoice_SeparateRepo(t *testing.T) {
t.Parallel()
opts := EnableOptions{}
err := applyCheckpointSyncChoice("github:myorg/private-checkpoints", &opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.CheckpointRemote != "github:myorg/private-checkpoints" {
t.Errorf("expected CheckpointRemote to be set, got %q", opts.CheckpointRemote)
}
if opts.SkipPushSessions {
t.Error("SkipPushSessions should not be set for separate repo")
}
}

func TestApplyCheckpointSyncChoice_InvalidRepo(t *testing.T) {
t.Parallel()
opts := EnableOptions{}
err := applyCheckpointSyncChoice("not-valid", &opts)
if err == nil {
t.Fatal("expected error for invalid repo format")
}
if !strings.Contains(err.Error(), "invalid checkpoint repo") {
t.Errorf("expected 'invalid checkpoint repo' in error, got: %v", err)
}
}

func TestApplyCheckpointSyncChoice_UnsupportedProvider(t *testing.T) {
t.Parallel()
opts := EnableOptions{}
err := applyCheckpointSyncChoice("gitlab:org/repo", &opts)
if err == nil {
t.Fatal("expected error for unsupported provider")
}
if !strings.Contains(err.Error(), "unsupported provider") {
t.Errorf("expected 'unsupported provider' in error, got: %v", err)
}
}

func TestCheckpointSyncFooter_Content(t *testing.T) {
t.Parallel()
if !strings.Contains(checkpointSyncFooter, "sensitive data") {
t.Error("footer should mention sensitive data")
}
if !strings.Contains(checkpointSyncFooter, "best-effort") {
t.Error("footer should mention best-effort redaction")
}
}
Loading