refactor(import): generalize import package across agents (Claude first) · Entire
Log in
refactor(import): generalize import package across agents (Claude first)
5733491→main·
computermode·3w ago·16 files·+691 added/-776 removed
Renames cmd/entire/cli/importclaude -> cmd/entire/cli/agentimport with an Importer seam (Discover + SplitTurns) and a static set of supported agents, so other agents can be added without touching the orchestration. Claude Code is the only implementation today.
Also drops the enable-time import prompt; importing is a hidden command only
(entire import
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Sessions
e58fc114f6a9View transcript
?\ Generalize Agent Import Across PlatformsClaude Code·Opus 4.8·2 steps
Changes
16
cmd/entire/cli
agentimport
Aagentimport.go+198
Aagentimport_test.go+155
Aclaude.go+215
Aclaude_test.go+106
Mimport_cmd.go+15/-11
importclaude
Ddiscover.go-59
Ddiscover_test.go-55
Did.go-20
Did_test.go-19
Dimporter.go-120
Dimporter_test.go-124
Dturns.go-171
Dturns_test.go-58
integration_test
Mimport_claude_test.go+2/-2
Msetup.go-67
Dsetup_import_prompt_test.go-70
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
190
191
192
193
194
195
196
197
198
// Package agentimport imports a coding agent's pre-existing local transcripts
// into Entire as read-only, commit-less checkpoints on the v1 metadata branch.
//
// The orchestration (discovery loop, idempotent per-turn IDs, redaction, and
// the checkpoint write) is agent-agnostic and lives here. Each agent plugs in
// an Importer that knows where its transcripts live and how to split one into
// per-user-prompt turns. Claude Code is the only implementation today
// (see claude.go); others register themselves the same way.
package agentimport
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"sort"
"time"
"github.com/go-git/go-git/v6"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/redact"
)
// importVersion is the importer schema version stamped into provenance.
const importVersion = 1
// LookbackDays bounds how far back import reaches. Fixed this pass (no flag).
const LookbackDays = 30
// SessionFile is one discovered agent transcript for a repo.
type SessionFile struct {
Path string // absolute path to the transcript file
SessionID string // agent session id (used in checkpoint metadata + provenance)
}
// Turn is one user-prompt turn extracted from a session transcript. Line
// offsets are in raw-line space (newline-counted), matching transcript slicing
// and the agent token-usage helpers.
type Turn struct {
LineStart, LineEnd int
UUID, ParentUUID string
Prompt, Model string
CreatedAt time.Time
Tokens *types.TokenUsage
ContentHash string
}
// Importer is the per-agent seam: it locates an agent's transcripts for a repo
// and splits one into per-turn units. Everything else (idempotency, redaction,
// writing) is handled generically by Run.
type Importer interface {
// Name is the registry key and the provenance source (e.g. "claude-code").
Name() string
// AgentType is the display name stored in checkpoint metadata (e.g. "Claude Code").
AgentType() types.AgentType
// Discover returns the agent's transcript files for the repo within the
// lookback window. overridePath replaces the default transcript dir;
// sessionFilter, when non-empty, keeps only matching session IDs.
Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error)
// SplitTurns splits one session's raw transcript bytes into per-turn units.
SplitTurns(sf SessionFile, full []byte) ([]Turn, error)
}
// importers is the static set of supported agents. Adding an agent is a new
// Importer implementation appended here — no init() / runtime registration.
var importers = []Importer{
claudeImporter{},
}
// Get returns the importer registered under name.
//
//nolint:ireturn // Importer is the intended polymorphic seam returned to callers.
func Get(name string) (Importer, bool) {
for _, imp := range importers {
if imp.Name() == name {
return imp, true
}
}
return nil, false
}
// All returns every supported importer, sorted by name.
func All() []Importer {
out := append([]Importer(nil), importers...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
// Options configures an import run.
type Options struct {
RepoRoot string
OverridePath string
SessionFilter []string
Now time.Time
DryRun bool
}
// Result summarizes an import run.
type Result struct {
SessionsScanned int
TurnsImported int
TurnsSkipped int
}
// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which
// is how import stays idempotent.
func DeriveCheckpointID(sessionID, turnUUID string) id.CheckpointID {
sum := sha256.Sum256([]byte(sessionID + "/" + turnUUID))
return id.MustCheckpointID(hex.EncodeToString(sum[:6])) // 6 bytes = 12 lowercase hex chars
}
// Run imports the given agent's transcripts (within the lookback window) as
// read-only checkpoints on the v1 metadata branch (Kind "imported"). It is
// idempotent: turns whose deterministic ID already exists are skipped.
func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) (Result, error) {
var res Result
files, err := imp.Discover(opts.RepoRoot, opts.OverridePath, opts.Now, opts.SessionFilter)
if err != nil {
return res, fmt.Errorf("discover %s sessions: %w", imp.Name(), err)
}
stores, err := cp.Open(ctx, repo, cp.OpenOptions{})
if err != nil {
return res, fmt.Errorf("open checkpoint store: %w", err)
}
existing := make(map[string]bool)
if infos, listErr := stores.Persistent.List(ctx); listErr == nil {
for _, in := range infos {
existing[in.CheckpointID.String()] = true
}
}
for _, sf := range files {
res.SessionsScanned++
full, readErr := os.ReadFile(sf.Path)
if readErr != nil {
return res, fmt.Errorf("read %s: %w", sf.Path, readErr)
}
turns, splitErr := imp.SplitTurns(sf, full)
if splitErr != nil {
return res, fmt.Errorf("split %s session %s: %w", imp.Name(), sf.SessionID, splitErr)
}
for _, turn := range turns {
cid := DeriveCheckpointID(sf.SessionID, turn.UUID)
if existing[cid.String()] {
res.TurnsSkipped++
continue
}
if opts.DryRun {
res.TurnsImported++ // counts what would import
continue
}
if err := writeTurn(ctx, stores, imp, cid, sf, full, turn); err != nil {
return res, err
}
existing[cid.String()] = true
res.TurnsImported++
}
}
return res, nil
}
func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, full []byte, turn Turn) error {
red, err := redact.JSONLBytes(full)
if err != nil {
return fmt.Errorf("redact transcript: %w", err)
}
prov := &cp.Provenance{
Source: imp.Name(), TranscriptPath: sf.Path, SessionID: sf.SessionID,
TurnUUID: turn.UUID, ParentUUID: turn.ParentUUID,
LineStart: turn.LineStart, LineEnd: turn.LineEnd,
ContentHash: turn.ContentHash, ImportVersion: importVersion,
}
if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{
CheckpointID: cid,
SessionID: sf.SessionID,
CreatedAt: turn.CreatedAt,
Strategy: "import",
Kind: string(session.KindImported),
Agent: imp.AgentType(),
Model: turn.Model,
Transcript: red,
Prompts: []string{turn.Prompt},
CheckpointsCount: 1,
CheckpointTranscriptStart: turn.LineStart,
TokenUsage: turn.Tokens,
Provenance: prov,
})); err != nil {
return fmt.Errorf("write imported checkpoint %s: %w", cid, err)
}
return nil
}
Acmd/entire/cli/agentimport/agentimport.go+198
package agentimport
import ( "context" "os" "path/filepath" "strings" "testing" "time"
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object"
cp "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/testutil" )
func TestDeriveCheckpointID_StableAndDistinct(t *testing.T) { t.Parallel() a := DeriveCheckpointID("sess", "turn-1") b := DeriveCheckpointID("sess", "turn-1") c := DeriveCheckpointID("sess", "turn-2") if a != b { t.Errorf("not deterministic: %s != %s", a, b) } if a == c { t.Errorf("collision across turns: %s == %s", a, c) } if a.IsEmpty() { t.Error("derived id is empty") } }
func TestRegistry_HasClaude(t *testing.T) { t.Parallel() imp, ok := Get("claude-code") if !ok { t.Fatal("claude-code importer not registered") } if imp.Name() != "claude-code" { t.Fatalf("unexpected name %q", imp.Name()) } if len(All()) == 0 { t.Fatal("All() returned no importers") } }
func initRepoWithCommit(t *testing.T) (*git.Repository, string) { t.Helper() repoDir := t.TempDir() testutil.InitRepo(t, repoDir) repo, err := git.PlainOpen(repoDir) if err != nil { t.Fatal(err) } wt, err := repo.Worktree() if err != nil { t.Fatal(err) } testutil.WriteFile(t, repoDir, "f.txt", "x") if _, err := wt.Add("f.txt"); err != nil { t.Fatal(err) } if _, err := wt.Commit("init", &git.CommitOptions{ Author: &object.Signature{Name: "Test", Email: "test@test.com"}, }); err != nil { t.Fatal(err) } return repo, repoDir }
func writeFixtureSession(t *testing.T, dir, name string) {
t.Helper()
content := strings.Join([]string{
{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}},
{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}},
{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}},
}, "\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRun_ImportsAndIsIdempotent(t *testing.T) { t.Parallel() repo, repoDir := initRepoWithCommit(t) claudeDir := t.TempDir() writeFixtureSession(t, claudeDir, "sess1.jsonl")
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)} imp := claudeImporter{}
res, err := Run(context.Background(), repo, imp, opts) if err != nil { t.Fatal(err) } if res.TurnsImported != 2 { t.Fatalf("want 2 imported, got %+v", res) }
res2, err := Run(context.Background(), repo, imp, opts) if err != nil { t.Fatal(err) } if res2.TurnsImported != 0 || res2.TurnsSkipped != 2 { t.Fatalf("re-run not idempotent: %+v", res2) }
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) if err != nil { t.Fatal(err) } infos, err := stores.Persistent.List(context.Background()) if err != nil { t.Fatal(err) } if len(infos) != 2 { t.Fatalf("expected 2 imported checkpoints on v1, got %+v", infos) } for _, in := range infos { if !in.Imported { t.Fatalf("checkpoint %s missing Imported flag: %+v", in.CheckpointID, in) } } }
func TestRun_DryRunWritesNothing(t *testing.T) { t.Parallel() repo, repoDir := initRepoWithCommit(t) claudeDir := t.TempDir() writeFixtureSession(t, claudeDir, "sess1.jsonl")
res, err := Run(context.Background(), repo, claudeImporter{}, Options{ RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } if res.TurnsImported != 2 { t.Fatalf("dry-run should count 2 turns, got %+v", res) }
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) if err != nil { t.Fatal(err) } infos, err := stores.Persistent.List(context.Background()) if err != nil { t.Fatal(err) } if len(infos) != 0 { t.Fatalf("dry-run must not write, got %+v", infos) } }
Acmd/entire/cli/agentimport/agentimport\_test.go+155
package agentimport
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/transcript"
)
// claudeImporter imports Claude Code transcripts (~/.claude/projects/<slug>/*.jsonl).
type claudeImporter struct{}
func (claudeImporter) Name() string { return "claude-code" }
func (claudeImporter) AgentType() types.AgentType { return agent.AgentTypeClaudeCode }
// Discover returns Claude transcript files for the repo modified within the
// lookback window. overridePath replaces the default ~/.claude/projects/<slug>
// dir; sessionFilter, when non-empty, keeps only matching session IDs (the
// file stem).
func (claudeImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {
dir := overridePath
if dir == "" {
ag := &claudecode.ClaudeCodeAgent{}
d, err := ag.GetSessionDir(repoRoot)
if err != nil {
return nil, fmt.Errorf("resolve claude session dir: %w", err)
}
dir = d
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // no transcripts for this repo
}
return nil, fmt.Errorf("read claude session dir: %w", err)
}
cutoff := now.AddDate(0, 0, -LookbackDays)
var out []SessionFile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
stem := strings.TrimSuffix(e.Name(), ".jsonl")
if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
continue
}
out = append(out, SessionFile{Path: filepath.Join(dir, e.Name()), SessionID: stem})
}
slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })
return out, nil
}
// SplitTurns produces one Turn per user-prompt line. Token usage for each turn
// is computed on the slice [LineStart, LineEnd) so turns don't double-count\
// later turns. tool_result lines (Type == "user" but no text content) do not\
// start a turn.\
func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {\
subagentsDir := filepath.Join(filepath.Dir(sf.Path), sf.SessionID, "subagents")\
rawLines := splitRawLines(full)\
\
// Identify user-prompt turn starts in raw-line space.\
var starts []int\
for i, raw := range rawLines {\
if isUserPromptLine(raw) {\
starts = append(starts, i)\
}\
}\
\
ag := &claudecode.ClaudeCodeAgent{}\
turns := make([]Turn, 0, len(starts))\
for k, start := range starts {\
end := len(rawLines)\
if k+1 < len(starts) {\
end = starts[k+1]\
}\
\
// Bound token usage to [start, end): truncate to the first `end` lines,\
// then let the agent helper slice from `start`.\
truncated := joinLines(rawLines[:end])\
tokens, err := ag.CalculateTotalTokenUsage(truncated, start, subagentsDir)\
if err != nil {\
return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
}\
\
var rec struct {\
UUID string `json:"uuid"`\
Message json.RawMessage `json:"message"`\
ParentUUID string `json:"parentUuid"`\
Timestamp string `json:"timestamp"`\
}\
if err := json.Unmarshal(rawLines[start], &rec); err != nil {\
// Already validated as a user-prompt line in isUserPromptLine; skip\
// defensively if it somehow fails to parse here.\
continue\
}\
ts, parseErr := time.Parse(time.RFC3339, rec.Timestamp)\
if parseErr != nil {\
ts = time.Time{}\
}\
\
slice := joinLines(rawLines[start:end])\
sum := sha256.Sum256(slice)\
\
turns = append(turns, Turn{\
LineStart: start,\
LineEnd: end,\
UUID: rec.UUID,\
ParentUUID: rec.ParentUUID,\
Prompt: transcript.ExtractUserContent(rec.Message),\
Model: modelInRange(rawLines, start, end),\
CreatedAt: ts,\
Tokens: tokens,\
ContentHash: "sha256:" + hex.EncodeToString(sum[:]),\
})\
}\
return turns, nil\
}\
\
// claudeExtraFields are line fields not modeled by transcript.Line.\
type claudeExtraFields struct {\
ParentUUID string `json:"parentUuid"`\
Timestamp string `json:"timestamp"`\
Message struct {\
Model string `json:"model"`\
} `json:"message"`\
}\
\
// modelInRange returns the model from the first assistant message within\
// [start, end), or "" when none carries one. The model lives on assistant\
// lines, not the user-prompt line.\
func modelInRange(rawLines [][]byte, start, end int) string {\
for i := start; i < end && i < len(rawLines); i++ {\
var line transcript.Line\
if err := json.Unmarshal(rawLines[i], &line); err != nil {\
continue\
}\
if line.Type != "assistant" {\
continue\
}\
var ex claudeExtraFields\
if err := json.Unmarshal(rawLines[i], &ex); err != nil {\
continue\
}\
if ex.Message.Model != "" {\
return ex.Message.Model\
}\
}\
return ""\
}\
\
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt\
// turn start: type "user" (or role "user") with non-empty extractable text.\
// tool_result lines are type "user" but carry no text, so they return false.\
func isUserPromptLine(raw []byte) bool {\
var line transcript.Line\
if err := json.Unmarshal(raw, &line); err != nil {\
return false\
}\
typ := line.Type\
if typ == "" {\
typ = line.Role\
}\
if typ != "user" {\
return false\
}\
return transcript.ExtractUserContent(line.Message) != ""\
}\
\
// splitRawLines splits content into raw lines in the same index space as\
// transcript.SliceFromLine (newline-counted). Trailing empty segment from a\
// final newline is dropped.\
func splitRawLines(content []byte) [][]byte {\
if len(content) == 0 {\
return nil\
}\
parts := strings.Split(string(content), "\n")\
if len(parts) > 0 && parts[len(parts)-1] == "" {\
parts = parts[:len(parts)-1]\
}\
out := make([][]byte, len(parts))\
for i, p := range parts {\
out[i] = []byte(p)\
}\
return out\
}\
\
// joinLines reassembles raw lines into newline-terminated bytes.\
func joinLines(lines [][]byte) []byte {\
if len(lines) == 0 {\
return nil\
}\
strs := make([]string, len(lines))\
for i, l := range lines {\
strs[i] = string(l)\
}\
return []byte(strings.Join(strs, "\n") + "\n")\
}\
```\
\
Acmd/entire/cli/agentimport/claude.go+215\
\
```\
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\
\
package agentimport\
\
import (\
"os"\
"path/filepath"\
"strings"\
"testing"\
"time"\
)\
\
func TestClaudeDiscover_LookbackAndFilter(t *testing.T) {\
t.Parallel()\
dir := t.TempDir()\
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
writeAged := func(name string, age time.Duration) {\
p := filepath.Join(dir, name)\
if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
t.Fatal(err)\
}\
mt := now.Add(-age)\
if err := os.Chtimes(p, mt, mt); err != nil {\
t.Fatal(err)\
}\
}\
writeAged("recent.jsonl", 5*24*time.Hour)\
writeAged("old.jsonl", 60*24*time.Hour)\
writeAged("skip.txt", 1*time.Hour)\
\
imp := claudeImporter{}\
got, err := imp.Discover("", dir, now, nil)\
if err != nil {\
t.Fatal(err)\
}\
if len(got) != 1 || got[0].SessionID != "recent" {\
t.Fatalf("lookback filter wrong: %v", got)\
}\
\
writeAged("abc123.jsonl", 1*24*time.Hour)\
got, err = imp.Discover("", dir, now, []string{"abc123"})\
if err != nil {\
t.Fatal(err)\
}\
if len(got) != 1 || got[0].SessionID != "abc123" {\
t.Fatalf("session filter wrong: %v", got)\
}\
}\
\
func TestClaudeDiscover_MissingDirIsEmpty(t *testing.T) {\
t.Parallel()\
got, err := claudeImporter{}.Discover("", filepath.Join(t.TempDir(), "nope"), time.Now(), nil)\
if err != nil {\
t.Fatalf("missing dir should not error: %v", err)\
}\
if len(got) != 0 {\
t.Fatalf("expected empty, got %v", got)\
}\
}\
\
func TestClaudeSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {\
t.Parallel()\
full := []byte(strings.Join([]string{\
`{"type":"user","uuid":"u1","parentUuid":"","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}}`,\
`{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
`{"type":"assistant","uuid":"a2","message":{"id":"m2","model":"claude-x","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":20,"output_tokens":7}}}`,\
}, "\n") + "\n")\
\
turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
if err != nil {\
t.Fatal(err)\
}\
if len(turns) != 2 {\
t.Fatalf("want 2 turns, got %d", len(turns))\
}\
if turns[0].LineStart != 0 || turns[0].LineEnd != 2 {\
t.Errorf("turn0 bounds = [%d,%d), want [0,2)", turns[0].LineStart, turns[0].LineEnd)\
}\
if turns[0].Prompt != "first" || turns[1].Prompt != "second" {\
t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
}\
if turns[0].Model != "claude-x" {\
t.Errorf("turn0 model = %q, want claude-x", turns[0].Model)\
}\
if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
}\
if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
}\
}\
\
func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
t.Parallel()\
full := []byte(strings.Join([]string{\
`{"type":"user","uuid":"u1","message":{"role":"user","content":"do it"}}`,\
`{"type":"assistant","uuid":"a1","message":{"id":"m1","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"output_tokens":3}}}`,\
`{"type":"user","uuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
}, "\n") + "\n")\
turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
if err != nil {\
t.Fatal(err)\
}\
if len(turns) != 1 {\
t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
}\
}\
```\
\
Acmd/entire/cli/agentimport/claude\_test.go+106\
\
```\
5 unmodified lines\
\
6\
7\
8\
9\
9\
10\
11\
12\
4 unmodified lines\
\
17\
18\
19\
20\
20\
21\
22\
23\
24\
25\
26\
27\
24\
28\
29\
30\
31\
32\
33\
30\
31\
32\
33\
34\
34\
35\
36\
37\
38\
39\
40\
41\
9 unmodified lines\
\
51\
52\
53\
50\
54\
55\
56\
57\
58\
55\
59\
60\
61\
62\
4 unmodified lines\
\
67\
68\
69\
66\
70\
71\
72\
73\
\
5 unmodified lines\
\
"github.com/spf13/cobra"\
\
"github.com/entireio/cli/cmd/entire/cli/importclaude"\
"github.com/entireio/cli/cmd/entire/cli/agentimport"\
"github.com/entireio/cli/cmd/entire/cli/paths"\
)\
\
4 unmodified lines\
\
Hidden: true,\
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },\
}\
cmd.AddCommand(newImportClaudeCodeCmd())\
// One subcommand per registered importer, so adding an agent is just a new\
// agentimport.Importer registration — no command wiring needed here.\
for _, imp := range agentimport.All() {\
cmd.AddCommand(newImportAgentCmd(imp))\
}\
return cmd\
}\
\
func newImportClaudeCodeCmd() *cobra.Command {\
func newImportAgentCmd(imp agentimport.Importer) *cobra.Command {\
var pathFlag string\
var dryRun bool\
var sessions []string\
\
cmd := &cobra.Command{\
Use: "claude-code",\
Short: "Import existing Claude Code transcripts as local, read-only checkpoints",\
Long: `Import pre-existing Claude Code transcripts for this repo (the past month)\
as local-only, read-only checkpoints. Imported history is searchable and\
explainable but is never pushed and not rewindable.`,\
Use: imp.Name(),\
Short: fmt.Sprintf("Import existing %s transcripts as read-only checkpoints", imp.AgentType()),\
Long: fmt.Sprintf(`Import pre-existing %s transcripts for this repo (the past month) as\
read-only checkpoints. Imported history is searchable and explainable but is\
not rewindable.`, imp.AgentType()),\
Args: cobra.NoArgs,\
RunE: func(c *cobra.Command, _ []string) error {\
ctx := c.Context()\
9 unmodified lines\
\
}\
defer repo.Close()\
\
res, err := importclaude.Run(ctx, repo, importclaude.Options{\
res, err := agentimport.Run(ctx, repo, imp, agentimport.Options{\
RepoRoot: repoRoot, OverridePath: pathFlag, SessionFilter: sessions,\
Now: time.Now(), DryRun: dryRun,\
})\
if err != nil {\
return fmt.Errorf("import claude-code: %w", err)\
return fmt.Errorf("import %s: %w", imp.Name(), err)\
}\
verb := "Imported"\
if dryRun {\
4 unmodified lines\
\
return nil\
},\
}\
cmd.Flags().StringVar(&pathFlag, "path", "", "Override the Claude projects directory to import from")\
cmd.Flags().StringVar(&pathFlag, "path", "", "Override the transcript directory to import from")\
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Report what would be imported without writing")\
cmd.Flags().StringSliceVar(&sessions, "session", nil, "Import only these session IDs (repeatable)")\
return cmd\
```\
\
Mcmd/entire/cli/import\_cmd.go+15/-11\
\
```\
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\
\
package importclaude\
\
import (\
"fmt"\
"os"\
"path/filepath"\
"slices"\
"strings"\
"time"\
\
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"\
)\
\
// LookbackDays bounds how far back import reaches. Fixed this pass (no --since).\
const LookbackDays = 30\
\
// DiscoverSessions returns absolute paths of Claude transcript files for the\
// repo modified within the lookback window. overridePath replaces the default\
// ~/.claude/projects/<slug> dir. sessionFilter, when non-empty, keeps only\
// files whose stem (name without .jsonl) matches one of the entries.\
func DiscoverSessions(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]string, error) {\
dir := overridePath\
if dir == "" {\
ag := &claudecode.ClaudeCodeAgent{}\
d, err := ag.GetSessionDir(repoRoot)\
if err != nil {\
return nil, fmt.Errorf("resolve claude session dir: %w", err)\
}\
dir = d\
}\
entries, err := os.ReadDir(dir)\
if err != nil {\
if os.IsNotExist(err) {\
return nil, nil // no transcripts for this repo\
}\
return nil, fmt.Errorf("read claude session dir: %w", err)\
}\
cutoff := now.AddDate(0, 0, -LookbackDays)\
var out []string\
for _, e := range entries {\
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {\
continue\
}\
stem := strings.TrimSuffix(e.Name(), ".jsonl")\
if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {\
continue\
}\
info, err := e.Info()\
if err != nil {\
continue\
}\
if info.ModTime().Before(cutoff) {\
continue\
}\
out = append(out, filepath.Join(dir, e.Name()))\
}\
slices.Sort(out)\
return out, nil\
}\
```\
\
Dcmd/entire/cli/importclaude/discover.go-59\
\
```\
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\
\
package importclaude\
\
import (\
"os"\
"path/filepath"\
"testing"\
"time"\
)\
\
func TestDiscoverSessions_LookbackAndFilter(t *testing.T) {\
t.Parallel()\
dir := t.TempDir()\
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
writeAged := func(name string, age time.Duration) {\
p := filepath.Join(dir, name)\
if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
t.Fatal(err)\
}\
mt := now.Add(-age)\
if err := os.Chtimes(p, mt, mt); err != nil {\
t.Fatal(err)\
}\
}\
writeAged("recent.jsonl", 5*24*time.Hour)\
writeAged("old.jsonl", 60*24*time.Hour)\
writeAged("skip.txt", 1*time.Hour)\
\
got, err := DiscoverSessions("", dir, now, nil)\
if err != nil {\
t.Fatal(err)\
}\
if len(got) != 1 || filepath.Base(got[0]) != "recent.jsonl" {\
t.Fatalf("lookback filter wrong: %v", got)\
}\
\
writeAged("abc123.jsonl", 1*24*time.Hour)\
got, err = DiscoverSessions("", dir, now, []string{"abc123"})\
if err != nil {\
t.Fatal(err)\
}\
if len(got) != 1 || filepath.Base(got[0]) != "abc123.jsonl" {\
t.Fatalf("session filter wrong: %v", got)\
}\
}\
\
func TestDiscoverSessions_MissingDirIsEmpty(t *testing.T) {\
t.Parallel()\
got, err := DiscoverSessions("", filepath.Join(t.TempDir(), "does-not-exist"), time.Now(), nil)\
if err != nil {\
t.Fatalf("missing dir should not error: %v", err)\
}\
if len(got) != 0 {\
t.Fatalf("expected empty, got %v", got)\
}\
}\
```\
\
Dcmd/entire/cli/importclaude/discover\_test.go-55\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
\
// Package importclaude imports pre-existing Claude Code transcripts into Entire\
// as read-only, commit-less ("orphaned") checkpoints on the local-only\
// entire/imports/v1 ref.\
package importclaude\
\
import (\
"crypto/sha256"\
"encoding/hex"\
\
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"\
)\
\
// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported\
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which\
// is how import stays idempotent.\
func DeriveCheckpointID(sessionID, turnUUID string) id.CheckpointID {\
sum := sha256.Sum256([]byte(sessionID + "/" + turnUUID))\
hexID := hex.EncodeToString(sum[:6]) // 6 bytes = 12 lowercase hex chars\
return id.MustCheckpointID(hexID)\
}\
```\
\
Dcmd/entire/cli/importclaude/id.go-20\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
\
package importclaude\
\
import "testing"\
\
func TestDeriveCheckpointID_StableAndDistinct(t *testing.T) {\
t.Parallel()\
a := DeriveCheckpointID("sess", "turn-1")\
b := DeriveCheckpointID("sess", "turn-1")\
c := DeriveCheckpointID("sess", "turn-2")\
if a != b {\
t.Errorf("not deterministic: %s != %s", a, b)\
}\
if a == c {\
t.Errorf("collision across turns: %s == %s", a, c)\
}\
if a.IsEmpty() {\
t.Error("derived id is empty")\
}\
}\
```\
\
Dcmd/entire/cli/importclaude/id\_test.go-19\
\
```\
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\
\
package importclaude\
\
import (\
"context"\
"fmt"\
"os"\
"path/filepath"\
"strings"\
"time"\
\
"github.com/go-git/go-git/v6"\
\
"github.com/entireio/cli/cmd/entire/cli/agent"\
cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"\
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"\
"github.com/entireio/cli/cmd/entire/cli/session"\
"github.com/entireio/cli/redact"\
)\
\
const importVersion = 1\
\
// Options configures an import run.\
type Options struct {\
RepoRoot string\
OverridePath string\
SessionFilter []string\
Now time.Time\
DryRun bool\
}\
\
// Result summarizes an import run.\
type Result struct {\
SessionsScanned int\
TurnsImported int\
TurnsSkipped int\
}\
\
// Run imports the repo's Claude Code transcripts (within the lookback window)\
// as read-only, local-only checkpoints on entire/imports/v1. It is idempotent:\
// turns whose deterministic ID already exists are skipped.\
func Run(ctx context.Context, repo *git.Repository, opts Options) (Result, error) {\
var res Result\
files, err := DiscoverSessions(opts.RepoRoot, opts.OverridePath, opts.Now, opts.SessionFilter)\
if err != nil {\
return res, err\
}\
\
stores, err := cp.Open(ctx, repo, cp.OpenOptions{})\
if err != nil {\
return res, fmt.Errorf("open checkpoint store: %w", err)\
}\
existing := make(map[string]bool)\
if infos, listErr := stores.Persistent.List(ctx); listErr == nil {\
for _, in := range infos {\
existing[in.CheckpointID.String()] = true\
}\
}\
\
for _, path := range files {\
res.SessionsScanned++\
sessionID := strings.TrimSuffix(filepath.Base(path), ".jsonl")\
full, readErr := os.ReadFile(path) //nolint:gosec // G304: path is a discovered .jsonl in the user's own Claude transcript dir\
if readErr != nil {\
return res, fmt.Errorf("read %s: %w", path, readErr)\
}\
subagentsDir := filepath.Join(filepath.Dir(path), sessionID, "subagents")\
turns, splitErr := SplitTurns(full, subagentsDir)\
if splitErr != nil {\
return res, splitErr\
}\
for _, turn := range turns {\
cid := DeriveCheckpointID(sessionID, turn.UUID)\
if existing[cid.String()] {\
res.TurnsSkipped++\
continue\
}\
if opts.DryRun {\
res.TurnsImported++ // counts what would import\
continue\
}\
if err := writeTurn(ctx, stores, cid, sessionID, path, full, turn); err != nil {\
return res, err\
}\
existing[cid.String()] = true\
res.TurnsImported++\
}\
}\
return res, nil\
}\
\
func writeTurn(ctx context.Context, stores *cp.Stores, cid id.CheckpointID, sessionID, path string, full []byte, turn Turn) error {\
red, err := redact.JSONLBytes(full)\
if err != nil {\
return fmt.Errorf("redact transcript: %w", err)\
}\
prov := &cp.Provenance{\
Source: "claude-code", TranscriptPath: path, SessionID: sessionID,\
TurnUUID: turn.UUID, ParentUUID: turn.ParentUUID,\
LineStart: turn.LineStart, LineEnd: turn.LineEnd,\
ContentHash: turn.ContentHash, ImportVersion: importVersion,\
}\
if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{\
CheckpointID: cid,\
SessionID: sessionID,\
CreatedAt: turn.CreatedAt,\
Strategy: "import",\
Kind: string(session.KindImported),\
Agent: agent.AgentTypeClaudeCode,\
Model: turn.Model,\
Transcript: red,\
Prompts: []string{turn.Prompt},\
CheckpointsCount: 1,\
CheckpointTranscriptStart: turn.LineStart,\
TokenUsage: turn.Tokens,\
Provenance: prov,\
})); err != nil {\
return fmt.Errorf("write imported checkpoint %s: %w", cid, err)\
}\
return nil\
}\
```\
\
Dcmd/entire/cli/importclaude/importer.go-120\
\
```\
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\
\
package importclaude\
\
import (\
"context"\
"os"\
"path/filepath"\
"strings"\
"testing"\
"time"\
\
"github.com/go-git/go-git/v6"\
"github.com/go-git/go-git/v6/plumbing/object"\
\
cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"\
"github.com/entireio/cli/cmd/entire/cli/testutil"\
)\
\
func initRepoWithCommit(t *testing.T) (*git.Repository, string) {\
t.Helper()\
repoDir := t.TempDir()\
testutil.InitRepo(t, repoDir)\
repo, err := git.PlainOpen(repoDir)\
if err != nil {\
t.Fatal(err)\
}\
wt, err := repo.Worktree()\
if err != nil {\
t.Fatal(err)\
}\
testutil.WriteFile(t, repoDir, "f.txt", "x")\
if _, err := wt.Add("f.txt"); err != nil {\
t.Fatal(err)\
}\
if _, err := wt.Commit("init", &git.CommitOptions{\
Author: &object.Signature{Name: "Test", Email: "test@test.com"},\
}); err != nil {\
t.Fatal(err)\
}\
return repo, repoDir\
}\
\
func writeFixtureSession(t *testing.T, dir, name string) {\
t.Helper()\
content := strings.Join([]string{\
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,\
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
}, "\n") + "\n"\
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {\
t.Fatal(err)\
}\
}\
\
func TestRun_ImportsAndIsIdempotent(t *testing.T) {\
t.Parallel()\
repo, repoDir := initRepoWithCommit(t)\
claudeDir := t.TempDir()\
writeFixtureSession(t, claudeDir, "sess1.jsonl")\
\
opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}\
\
res, err := Run(context.Background(), repo, opts)\
if err != nil {\
t.Fatal(err)\
}\
if res.TurnsImported != 2 {\
t.Fatalf("want 2 imported, got %+v", res)\
}\
\
res2, err := Run(context.Background(), repo, opts)\
if err != nil {\
t.Fatal(err)\
}\
if res2.TurnsImported != 0 || res2.TurnsSkipped != 2 {\
t.Fatalf("re-run not idempotent: %+v", res2)\
}\
\
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})\
if err != nil {\
t.Fatal(err)\
}\
infos, err := stores.Persistent.List(context.Background())\
if err != nil {\
t.Fatal(err)\
}\
if len(infos) != 2 {\
t.Fatalf("expected 2 imported checkpoints on v1, got %+v", infos)\
}\
for _, in := range infos {\
if !in.Imported {\
t.Fatalf("checkpoint %s missing Imported flag: %+v", in.CheckpointID, in)\
}\
}\
}\
\
func TestRun_DryRunWritesNothing(t *testing.T) {\
t.Parallel()\
repo, repoDir := initRepoWithCommit(t)\
claudeDir := t.TempDir()\
writeFixtureSession(t, claudeDir, "sess1.jsonl")\
\
res, err := Run(context.Background(), repo, Options{\
RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true,\
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),\
})\
if err != nil {\
t.Fatal(err)\
}\
if res.TurnsImported != 2 {\
t.Fatalf("dry-run should count 2 turns, got %+v", res)\
}\
\
stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})\
if err != nil {\
t.Fatal(err)\
}\
infos, err := stores.Persistent.List(context.Background())\
if err != nil {\
t.Fatal(err)\
}\
if len(infos) != 0 {\
t.Fatalf("dry-run must not write, got %+v", infos)\
}\
}\
```\
\
Dcmd/entire/cli/importclaude/importer\_test.go-124\
\
```\
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\
\
package importclaude\
\
import (\
"crypto/sha256"\
"encoding/hex"\
"encoding/json"\
"fmt"\
"strings"\
"time"\
\
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"\
"github.com/entireio/cli/cmd/entire/cli/agent/types"\
"github.com/entireio/cli/cmd/entire/cli/transcript"\
)\
\
// Turn is one user-prompt turn extracted from a session transcript. Line\
// offsets are in raw-line space (newline-counted), matching the offsets used by\
// transcript.SliceFromLine and the agent token-usage helpers.\
type Turn struct {\
LineStart, LineEnd int\
UUID, ParentUUID string\
Prompt, Model string\
CreatedAt time.Time\
Tokens *types.TokenUsage\
ContentHash string\
}\
\
// extraFields are line fields not modeled by transcript.Line.\
type extraFields struct {\
ParentUUID string `json:"parentUuid"`\
Timestamp string `json:"timestamp"`\
Message struct {\
Model string `json:"model"`\
} `json:"message"`\
}\
\
// SplitTurns produces one Turn per user-prompt line. Token usage for each turn\
// is computed on the slice [LineStart, LineEnd) so turns don't double-count\
// later turns. tool_result lines (Type == "user" but no text content) do not\
// start a turn.\
func SplitTurns(full []byte, subagentsDir string) ([]Turn, error) {\
rawLines := splitRawLines(full)\
\
// Identify user-prompt turn starts in raw-line space.\
var starts []int\
for i, raw := range rawLines {\
if isUserPromptLine(raw) {\
starts = append(starts, i)\
}\
}\
\
ag := &claudecode.ClaudeCodeAgent{}\
turns := make([]Turn, 0, len(starts))\
for k, start := range starts {\
end := len(rawLines)\
if k+1 < len(starts) {\
end = starts[k+1]\
}\
\
// Bound token usage to [start, end): truncate to the first `end` lines,\
// then let the agent helper slice from `start`.\
truncated := joinLines(rawLines[:end])\
tokens, err := ag.CalculateTotalTokenUsage(truncated, start, subagentsDir)\
if err != nil {\
return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
}\
\
var rec struct {\
UUID string `json:"uuid"`\
Message json.RawMessage `json:"message"`\
ParentUUID string `json:"parentUuid"`\
Timestamp string `json:"timestamp"`\
}\
if err := json.Unmarshal(rawLines[start], &rec); err != nil {\
// Already validated as a user-prompt line in isUserPromptLine; skip\
// defensively if it somehow fails to parse here.\
continue\
}\
ts, parseErr := time.Parse(time.RFC3339, rec.Timestamp)\
if parseErr != nil {\
ts = time.Time{}\
}\
\
slice := joinLines(rawLines[start:end])\
sum := sha256.Sum256(slice)\
\
turns = append(turns, Turn{\
LineStart: start,\
LineEnd: end,\
UUID: rec.UUID,\
ParentUUID: rec.ParentUUID,\
Prompt: transcript.ExtractUserContent(rec.Message),\
Model: modelInRange(rawLines, start, end),\
CreatedAt: ts,\
Tokens: tokens,\
ContentHash: "sha256:" + hex.EncodeToString(sum[:]),\
})\
}\
return turns, nil\
}\
\
// modelInRange returns the model from the first assistant message within\
// [start, end), or "" when none carries one. The model lives on assistant\
// lines, not the user-prompt line.\
func modelInRange(rawLines [][]byte, start, end int) string {\
for i := start; i < end && i < len(rawLines); i++ {\
var line transcript.Line\
if err := json.Unmarshal(rawLines[i], &line); err != nil {\
continue\
}\
if line.Type != "assistant" {\
continue\
}\
var ex extraFields\
if err := json.Unmarshal(rawLines[i], &ex); err != nil {\
continue\
}\
if ex.Message.Model != "" {\
return ex.Message.Model\
}\
}\
return ""\
}\
\
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt\
// turn start: type "user" (or role "user") with non-empty extractable text.\
// tool_result lines are type "user" but carry no text, so they return false.\
func isUserPromptLine(raw []byte) bool {\
var line transcript.Line\
if err := json.Unmarshal(raw, &line); err != nil {\
return false\
}\
typ := line.Type\
if typ == "" {\
typ = line.Role\
}\
if typ != "user" {\
return false\
}\
return transcript.ExtractUserContent(line.Message) != ""\
}\
\
// splitRawLines splits content into raw lines in the same index space as\
// transcript.SliceFromLine (newline-counted). Trailing empty segment from a\
// final newline is dropped.\
func splitRawLines(content []byte) [][]byte {\
if len(content) == 0 {\
return nil\
}\
parts := strings.Split(string(content), "\n")\
if len(parts) > 0 && parts[len(parts)-1] == "" {\
parts = parts[:len(parts)-1]\
}\
out := make([][]byte, len(parts))\
for i, p := range parts {\
out[i] = []byte(p)\
}\
return out\
}\
\
// joinLines reassembles raw lines into newline-terminated bytes.\
func joinLines(lines [][]byte) []byte {\
if len(lines) == 0 {\
return nil\
}\
strs := make([]string, len(lines))\
for i, l := range lines {\
strs[i] = string(l)\
}\
return []byte(strings.Join(strs, "\n") + "\n")\
}\
```\
\
Dcmd/entire/cli/importclaude/turns.go-171\
\
```\
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\
\
package importclaude\
\
import (\
"strings"\
"testing"\
)\
\
func TestSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {\
t.Parallel()\
full := []byte(strings.Join([]string{\
`{"type":"user","uuid":"u1","parentUuid":"","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}}`,\
`{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
`{"type":"assistant","uuid":"a2","message":{"id":"m2","model":"claude-x","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":20,"output_tokens":7}}}`,\
}, "\n") + "\n")\
\
turns, err := SplitTurns(full, "")\
if err != nil {\
t.Fatal(err)\
}\
if len(turns) != 2 {\
t.Fatalf("want 2 turns, got %d", len(turns))\
}\
if turns[0].LineStart != 0 || turns[0].LineEnd != 2 {\
t.Errorf("turn0 bounds = [%d,%d), want [0,2)", turns[0].LineStart, turns[0].LineEnd)\
}\
if turns[0].Prompt != "first" || turns[1].Prompt != "second" {\
t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
}\
if turns[0].UUID != "u1" || turns[1].ParentUUID != "a1" {\
t.Errorf("uuid/parent wrong: %+v %+v", turns[0], turns[1])\
}\
if turns[0].Model != "claude-x" {\
t.Errorf("turn0 model = %q, want claude-x", turns[0].Model)\
}\
if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
}\
if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
}\
}\
\
func TestSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
t.Parallel()\
full := []byte(strings.Join([]string{\
`{"type":"user","uuid":"u1","message":{"role":"user","content":"do it"}}`,\
`{"type":"assistant","uuid":"a1","message":{"id":"m1","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"output_tokens":3}}}`,\
`{"type":"user","uuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
}, "\n") + "\n")\
turns, err := SplitTurns(full, "")\
if err != nil {\
t.Fatal(err)\
}\
if len(turns) != 1 {\
t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
}\
}\
```\
\
Dcmd/entire/cli/importclaude/turns\_test.go-58\
\
```\
9 unmodified lines\
\
10\
11\
12\
13\
13\
14\
15\
16\
27 unmodified lines\
\
44\
45\
46\
47\
47\
48\
49\
50\
\
9 unmodified lines\
\
"github.com/stretchr/testify/require"\
\
"github.com/entireio/cli/cmd/entire/cli/importclaude"\
"github.com/entireio/cli/cmd/entire/cli/agentimport"\
)\
\
func TestImportClaudeCode_EndToEnd(t *testing.T) {\
27 unmodified lines\
\
// 4. explain resolves an imported checkpoint by ID (regression: explain once\
// only consulted the committed/shadow paths and missed imports).\
importedID := importclaude.DeriveCheckpointID(sessionID, "u1").String()\
importedID := agentimport.DeriveCheckpointID(sessionID, "u1").String()\
explainOut := env.RunCLI("checkpoint", "explain", importedID)\
require.Contains(t, explainOut, "first", "explain should show the imported turn's prompt; got: %s", explainOut)\
```\
\
Mcmd/entire/cli/integration\_test/import\_claude\_test.go+2/-2\
\
```\
13 unmodified lines\
\
14\
15\
16\
17\
17\
18\
19\
1151 unmodified lines\
\
1171\
1172\
1173\
1175\
1176\
1177\
1178\
1174\
1175\
1176\
688 unmodified lines\
\
1865\
1866\
1867\
1873\
1874\
1875\
1876\
1877\
1878\
1879\
1880\
1881\
1882\
1883\
1884\
1885\
1886\
1887\
1888\
1889\
1890\
1891\
1892\
1893\
1894\
1895\
1896\
1897\
1898\
1899\
1900\
1901\
1902\
1903\
1904\
1905\
1906\
1907\
1908\
1909\
1910\
1911\
1912\
1913\
1914\
1915\
1916\
1917\
1918\
1919\
1920\
1921\
1922\
1923\
1924\
1925\
1926\
1927\
1928\
1929\
1930\
1931\
1932\
1933\
1934\
1868\
1869\
1870\
\
13 unmodified lines\
\
"github.com/entireio/cli/cmd/entire/cli/agent/external"\
"github.com/entireio/cli/cmd/entire/cli/agent/types"\
"github.com/entireio/cli/cmd/entire/cli/gitremote"\
"github.com/entireio/cli/cmd/entire/cli/importclaude"\
"github.com/entireio/cli/cmd/entire/cli/interactive"\
"github.com/entireio/cli/cmd/entire/cli/logging"\
"github.com/entireio/cli/cmd/entire/cli/paths"\
1151 unmodified lines\
\
return nil\
}\
\
if err := promptImportClaudeContext(ctx, w, opts); err != nil {\
return err\
}\
\
fmt.Fprintln(w, "\nReady.")\
\
// Note about empty repos at the end, after setup is complete\
688 unmodified lines\
\
return nil\
}\
\
// promptImportClaudeContext offers, at enable time, to import the repo's\
// existing Claude Code transcripts (past month) as local, read-only history.\
// No-op when non-interactive, under --yes, or when nothing is found.\
func promptImportClaudeContext(ctx context.Context, w io.Writer, opts EnableOptions) error {\
if opts.Yes || !interactive.CanPromptInteractively() {\
return nil\
}\
repoRoot, err := paths.WorktreeRoot(ctx)\
if err != nil {\
return nil\
}\
now := time.Now()\
files, err := importclaude.DiscoverSessions(repoRoot, "", now, nil)\
if err != nil || len(files) == 0 {\
return nil\
}\
turnCount := 0\
for _, f := range files {\
data, rerr := os.ReadFile(f) //nolint:gosec // G304: f is a discovered .jsonl in the user's own Claude transcript dir\
if rerr != nil {\
continue\
}\
turns, terr := importclaude.SplitTurns(data, "")\
if terr != nil {\
continue\
}\
turnCount += len(turns)\
}\
if turnCount == 0 {\
return nil\
}\
\
doImport := false\
form := NewAccessibleForm(\
huh.NewGroup(\
huh.NewConfirm().\
Title(fmt.Sprintf("Found %d past Claude session turn(s) for this repo from the past month.", turnCount)).\
Description("Import them as local, read-only history? It stays on your machine and is never pushed.").\
Affirmative("Yes").\
Negative("No").\
Value(&doImport),\
),\
)\
if err := form.Run(); err != nil {\
return fmt.Errorf("import prompt: %w", err)\
}\
if !doImport {\
return nil\
}\
repo, err := strategy.OpenRepository(ctx)\
if err != nil {\
return fmt.Errorf("open repository: %w", err)\
}\
defer repo.Close()\
res, err := importclaude.Run(ctx, repo, importclaude.Options{RepoRoot: repoRoot, Now: now})\
if err != nil {\
return fmt.Errorf("import claude history: %w", err)\
}\
fmt.Fprintf(w, " ✓ Imported %d turn(s) of Claude history (local, read-only)\n", res.TurnsImported)\
return nil\
}\
\
// promptTelemetryConsent asks the user if they want to enable telemetry.\
// It modifies settings.Telemetry based on the user's choice or flags.\
// The caller is responsible for saving settings.\
```\
\
Mcmd/entire/cli/setup.go-67\
\
```\
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\
\
package cli\
\
import (\
"bytes"\
"context"\
"os"\
"path/filepath"\
"testing"\
\
"github.com/entireio/cli/cmd/entire/cli/checkpoint"\
"github.com/entireio/cli/cmd/entire/cli/strategy"\
"github.com/entireio/cli/cmd/entire/cli/testutil"\
)\
\
func TestPromptImportClaudeContext_SkipsWhenNoTranscripts(t *testing.T) {\
// Not parallel: t.Chdir + t.Setenv.\
repoDir := t.TempDir()\
testutil.InitRepo(t, repoDir)\
t.Chdir(repoDir)\
t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", t.TempDir()) // empty dir\
t.Setenv("ENTIRE_TEST_TTY", "0") // non-interactive\
\
var out bytes.Buffer\
if err := promptImportClaudeContext(context.Background(), &out, EnableOptions{}); err != nil {\
t.Fatal(err)\
}\
if out.Len() != 0 {\
t.Fatalf("expected silent no-op, got %q", out.String())\
}\
}\
\
func TestPromptImportClaudeContext_NonInteractiveDoesNotImport(t *testing.T) {\
// Not parallel: t.Chdir + t.Setenv.\
repoDir := t.TempDir()\
testutil.InitRepo(t, repoDir)\
testutil.WriteFile(t, repoDir, "f.txt", "x")\
testutil.GitAdd(t, repoDir, "f.txt")\
testutil.GitCommit(t, repoDir, "init")\
t.Chdir(repoDir)\
\
cdir := t.TempDir()\
if err := os.WriteFile(filepath.Join(cdir, "s.jsonl"),\
[]byte(`{"type":"user","uuid":"u1","message":{"role":"user","content":"hi"}}`+"\n"), 0o644); err != nil {\
t.Fatal(err)\
}\
t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", cdir)\
t.Setenv("ENTIRE_TEST_TTY", "0") // non-interactive => must not prompt or import\
\
var out bytes.Buffer\
if err := promptImportClaudeContext(context.Background(), &out, EnableOptions{}); err != nil {\
t.Fatal(err)\
}\
\
repo, err := strategy.OpenRepository(context.Background())\
if err != nil {\
t.Fatal(err)\
}\
defer repo.Close()\
stores, err := checkpoint.Open(context.Background(), repo, checkpoint.OpenOptions{})\
if err != nil {\
t.Fatal(err)\
}\
infos, err := stores.Persistent.List(context.Background())\
if err != nil {\
t.Fatal(err)\
}\
if len(infos) != 0 {\
t.Fatalf("non-interactive import should not have written: %+v", infos)\
}\
}\
```\
\
Dcmd/entire/cli/setup\_import\_prompt\_test.go-70