feat(enable): offer to import agent history on first-time enable · Entire

feat(enable): offer to import agent history on first-time enable

885859c→main·computermode·2w ago·4 files·+491 added/-0 removed

First-time entire enable now offers to import pre-existing agent history for the agents selected during setup. Interactive runs show an unchecked multi-select of eligible agents — those with a registered importer and at least one discoverable session in the 30-day lookback window. Non-interactive runs (--yes or no TTY) auto-import all eligible agents. The offer is gated to first run via IsSetUpAny and is best-effort: discovery or import failures are logged and surfaced as a notice but never fail enable.

Wired into both first-run enable paths — runEnableInteractive (bare / --yes) and setupAgentHooksNonInteractive (--agent) — reusing the same redaction + agentimport.Run flow as the standalone entire import.

Sessions

938f78effaffView transcript

[?
Import Agent History on First-Time EnableClaude Code·Opus 4.8·3 steps](/content/gh/entireio/cli/session/ca12eaf1-a5bf-43b7-bfb9-89521e42568f#timeline-938f78effaff/index.html)

Changes

4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

//go:build integration

package integration

import (
    "os"
    "path/filepath"
    "strings"
    "testing"

"github.com/stretchr/testify/require"
)

// claudeFixture is a two-turn Claude transcript used to verify enable-time import.
const claudeImportFixture = `{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}\n{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}\n{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`

// freshRepoEnv builds a repo with an initial commit but WITHOUT Entire enabled,
// so `entire enable` runs its real first-time flow.
func freshRepoEnv(t *testing.T) *TestEnv {
    t.Helper()
    env := NewTestEnv(t)
    env.InitRepo()
    env.WriteFile("README.md", "# Test Repository")
    env.GitAdd("README.md")
    env.GitCommit("Initial commit")
    return env
}

func TestEnableOffersImport_FirstRunAutoImports(t *testing.T) {
    t.Parallel()
    env := freshRepoEnv(t)

// Pre-existing Claude history for this repo.
    require.NoError(t, os.WriteFile(
        filepath.Join(env.ClaudeProjectDir, "sess1.jsonl"),
        []byte(claudeImportFixture), 0o644))

// First-time enable in a non-interactive (no-TTY) subprocess auto-imports the
    // selected agent's discoverable history.
    out := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
    require.Contains(t, out, "Ready.", "enable should complete; got: %s", out)
    require.Contains(t, out, "Imported 2 turn(s)", "first-time enable should import discovered history; got: %s", out)

// The imported turns are real checkpoints on the v1 metadata branch.
    require.Contains(t, env.RunCLI("checkpoint", "list"), "[imported]",
        "imported checkpoints should be listed")
}

func TestEnableOffersImport_NoHistoryIsSilent(t *testing.T) {
    t.Parallel()
    env := freshRepoEnv(t)
    // No transcripts written: nothing discoverable.

out := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
    require.Contains(t, out, "Ready.", "enable should complete; got: %s", out)
    require.NotContains(t, out, "Imported", "no history => import offer must be a silent no-op; got: %s", out)
}

func TestEnableOffersImport_NotOfferedOnReEnable(t *testing.T) {
    t.Parallel()
    env := freshRepoEnv(t)
    require.NoError(t, os.WriteFile(
        filepath.Join(env.ClaudeProjectDir, "sess1.jsonl"),
        []byte(claudeImportFixture), 0o644))

// First enable imports.
    first := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
    require.Contains(t, first, "Imported 2 turn(s)", "first enable should import; got: %s", first)

// Re-enable must not re-offer or re-import, even though history is still present.
    second := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
    require.NotContains(t, second, "Imported", "re-enable must not offer import again; got: %s", second)
    require.False(t, strings.Contains(second, "already imported"),
        "re-enable must not run import at all; got: %s", second)
}

Acmd/entire/cli/integration_test/enable_import_test.go+78

1081 unmodified lines

1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
113 unmodified lines

1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
389 unmodified lines

1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
84 unmodified lines

1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712

1081 unmodified lines

// 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 {
    // Capture first-run status before we write any settings: setupEntireDirectory
    // and saveSettings below make IsSetUpAny report true. maybeOfferSessionImport
    // uses this so the import offer only fires on the very first enable.
    firstRun := !settings.IsSetUpAny(ctx)

// 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)
    }

