Problem
~/.claude/projects grows silently — long-lived users accumulate gigabytes of JSONL transcripts, and there is no tool to see where the space goes or to reclaim it safely. ccsession already knows how to enumerate every transcript, so it is the natural place for a disk-usage report and a guarded cleanup command.
UX spec
ccsession gc # DRY RUN: list deletion candidates + total size, delete nothing
ccsession gc --older-than 90d # change the age threshold (default 90d)
ccsession gc --min-size 100kb # additionally require a minimum file size
ccsession gc --rm # actually delete, after an interactive y/N confirmation
ccsession gc --rm --yes # delete without prompting (for scripts)
ccsession gc --archive ~/backup # write candidates into a tar.gz under ~/backup, then delete them
ccsession gc --du # per-project disk usage summary, no candidates, never deletes
Dry-run / candidate output (TSV, aligned with list conventions):
AGE SIZE DIR LABEL PATH
182d 4.2MB ccsession fix preview highlight ... /Users/x/.claude/projects/.../abc.jsonl
120d 890KB myapp add login endpoint ... /Users/x/.claude/projects/.../def.jsonl
--
2 sessions, 5.1MB total (dry run — pass --rm to delete, --archive DIR to archive)
--du output:
SIZE SESSIONS PROJECT
1.8GB 412 /Users/x/.claude/projects/-Users-x-ghq-...-myapp
340MB 98 /Users/x/.claude/projects/-Users-x-ghq-...-ccsession
--
2.2GB total, 510 sessions
Scope: claude source only
This command operates on the Claude JSONL store only. OpenCode is a single SQLite database and Grok/Codex have different store layouts; deleting inside those is a separate feature. If CCSESSION_SOURCE (or --source) selects anything other than the claude default, gc must fail fast with: gc currently supports the claude source only.
Implementation plan
1. New package internal/gc
gc.Options{OlderThan time.Duration, MinSize int64, Mode (DryRun|Delete|Archive), ArchiveDir string, Yes bool, DU bool, Out io.Writer}.
- Candidate discovery:
- Root via
session.ProjectsDir() (internal/session/scan.go:17) so CLAUDE_CONFIG_DIR is honored.
- Enumerate with
session.CollectJSONLPaths (scan.go:69), then os.Lstat each path: skip anything that is not a regular file (symlinks explicitly excluded), record size and mtime.
- A path is a candidate iff
now-mtime > OlderThan AND size >= MinSize.
- Labels: parse candidates with
session.ParseSessionTail for display only; a file that fails to parse (ErrSessionEmpty, corrupt) is still a candidate — unparseable old files are exactly the junk gc should reclaim. Show (unparseable) as the label.
- Safety invariants (enforce in code, not just by construction):
- every deleted path must be inside
ProjectsDir() (verify with filepath.Rel — reject .. escapes) and have the .jsonl suffix;
- never touch a file whose mtime is within the last 7 days, regardless of flags — a hard floor so
--older-than 0s cannot nuke live sessions;
- deletion also removes a matching
<id> sidecar only if one exists today — as of now there are none, so: delete exactly the JSONL file, leave directories in place even if empty.
- Archive mode: create
ccsession-gc-<YYYYMMDD-HHMMSS>.tar.gz (timestamp from a clock passed in, see Testing) under ArchiveDir containing candidates with paths relative to ProjectsDir(). Delete originals only after the tar.gz is fully written, fsynced, and closed. On any archive error: delete nothing, remove the partial tar.gz, return the error.
--du: group os.Lstat results by the first path segment under ProjectsDir(), sort by total size descending.
2. Flag parsing helpers
--older-than accepts 90d, 24h, 30m: parse a trailing d as 24h*n and delegate the rest to time.ParseDuration (Go's parser has no days unit). Put the helper in internal/gc with tests.
--min-size accepts 100kb, 2mb, 1gb (case-insensitive, decimal 1000-based or 1024-based — pick 1024 and document it) plus bare bytes. Same file.
3. CLI wiring
- New case
"gc" in the subcommand switch in cmd/ccsession/main.go (main.go:184), flag parsing mirroring cmdList's style.
- Confirmation prompt for
--rm without --yes: print the candidate summary, then read a line from stdin; proceed only on y/yes. If stdin is not a tty, refuse and tell the user to pass --yes.
--rm and --archive are mutually exclusive; both imply "not a dry run".
- Update usage text and README (new "Housekeeping" subsection under Usage).
Edge cases
- Empty projects dir / no candidates: print
nothing to clean and exit 0.
- Partial failure while deleting (one file EPERM): continue with the rest, report each failure to stderr, exit non-zero at the end.
--archive dir does not exist: create it with os.MkdirAll.
- Candidate file disappears between listing and deletion (session deleted by claude itself): treat
os.IsNotExist on remove as success.
--du with unreadable subdirectory: report to stderr, continue.
Testing
Table-driven tests in internal/gc using t.TempDir() as a fake projects dir (set CLAUDE_CONFIG_DIR via t.Setenv):
- candidate selection across age/size boundaries, including the 7-day hard floor and symlink exclusion;
- duration/size parsers (valid, invalid, units);
- delete mode removes exactly the candidates and nothing else;
- archive mode: tar.gz round-trips (extract and compare), originals gone, and the "archive write fails → nothing deleted" invariant (make ArchiveDir read-only);
--du aggregation and sort order;
- non-claude source fails fast.
Inject now (a time.Time field on Options, defaulting to time.Now() in the CLI layer) so age boundaries are deterministic.
Non-goals
- gc for opencode/grok/codex stores.
- Interactive multi-select via fzf (
--multi) — a possible follow-up once the flag-driven core exists.
- Any persistent config for thresholds; flags only.
Acceptance criteria
ccsession gc never modifies the filesystem without --rm or --archive.
--rm prompts by default, --yes skips the prompt, --archive produces a tar.gz that round-trips.
- The 7-day hard floor holds for every mode and flag combination.
ccsession gc --du gives an accurate per-project size/count summary.
gofmt -l . clean, go vet ./... clean, golangci-lint run clean, new tests pass with go test ./....
Problem
~/.claude/projectsgrows silently — long-lived users accumulate gigabytes of JSONL transcripts, and there is no tool to see where the space goes or to reclaim it safely.ccsessionalready knows how to enumerate every transcript, so it is the natural place for a disk-usage report and a guarded cleanup command.UX spec
Dry-run / candidate output (TSV, aligned with
listconventions):--duoutput:Scope: claude source only
This command operates on the Claude JSONL store only. OpenCode is a single SQLite database and Grok/Codex have different store layouts; deleting inside those is a separate feature. If
CCSESSION_SOURCE(or--source) selects anything other than the claude default,gcmust fail fast with:gc currently supports the claude source only.Implementation plan
1. New package
internal/gcgc.Options{OlderThan time.Duration, MinSize int64, Mode (DryRun|Delete|Archive), ArchiveDir string, Yes bool, DU bool, Out io.Writer}.session.ProjectsDir()(internal/session/scan.go:17) soCLAUDE_CONFIG_DIRis honored.session.CollectJSONLPaths(scan.go:69), thenos.Lstateach path: skip anything that is not a regular file (symlinks explicitly excluded), record size and mtime.now-mtime > OlderThanANDsize >= MinSize.session.ParseSessionTailfor display only; a file that fails to parse (ErrSessionEmpty, corrupt) is still a candidate — unparseable old files are exactly the junk gc should reclaim. Show(unparseable)as the label.ProjectsDir()(verify withfilepath.Rel— reject..escapes) and have the.jsonlsuffix;--older-than 0scannot nuke live sessions;<id>sidecar only if one exists today — as of now there are none, so: delete exactly the JSONL file, leave directories in place even if empty.ccsession-gc-<YYYYMMDD-HHMMSS>.tar.gz(timestamp from a clock passed in, see Testing) underArchiveDircontaining candidates with paths relative toProjectsDir(). Delete originals only after the tar.gz is fully written, fsynced, and closed. On any archive error: delete nothing, remove the partial tar.gz, return the error.--du: groupos.Lstatresults by the first path segment underProjectsDir(), sort by total size descending.2. Flag parsing helpers
--older-thanaccepts90d,24h,30m: parse a trailingdas24h*nand delegate the rest totime.ParseDuration(Go's parser has no days unit). Put the helper ininternal/gcwith tests.--min-sizeaccepts100kb,2mb,1gb(case-insensitive, decimal 1000-based or 1024-based — pick 1024 and document it) plus bare bytes. Same file.3. CLI wiring
"gc"in the subcommand switch incmd/ccsession/main.go(main.go:184), flag parsing mirroringcmdList's style.--rmwithout--yes: print the candidate summary, then read a line from stdin; proceed only ony/yes. If stdin is not a tty, refuse and tell the user to pass--yes.--rmand--archiveare mutually exclusive; both imply "not a dry run".Edge cases
nothing to cleanand exit 0.--archivedir does not exist: create it withos.MkdirAll.os.IsNotExiston remove as success.--duwith unreadable subdirectory: report to stderr, continue.Testing
Table-driven tests in
internal/gcusingt.TempDir()as a fake projects dir (setCLAUDE_CONFIG_DIRviat.Setenv):--duaggregation and sort order;Inject
now(atime.Timefield on Options, defaulting totime.Now()in the CLI layer) so age boundaries are deterministic.Non-goals
--multi) — a possible follow-up once the flag-driven core exists.Acceptance criteria
ccsession gcnever modifies the filesystem without--rmor--archive.--rmprompts by default,--yesskips the prompt,--archiveproduces a tar.gz that round-trips.ccsession gc --dugives an accurate per-project size/count summary.gofmt -l .clean,go vet ./...clean,golangci-lint runclean, new tests pass withgo test ./....