// Offer to import pre-existing agent history for the just-selected agents.
// First-run only; best-effort (never fails enable).
if err := maybeOfferSessionImport(ctx, w, agents, opts, firstRun); err != nil {
    return err
}

Mcmd/entire/cli/setup.go+21

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189

package cli

import (
    "context"
    "fmt"
    "io"
    "time"

"charm.land/huh/v2"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/agentimport"
    "github.com/entireio/cli/cmd/entire/cli/interactive"
    "github.com/entireio/cli/cmd/entire/cli/logging"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/strategy"
)

// eligibleImport pairs a just-selected agent with its importer and the number
// of sessions discoverable for the current repo within the lookback window.
type eligibleImport struct {
    imp          agentimport.Importer
    displayName  string
    sessionCount int
}

// Seams for testing the orchestration in maybeOfferSessionImport without disk
// discovery, a real TTY, or real checkpoint writes. Production wiring uses the
// real implementations below.
var (
    sessionImportDiscover = discoverImportableAgents
    sessionImportPrompt   = promptImportSelection
    sessionImportRun      = runSelectedImports
)

// maybeOfferSessionImport offers, on first-time enable only, to import
// pre-existing agent history for the just-selected agents. Granularity is
// agent-level: choosing an agent imports all its discoverable sessions (30-day
// lookback, matching `entire import`). It is best-effort — discovery or import
// failures are logged and reported to the user but never fail enable.
//
// Interactive runs present a multi-select with nothing pre-checked, so import
// only happens when the user actively selects agents. Non-interactive runs
// (`--yes` or no TTY) auto-import all eligible agents.
func maybeOfferSessionImport(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions, firstRun bool) error {
    if !firstRun {
        return nil
    }

repoRoot, err := paths.WorktreeRoot(ctx)
    if err != nil {
        // No worktree root => nothing to import against. Enabling still succeeds.
        logging.Warn(ctx, "session import offer skipped: no worktree root", "error", err)
        return nil
    }

eligible := sessionImportDiscover(ctx, agents, repoRoot)
    if len(eligible) == 0 {
        return nil
    }

selected := eligible
    if !opts.Yes && interactive.CanPromptInteractively() {
        selected, err = sessionImportPrompt(w, eligible)
        if err != nil {
            return err
        }
    }
    if len(selected) == 0 {
        return nil
    }

sessionImportRun(ctx, w, repoRoot, selected)
    return nil
}

// discoverImportableAgents keeps the selected agents that have a registered
// importer and at least one discoverable session for the repo.
func discoverImportableAgents(ctx context.Context, agents []agent.Agent, repoRoot string) []eligibleImport {
    now := time.Now()
    var out []eligibleImport
    for _, ag := range agents {
        imp := importerForAgent(ag)
        if imp == nil {
            continue
        }
        sessions, err := imp.Discover(repoRoot, "", now, nil)
        if err != nil {
            logging.Warn(ctx, "session import discovery failed", "agent", string(ag.Type()), "error", err)
            continue
        }
        if len(sessions) == 0 {
            continue
        }
        out = append(out, eligibleImport{
            imp:          imp,
            displayName:  string(ag.Type()),
            sessionCount: len(sessions),
        })
    }
    return out
}

// importerForAgent finds the importer for an agent by matching AgentType, which
// is the shared display-name identity between the two seams (importer Name and
// AgentType are distinct concepts, so match on type rather than name).
func importerForAgent(ag agent.Agent) agentimport.Importer {
    for _, imp := range agentimport.All() {
        if imp.AgentType() == ag.Type() {
            return imp
        }
    }
    return nil
}

// promptImportSelection shows the agent multi-select (all unchecked) and
// returns the chosen subset. An empty selection (or user abort) returns an
// empty slice, which the caller treats as "skip import".
func promptImportSelection(w io.Writer, eligible []eligibleImport) ([]eligibleImport, error) {
    byName := make(map[string]eligibleImport, len(eligible))
    options := make([]huh.Option[string], 0, len(eligible))
    for _, e := range eligible {
        byName[e.imp.Name()] = e
        label := fmt.Sprintf("%s  (%s, last %d days)", e.displayName, pluralSessions(e.sessionCount), agentimport.LookbackDays)
        options = append(options, huh.NewOption(label, e.imp.Name()))
    }

var chosen []string
    form := NewAccessibleForm(
        huh.NewGroup(
            huh.NewMultiSelect[string]().
                Title("Import existing sessions into Entire? (optional)").
                Description("Space to select, enter to confirm. Select none to skip.").
                Options(options...).
                Value(&chosen),
            ),
        )
    if err := form.Run(); err != nil {
        // Cancellation returns nil here => skip import; other errors propagate.
        return nil, handleFormCancellation(w, "Import", err)
    }

out := make([]eligibleImport, 0, len(chosen))
    for _, name := range chosen {
        if e, ok := byName[name]; ok {
            out = append(out, e)
        }
    }
    return out, nil
}

// runSelectedImports imports each chosen agent's history, mirroring the
// standalone `entire import` command. Per-agent failures are logged and
// reported but do not stop the remaining imports or fail enable.
func runSelectedImports(ctx context.Context, w io.Writer, repoRoot string, selected []eligibleImport) {
    repo, err := openRepository(ctx)
    if err != nil {
        logging.Warn(ctx, "session import skipped: open repository failed", "error", err)
        fmt.Fprintf(w, "Note: could not import agent history: %v\n", err)
        return
    }
    defer repo.Close()

// Load repo/user-configured redaction before any checkpoint write, matching
    // import_cmd.go; without it only always-on secret scanning would run.
    strategy.EnsureRedactionConfigured()

for _, e := range selected {
        res, err := agentimport.Run(ctx, repo, e.imp, agentimport.Options{
            RepoRoot: repoRoot,
            Now:      time.Now(),
        })
        if err != nil {
            logging.Warn(ctx, "session import failed", "agent", e.imp.Name(), "error", err)
            fmt.Fprintf(w, "Note: could not import %s history: %v\n", e.displayName, err)
            continue
        }
        fmt.Fprintf(w, "Imported %d turn(s) from %d session(s) (%d already imported).\n",
            res.TurnsImported, res.SessionsScanned, res.TurnsSkipped)
    }
}

// pluralSessions renders a session count with correct pluralization.
func pluralSessions(n int) string {
    if n == 1 {
        return "1 session"
    }
    return fmt.Sprintf("%d sessions", n)
}

// Mock Test Functions

func TestPluralSessions(t *testing.T) { t.Parallel() cases := map[int]string{0: "0 sessions", 1: "1 session", 2: "2 sessions", 42: "42 sessions"} for n, want := range cases { if got := pluralSessions(n); got != want { t.Errorf("pluralSessions(%d) = %q, want %q", n, got, want) } } }

func TestImporterForAgent_MatchesByType(t *testing.T) { t.Parallel() // Every registered importer must be resolvable from an agent carrying the // same AgentType — this is the contract the offer relies on. for _, imp := range agentimport.All() { ag := fakeAgent{typ: imp.AgentType()} got := importerForAgent(ag) if got == nil { t.Errorf("importerForAgent(%q) = nil, want importer %q", imp.AgentType(), imp.Name()) continue } if got.Name() != imp.Name() { t.Errorf("importerForAgent(%q) = %q, want %q", imp.AgentType(), got.Name(), imp.Name()) } } }

func TestImporterForAgent_UnknownTypeReturnsNil(t *testing.T) { t.Parallel() if got := importerForAgent(fakeAgent{typ: "Definitely Not A Real Agent"}); got != nil { t.Errorf("importerForAgent(unknown) = %q, want nil", got.Name()) } }