From 5390cf24e2385fd780998c22d49e8c1fa10cee11 Mon Sep 17 00:00:00 2001 From: andyhtran Date: Fri, 1 May 2026 09:42:43 -0400 Subject: [PATCH] feat: skill command group + bundled SKILL.md Ship cct as a Claude Code skill so agents auto-discover the tool without the user manually copying SKILL.md into ~/.claude/skills/. The skill content is embedded in the binary; cct skill install creates a symlink at ~/.claude/skills/cct -> ~/.cache/cct/skills/cct, and the live copy auto-syncs from the embedded version on every cct invocation so brew upgrade cct keeps the on-disk skill aligned with the binary. Until installed, every cct invocation prints a one-line install hint to stderr (rate-limited to once per 24h, dismissible via cct skill nudge off). Skill content is reordered by real usage frequency from session history (search > export > info > list) and includes explicit anti-triggers for grep ~/.claude/projects/ and the non-existent cct show. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 + README.md | 13 +- internal/app/cli.go | 18 ++ internal/app/skill.go | 130 ++++++++++ internal/paths/paths.go | 31 +++ internal/skill/embed.go | 48 ++++ internal/skill/nudge.go | 73 ++++++ internal/skill/skill_test.go | 343 +++++++++++++++++++++++++ internal/skill/status.go | 59 +++++ internal/skill/symlink.go | 76 ++++++ internal/skill/sync.go | 76 ++++++ skills/cct/SKILL.md | 111 ++++++++ skills/cct/references/commands.md | 159 ++++++++++++ skills/cct/references/search-syntax.md | 66 +++++ skills/embed.go | 12 + 15 files changed, 1219 insertions(+), 6 deletions(-) create mode 100644 internal/app/skill.go create mode 100644 internal/skill/embed.go create mode 100644 internal/skill/nudge.go create mode 100644 internal/skill/skill_test.go create mode 100644 internal/skill/status.go create mode 100644 internal/skill/symlink.go create mode 100644 internal/skill/sync.go create mode 100644 skills/cct/SKILL.md create mode 100644 skills/cct/references/commands.md create mode 100644 skills/cct/references/search-syntax.md create mode 100644 skills/embed.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1767acd..ed673d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- `skill` command group: ship a Claude Code skill bundled in the cct binary so agents auto-discover the tool. `cct skill install` creates a symlink at `~/.claude/skills/cct/` pointing at `~/.cache/cct/skills/cct/`; the live copy auto-syncs from the embedded version on every cct invocation, so `brew upgrade cct` keeps the on-disk skill aligned with the binary. Idempotent with conflict detection — refuses to overwrite a foreign file or symlink. +- `skill uninstall`: removes only the symlink, preserves the live copy for fast reinstall. +- `skill status`: reports install state, symlink target, sync state (embedded vs. live hash), and nudge state. `--json` for tooling. +- Install nudge: until the skill is installed, cct prints a one-line hint to stderr (rate-limited to once per 24h). `cct skill nudge on|off|status` controls it. +- Skill content: SKILL.md plus `references/commands.md` and `references/search-syntax.md`. Workflows ordered by real-world frequency from session history; explicit anti-triggers (`grep ~/.claude/projects/`, the non-existent `cct show`); full JSON schemas for `stats`, `list`, and `search`. + ## [1.5.1] - 2026-04-23 ### Fixed diff --git a/README.md b/README.md index 20d35b3..cc5d97c 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,13 @@ cct resume # cd to project dir and run claude --resume ## Use with Claude Code agents -Add to your `CLAUDE.md` to let Claude search your session history: +`cct skill install` ships a Claude Code skill that the harness auto-loads when you reference past sessions. The skill is embedded in the cct binary and updates with each `brew upgrade cct` — no manual file copying. -```markdown -Use `cct search ` to find relevant past sessions. -Use `cct export --full` to read full conversation context. -Use `cct changelog --search ` to look up Claude Code features, behavior changes, or disable flags. +```bash +cct skill install # creates a symlink at ~/.claude/skills/cct +cct skill status # check install/sync/nudge state +cct skill uninstall # remove the symlink (live copy preserved) +cct skill nudge off # silence the install prompt ``` Then prompt naturally: @@ -85,7 +86,7 @@ Then prompt naturally: use cct to find sessions where we debugged the auth issue ``` -This turns your session history into a searchable knowledge base that Claude can query. +The skill describes canonical workflows (search→export, list→info, JSON+jq pipelines) and explicit anti-patterns so agents prefer cct over `grep ~/.claude/projects/`. Until installed, cct prints a one-line install hint to stderr (rate-limited to once per 24h); run `cct skill nudge off` to silence. ## Preserving session history diff --git a/internal/app/cli.go b/internal/app/cli.go index a148434..69d00a0 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -4,8 +4,11 @@ import ( "errors" "fmt" "os" + "strings" "github.com/alecthomas/kong" + + "github.com/andyhtran/cct/internal/skill" ) type ExitError struct{ Code int } @@ -33,6 +36,7 @@ type CLI struct { Schema SchemaCmd `cmd:"" help:"Show CLI schema as JSON (for tooling)"` Index IndexCmd `cmd:"" help:"Manage search index"` Backup BackupCmd `cmd:"" help:"Back up session JSONL files (guards against upstream cleanup bugs).\n\nRun 'cct backup sweep' periodically (cron, shell hook, or manually).\ncct never modifies ~/.claude/settings.json."` + Skill SkillCmd `cmd:"" help:"Manage the cct Claude Code skill (install/uninstall/status/nudge)"` } type Globals struct { @@ -63,7 +67,21 @@ func Run(version string) int { return 1 } + // Skip skill side effects for `cct skill *` (would be circular) and for + // `cct schema` (output is consumed by tooling that expects clean stdout). + selected := ctx.Command() + skillSideEffects := !strings.HasPrefix(selected, "skill") && !strings.HasPrefix(selected, "schema") + + if skillSideEffects { + skill.SyncQuiet() + } + err = ctx.Run(&cli.Globals, k) + + if skillSideEffects { + skill.MaybeNudge(os.Stderr) + } + if err != nil { var exitErr *ExitError if errors.As(err, &exitErr) { diff --git a/internal/app/skill.go b/internal/app/skill.go new file mode 100644 index 0000000..cb6379e --- /dev/null +++ b/internal/app/skill.go @@ -0,0 +1,130 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/andyhtran/cct/internal/skill" +) + +type SkillCmd struct { + Install SkillInstallCmd `cmd:"" help:"Install the cct Claude Code skill (creates a symlink at ~/.claude/skills/cct)"` + Uninstall SkillUninstallCmd `cmd:"" help:"Remove the symlink at ~/.claude/skills/cct (live copy is preserved)"` + Status SkillStatusCmd `cmd:"" help:"Show install state, symlink target, sync state, and nudge state"` + Nudge SkillNudgeCmd `cmd:"" help:"Toggle the install-prompt nudge"` +} + +type SkillInstallCmd struct{} + +func (cmd *SkillInstallCmd) Run(globals *Globals) error { + if err := skill.Install(); err != nil { + return fmt.Errorf("install: %w", err) + } + if globals.JSON { + return jsonEncode(map[string]string{"status": "installed"}) + } + fmt.Println("Installed cct skill at ~/.claude/skills/cct") + return nil +} + +type SkillUninstallCmd struct{} + +func (cmd *SkillUninstallCmd) Run(globals *Globals) error { + if err := skill.Uninstall(); err != nil { + return fmt.Errorf("uninstall: %w", err) + } + if globals.JSON { + return jsonEncode(map[string]string{"status": "uninstalled"}) + } + fmt.Println("Removed cct skill symlink (live copy preserved at ~/.cache/cct/skills/cct)") + return nil +} + +type SkillStatusCmd struct{} + +func (cmd *SkillStatusCmd) Run(globals *Globals) error { + s, err := skill.GetStatus() + if err != nil { + return err + } + if globals.JSON { + return jsonEncode(s) + } + + fmt.Printf("Installed: %s\n", yesNo(s.Installed)) + fmt.Printf("Symlink: %s\n", s.SymlinkPath) + switch { + case s.SymlinkTarget == "": + fmt.Println(" (no symlink)") + case s.OurSymlink: + fmt.Printf(" → %s\n", s.SymlinkTarget) + default: + fmt.Printf(" → %s (foreign — not managed by cct)\n", s.SymlinkTarget) + } + fmt.Printf("Live dir: %s\n", s.LiveDir) + switch { + case s.LiveHash == "": + fmt.Println("Sync: not yet extracted (run any cct command to populate)") + case s.InSync: + fmt.Println("Sync: in sync with embedded version") + default: + fmt.Println("Sync: out of date (next cct invocation will resync)") + } + nudgeWord := "disabled" + if s.NudgeEnabled { + nudgeWord = "enabled" + } + fmt.Printf("Nudge: %s", nudgeWord) + if s.NudgeLastShown != "" { + fmt.Printf(" (last shown %s)", s.NudgeLastShown) + } + fmt.Println() + return nil +} + +type SkillNudgeCmd struct { + State string `arg:"" enum:"on,off,status" help:"on, off, or status"` +} + +func (cmd *SkillNudgeCmd) Run(globals *Globals) error { + switch cmd.State { + case "on": + if err := skill.SetNudgeEnabled(true); err != nil { + return err + } + if !globals.JSON { + fmt.Println("Nudge enabled") + } + case "off": + if err := skill.SetNudgeEnabled(false); err != nil { + return err + } + if !globals.JSON { + fmt.Println("Nudge disabled") + } + case "status": + state := "disabled" + if skill.NudgeEnabled() { + state = "enabled" + } + if globals.JSON { + return jsonEncode(map[string]string{"nudge": state}) + } + fmt.Println(state) + } + return nil +} + +func jsonEncode(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 89ba82a..9ae080d 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -52,3 +52,34 @@ func BackupProjectsDir() string { func BackupManifestPath() string { return filepath.Join(BackupDir(), "manifest.json") } + +// ClaudeSkillsDir is the user-global skills directory the Claude Code harness +// scans at session start. +func ClaudeSkillsDir() string { + return filepath.Join(ClaudeDir(), "skills") +} + +// SkillLiveDir is the on-disk extraction of the embedded cct skill content. +// Symlinked from SkillSymlinkPath() on install; safe to delete (regenerated +// on next cct invocation). +func SkillLiveDir() string { + return filepath.Join(CacheDir(), "skills", "cct") +} + +// SkillSymlinkPath is where ~/.claude/skills/cct lives. Always a symlink to +// SkillLiveDir() when managed by cct. +func SkillSymlinkPath() string { + return filepath.Join(ClaudeSkillsDir(), "cct") +} + +// SkillNudgeLastPath stores the unix timestamp of the most recent install +// nudge so we can rate-limit it to once per 24h. +func SkillNudgeLastPath() string { + return filepath.Join(CacheDir(), "skill-nudge-last") +} + +// SkillNudgeDisabledPath, when present, suppresses the install nudge entirely +// (set by `cct skill nudge off`). +func SkillNudgeDisabledPath() string { + return filepath.Join(CacheDir(), "skill-nudge-disabled") +} diff --git a/internal/skill/embed.go b/internal/skill/embed.go new file mode 100644 index 0000000..6bfd18e --- /dev/null +++ b/internal/skill/embed.go @@ -0,0 +1,48 @@ +// Package skill manages the on-disk lifecycle of cct's bundled Claude Code +// skill: extracting the embedded content to ~/.cache/cct/skills/cct, creating +// and removing the ~/.claude/skills/cct symlink, and printing the install +// nudge. +package skill + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "sort" + + "github.com/andyhtran/cct/skills" +) + +// embeddedHash returns a deterministic sha256 over every file in the embedded +// skill tree (path + null + content + null, files sorted lexicographically). +// Used as a content marker so we only re-extract when the binary's bundled +// version actually differs from what's on disk. +func embeddedHash() (string, error) { + h := sha256.New() + var paths []string + err := fs.WalkDir(skills.FS, skills.Root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + paths = append(paths, p) + return nil + }) + if err != nil { + return "", err + } + sort.Strings(paths) + for _, p := range paths { + h.Write([]byte(p)) + h.Write([]byte{0}) + b, err := skills.FS.ReadFile(p) + if err != nil { + return "", err + } + h.Write(b) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/skill/nudge.go b/internal/skill/nudge.go new file mode 100644 index 0000000..5757655 --- /dev/null +++ b/internal/skill/nudge.go @@ -0,0 +1,73 @@ +package skill + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/andyhtran/cct/internal/paths" +) + +// nudgeInterval rate-limits the install prompt so it isn't shown on every +// invocation. 24h is the sweet spot — visible enough to be remembered, sparse +// enough not to be noise. +const nudgeInterval = 24 * time.Hour + +// MaybeNudge prints a one-line install hint to w when the skill isn't +// installed, the user hasn't silenced the nudge, and >24h have passed since +// the last one. Best-effort: any I/O error is swallowed. +// +// stderr is the right destination — keeps stdout clean for `--json` pipelines +// while still surfacing the hint to both terminal users and agents (Claude +// Code's Bash tool captures both streams). +func MaybeNudge(w io.Writer) { + if ours, err := IsOurSymlink(); err == nil && ours { + return + } + if _, err := os.Stat(paths.SkillNudgeDisabledPath()); err == nil { + return + } + + lastPath := paths.SkillNudgeLastPath() + if b, err := os.ReadFile(lastPath); err == nil { + if ts, parseErr := strconv.ParseInt(string(b), 10, 64); parseErr == nil { + if time.Since(time.Unix(ts, 0)) < nudgeInterval { + return + } + } + } + + _, _ = fmt.Fprintln(w, "tip: cct ships a Claude Code skill so agents auto-discover this tool.") + _, _ = fmt.Fprintln(w, " run `cct skill install` to enable, or `cct skill nudge off` to silence.") + + _ = os.MkdirAll(filepath.Dir(lastPath), 0o755) + _ = os.WriteFile(lastPath, []byte(strconv.FormatInt(time.Now().Unix(), 10)), 0o644) +} + +// SetNudgeEnabled toggles the persistent disable flag. enabled=true removes +// the flag file; enabled=false creates it. +func SetNudgeEnabled(enabled bool) error { + p := paths.SkillNudgeDisabledPath() + if enabled { + err := os.Remove(p) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte{}, 0o644) +} + +// NudgeEnabled reports whether the install nudge is currently enabled (i.e. +// the user has not run `cct skill nudge off`). +func NudgeEnabled() bool { + _, err := os.Stat(paths.SkillNudgeDisabledPath()) + return errors.Is(err, os.ErrNotExist) +} diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go new file mode 100644 index 0000000..aebc127 --- /dev/null +++ b/internal/skill/skill_test.go @@ -0,0 +1,343 @@ +package skill + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/andyhtran/cct/internal/paths" +) + +// setupSkillEnv isolates HOME and XDG_CACHE_HOME for one test so skill state +// can't escape into the developer's real ~/.claude or ~/.cache. +func setupSkillEnv(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", "") +} + +func TestSync_FreshExtraction(t *testing.T) { + setupSkillEnv(t) + + if err := Sync(); err != nil { + t.Fatalf("Sync: %v", err) + } + + skillPath := filepath.Join(paths.SkillLiveDir(), "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("SKILL.md missing after sync: %v", err) + } + + refPath := filepath.Join(paths.SkillLiveDir(), "references", "commands.md") + if _, err := os.Stat(refPath); err != nil { + t.Fatalf("references/commands.md missing after sync: %v", err) + } + + marker, err := os.ReadFile(filepath.Join(paths.SkillLiveDir(), versionMarkerFile)) + if err != nil { + t.Fatalf("marker missing: %v", err) + } + if len(marker) == 0 { + t.Fatal("marker is empty") + } +} + +func TestSync_IdempotentSkipsRewrite(t *testing.T) { + setupSkillEnv(t) + + if err := Sync(); err != nil { + t.Fatalf("first Sync: %v", err) + } + markerPath := filepath.Join(paths.SkillLiveDir(), versionMarkerFile) + first, err := os.Stat(markerPath) + if err != nil { + t.Fatalf("stat marker: %v", err) + } + + // Sleep long enough that an mtime change would be observable, then + // re-sync. With matching hashes Sync must not touch the marker. + time.Sleep(20 * time.Millisecond) + if err := Sync(); err != nil { + t.Fatalf("second Sync: %v", err) + } + second, err := os.Stat(markerPath) + if err != nil { + t.Fatalf("stat marker after second sync: %v", err) + } + if !first.ModTime().Equal(second.ModTime()) { + t.Fatalf("marker rewritten when hashes matched (mtime %v -> %v)", first.ModTime(), second.ModTime()) + } +} + +func TestSync_RemovesStaleFilesOnReExtract(t *testing.T) { + setupSkillEnv(t) + + if err := Sync(); err != nil { + t.Fatalf("Sync: %v", err) + } + stale := filepath.Join(paths.SkillLiveDir(), "stale.md") + if err := os.WriteFile(stale, []byte("garbage"), 0o644); err != nil { + t.Fatal(err) + } + + // Force re-extract by corrupting the marker. + if err := os.WriteFile(filepath.Join(paths.SkillLiveDir(), versionMarkerFile), []byte("wrong"), 0o644); err != nil { + t.Fatal(err) + } + + if err := Sync(); err != nil { + t.Fatalf("re-Sync: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale file survived re-extract: err=%v", err) + } +} + +func TestInstall_CreatesSymlinkAndIsIdempotent(t *testing.T) { + setupSkillEnv(t) + + if err := Install(); err != nil { + t.Fatalf("Install: %v", err) + } + target, err := os.Readlink(paths.SkillSymlinkPath()) + if err != nil { + t.Fatalf("symlink missing: %v", err) + } + if target != paths.SkillLiveDir() { + t.Fatalf("symlink target = %s, want %s", target, paths.SkillLiveDir()) + } + + if err := Install(); err != nil { + t.Fatalf("second Install (should be idempotent): %v", err) + } +} + +func TestInstall_RefusesForeignDir(t *testing.T) { + setupSkillEnv(t) + + // Pre-create a regular directory at the destination — simulates a + // hand-installed skill that we must not silently overwrite. + if err := os.MkdirAll(paths.SkillSymlinkPath(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(paths.SkillSymlinkPath(), "SKILL.md"), []byte("foreign"), 0o644); err != nil { + t.Fatal(err) + } + + err := Install() + if err == nil { + t.Fatal("Install should have refused to overwrite foreign directory") + } +} + +func TestInstall_RefusesForeignSymlink(t *testing.T) { + setupSkillEnv(t) + + // Symlink pointing somewhere we don't control. + if err := os.MkdirAll(paths.ClaudeSkillsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/tmp/some-other-skill", paths.SkillSymlinkPath()); err != nil { + t.Fatal(err) + } + + err := Install() + if err == nil { + t.Fatal("Install should have refused foreign symlink") + } +} + +func TestUninstall_OnlyRemovesOurSymlink(t *testing.T) { + setupSkillEnv(t) + if err := os.MkdirAll(paths.ClaudeSkillsDir(), 0o755); err != nil { + t.Fatal(err) + } + foreignTarget := "/tmp/not-cct" + if err := os.Symlink(foreignTarget, paths.SkillSymlinkPath()); err != nil { + t.Fatal(err) + } + + if err := Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + + target, err := os.Readlink(paths.SkillSymlinkPath()) + if err != nil { + t.Fatalf("foreign symlink should still exist: %v", err) + } + if target != foreignTarget { + t.Fatalf("foreign symlink target changed: %s", target) + } +} + +func TestUninstall_NoOpWhenMissing(t *testing.T) { + setupSkillEnv(t) + if err := Uninstall(); err != nil { + t.Fatalf("Uninstall on empty state: %v", err) + } +} + +func TestIsOurSymlink_AllCases(t *testing.T) { + setupSkillEnv(t) + + if ok, err := IsOurSymlink(); err != nil || ok { + t.Fatalf("missing path: ok=%v err=%v, want false/nil", ok, err) + } + + if err := os.MkdirAll(paths.ClaudeSkillsDir(), 0o755); err != nil { + t.Fatal(err) + } + regular := paths.SkillSymlinkPath() + if err := os.WriteFile(regular, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if ok, err := IsOurSymlink(); err != nil || ok { + t.Fatalf("regular file: ok=%v err=%v", ok, err) + } + _ = os.Remove(regular) + + if err := os.Symlink("/elsewhere", regular); err != nil { + t.Fatal(err) + } + if ok, err := IsOurSymlink(); err != nil || ok { + t.Fatalf("foreign symlink: ok=%v err=%v", ok, err) + } + _ = os.Remove(regular) + + if err := os.Symlink(paths.SkillLiveDir(), regular); err != nil { + t.Fatal(err) + } + if ok, err := IsOurSymlink(); err != nil || !ok { + t.Fatalf("our symlink: ok=%v err=%v", ok, err) + } +} + +func TestMaybeNudge_SuppressedWhenInstalled(t *testing.T) { + setupSkillEnv(t) + if err := Install(); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + MaybeNudge(&buf) + if buf.Len() != 0 { + t.Fatalf("nudge fired while installed: %q", buf.String()) + } +} + +func TestMaybeNudge_SuppressedWhenDisabled(t *testing.T) { + setupSkillEnv(t) + if err := SetNudgeEnabled(false); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + MaybeNudge(&buf) + if buf.Len() != 0 { + t.Fatalf("nudge fired while disabled: %q", buf.String()) + } +} + +func TestMaybeNudge_RateLimited(t *testing.T) { + setupSkillEnv(t) + + var first bytes.Buffer + MaybeNudge(&first) + if first.Len() == 0 { + t.Fatal("first nudge should fire") + } + + var second bytes.Buffer + MaybeNudge(&second) + if second.Len() != 0 { + t.Fatalf("second nudge should be rate-limited: %q", second.String()) + } +} + +func TestMaybeNudge_FiresAfterInterval(t *testing.T) { + setupSkillEnv(t) + + // Backdate the last-shown timestamp to >24h ago. + if err := os.MkdirAll(filepath.Dir(paths.SkillNudgeLastPath()), 0o755); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-25 * time.Hour).Unix() + if err := os.WriteFile(paths.SkillNudgeLastPath(), []byte(strconv.FormatInt(old, 10)), 0o644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + MaybeNudge(&buf) + if buf.Len() == 0 { + t.Fatal("nudge should fire after interval elapsed") + } +} + +func TestSetNudgeEnabled_Toggle(t *testing.T) { + setupSkillEnv(t) + + if !NudgeEnabled() { + t.Fatal("default state should be enabled") + } + if err := SetNudgeEnabled(false); err != nil { + t.Fatal(err) + } + if NudgeEnabled() { + t.Fatal("after off: still enabled") + } + // Idempotent off. + if err := SetNudgeEnabled(false); err != nil { + t.Fatal(err) + } + if err := SetNudgeEnabled(true); err != nil { + t.Fatal(err) + } + if !NudgeEnabled() { + t.Fatal("after on: still disabled") + } + // Idempotent on (file already absent). + if err := SetNudgeEnabled(true); err != nil { + t.Fatal(err) + } +} + +func TestGetStatus_NotInstalled(t *testing.T) { + setupSkillEnv(t) + s, err := GetStatus() + if err != nil { + t.Fatal(err) + } + if s.Installed { + t.Fatal("should report not installed") + } + if !s.NudgeEnabled { + t.Fatal("nudge should default to enabled") + } + if s.EmbeddedHash == "" { + t.Fatal("embedded hash should always be computable") + } +} + +func TestGetStatus_Installed(t *testing.T) { + setupSkillEnv(t) + if err := Install(); err != nil { + t.Fatal(err) + } + s, err := GetStatus() + if err != nil { + t.Fatal(err) + } + if !s.Installed { + t.Fatal("should report installed") + } + if !s.OurSymlink { + t.Fatal("OurSymlink should be true") + } + if !s.InSync { + t.Fatal("should be in sync") + } + if s.SymlinkTarget != paths.SkillLiveDir() { + t.Fatalf("target = %s, want %s", s.SymlinkTarget, paths.SkillLiveDir()) + } +} diff --git a/internal/skill/status.go b/internal/skill/status.go new file mode 100644 index 0000000..2fc7437 --- /dev/null +++ b/internal/skill/status.go @@ -0,0 +1,59 @@ +package skill + +import ( + "os" + "path/filepath" + "strconv" + "time" + + "github.com/andyhtran/cct/internal/paths" +) + +// Status captures everything `cct skill status` reports. +type Status struct { + Installed bool `json:"installed"` + SymlinkPath string `json:"symlink_path"` + SymlinkTarget string `json:"symlink_target,omitempty"` + OurSymlink bool `json:"our_symlink"` + LiveDir string `json:"live_dir"` + EmbeddedHash string `json:"embedded_hash"` + LiveHash string `json:"live_hash,omitempty"` + InSync bool `json:"in_sync"` + NudgeEnabled bool `json:"nudge_enabled"` + NudgeLastShown string `json:"nudge_last_shown,omitempty"` +} + +// GetStatus reads disk state and reports installation, sync, and nudge state. +// Best-effort on individual fields: a missing file or unreadable target falls +// back to a zero value rather than erroring the whole call. +func GetStatus() (Status, error) { + s := Status{ + SymlinkPath: paths.SkillSymlinkPath(), + LiveDir: paths.SkillLiveDir(), + NudgeEnabled: NudgeEnabled(), + } + if h, err := embeddedHash(); err == nil { + s.EmbeddedHash = h + } + + if b, err := os.ReadFile(filepath.Join(s.LiveDir, versionMarkerFile)); err == nil { + s.LiveHash = string(b) + s.InSync = s.LiveHash == s.EmbeddedHash + } + + if info, err := os.Lstat(s.SymlinkPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if target, err := os.Readlink(s.SymlinkPath); err == nil { + s.SymlinkTarget = target + s.OurSymlink = target == s.LiveDir + s.Installed = s.OurSymlink + } + } + + if b, err := os.ReadFile(paths.SkillNudgeLastPath()); err == nil { + if ts, parseErr := strconv.ParseInt(string(b), 10, 64); parseErr == nil { + s.NudgeLastShown = time.Unix(ts, 0).Format(time.RFC3339) + } + } + + return s, nil +} diff --git a/internal/skill/symlink.go b/internal/skill/symlink.go new file mode 100644 index 0000000..fb36d22 --- /dev/null +++ b/internal/skill/symlink.go @@ -0,0 +1,76 @@ +package skill + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/andyhtran/cct/internal/paths" +) + +// Install ensures the live copy is up to date and creates the +// ~/.claude/skills/cct symlink pointing at it. Idempotent when the symlink +// already points at our live dir. Refuses to overwrite a foreign item at the +// destination. +func Install() error { + if err := Sync(); err != nil { + return fmt.Errorf("sync: %w", err) + } + symlinkPath := paths.SkillSymlinkPath() + liveDir := paths.SkillLiveDir() + + if err := os.MkdirAll(filepath.Dir(symlinkPath), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(symlinkPath), err) + } + + ours, err := IsOurSymlink() + if err != nil { + return err + } + if ours { + return nil + } + + if _, err := os.Lstat(symlinkPath); err == nil { + return fmt.Errorf("%s already exists and is not managed by cct; remove it manually before retrying", symlinkPath) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + return os.Symlink(liveDir, symlinkPath) +} + +// Uninstall removes the symlink only if it's ours. Leaves the live copy in +// place so reinstall is a one-syscall operation. +func Uninstall() error { + ours, err := IsOurSymlink() + if err != nil { + return err + } + if !ours { + return nil + } + return os.Remove(paths.SkillSymlinkPath()) +} + +// IsOurSymlink reports whether ~/.claude/skills/cct is a symlink pointing +// exactly at SkillLiveDir(). +func IsOurSymlink() (bool, error) { + symlinkPath := paths.SkillSymlinkPath() + info, err := os.Lstat(symlinkPath) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink == 0 { + return false, nil + } + target, err := os.Readlink(symlinkPath) + if err != nil { + return false, err + } + return target == paths.SkillLiveDir(), nil +} diff --git a/internal/skill/sync.go b/internal/skill/sync.go new file mode 100644 index 0000000..da9a23b --- /dev/null +++ b/internal/skill/sync.go @@ -0,0 +1,76 @@ +package skill + +import ( + "io/fs" + "os" + "path/filepath" + + "github.com/andyhtran/cct/internal/paths" + "github.com/andyhtran/cct/skills" +) + +// versionMarkerFile is written into the live dir alongside the extracted +// content. Contents are the hex-encoded sha256 of the embedded tree at time +// of extraction. Used by Sync to skip work when nothing changed. +const versionMarkerFile = ".cct-skill-version" + +// Sync extracts the embedded skill content to paths.SkillLiveDir() if the +// version marker doesn't match the current binary's embedded content. Safe to +// call on every cct invocation — costs one stat + small read in the steady +// state. +func Sync() error { + liveDir := paths.SkillLiveDir() + wantHash, err := embeddedHash() + if err != nil { + return err + } + + markerPath := filepath.Join(liveDir, versionMarkerFile) + if cur, err := os.ReadFile(markerPath); err == nil && string(cur) == wantHash { + return nil + } + + if err := os.MkdirAll(liveDir, 0o755); err != nil { + return err + } + + // Wipe existing managed contents before re-extracting. Live dir is + // cct-owned (under our cache dir), so blowing it away is safe. + entries, _ := os.ReadDir(liveDir) + for _, e := range entries { + if err := os.RemoveAll(filepath.Join(liveDir, e.Name())); err != nil { + return err + } + } + + err = fs.WalkDir(skills.FS, skills.Root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(skills.Root, p) + if err != nil { + return err + } + target := filepath.Join(liveDir, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + b, err := skills.FS.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, b, 0o644) + }) + if err != nil { + return err + } + + return os.WriteFile(markerPath, []byte(wantHash), 0o644) +} + +// SyncQuiet runs Sync and swallows any error. Used in the root command's +// pre-run hook so a transient sync failure (disk full, race) never blocks the +// user's actual command. +func SyncQuiet() { + _ = Sync() +} diff --git a/skills/cct/SKILL.md b/skills/cct/SKILL.md new file mode 100644 index 0000000..9643b61 --- /dev/null +++ b/skills/cct/SKILL.md @@ -0,0 +1,111 @@ +--- +name: cct +description: Search and recall Claude Code session history via the cct CLI. Use ONLY when the user asks about previous sessions — what was discussed, what was done in a project, a decision/plan from an earlier conversation, or session statistics. Covers cct search, cct export, cct info, cct list, cct stats, cct backup, cct changelog. Do not trigger proactively — wait for the user to reference past sessions. +--- + +# cct + +`cct` is the canonical way to query the user's local Claude Code session history. Sessions live as JSONL files under `~/.claude/projects/`; cct indexes them into a SQLite FTS5 database and adds backup, export, and a TUI on top. + +## The core loop + +**Recall content from a past discussion:** search → export. +**Inspect recent activity:** list → info. + +- `cct search ` — full-text search across session content (most-used) +- `cct export ` — export full session as markdown or JSON +- `cct info ` — metadata + first prompt for one session +- `cct list` — recent sessions, newest first + +Session IDs accept a short prefix (first 8 chars of the UUID) — no need to type the full one. + +## Quickstart + +``` +cct search "login bug" -p myproject # find a past discussion in one project +cct export > out.md # dump the conversation as markdown +cct list -p MyProject --limit 20 # recent activity in a project +cct info # first prompt + metadata +``` + +`-p` is short for `--project`. `--no-agents` excludes sub-agent sessions on both `list` and `search`. `-n` is short for `--limit`. + +## Common workflows + +The order below reflects real usage frequency. + +**1. Search by topic, then export the winner.** +This is the dominant pattern. The user remembers a technical concept and wants the conversation back. + +``` +cct search "auth flow" -p myproject --json | jq -r '.[].short_id' +cct export > recall.md +``` + +Use `--project` (`-p`) to scope. FTS5 phrase queries: quote multi-word phrases. See `references/search-syntax.md` for operators (OR, NOT, hyphen handling). + +**2. Recent activity in a project, filtered by date.** +cct doesn't have a date flag — filter via jq on the `modified` field. + +``` +cct list -p myproject --limit 40 --no-agents --json \ + | jq -r '.[] | select(.modified[:10] == "2025-04-21") | .short_id' +``` + +**3. Inspect a single session.** +`cct info ` for metadata + first prompt. `cct export ` for the full conversation. There is **no `cct show`**. + +**4. Recover a deleted session.** +Claude Code occasionally cleans up old sessions. `cct backup status` shows what's archived locally; `cct backup restore ` brings it back. + +**5. Look up Claude Code release notes.** +`cct changelog` (alias `cct log`) fetches upstream CHANGELOG.md, cached 6h. `cct changelog --search "disable|opt.?out"` greps across entries. + +## Programmatic inspection (JSON + jq) + +`--json` is the dominant inspection mode for agents. Stable schemas — pipe to jq. + +**Top projects from stats.** +``` +cct stats --json | jq '.top_projects[] | {name, sessions}' +``` +Schema: `total_sessions`, `unique_projects`, `sessions_this_week`, `sessions_this_month`, `top_projects[].{name,sessions}`, `recent_projects[].{name,last_used}`, `agent_types[].{type,count}`. + +**Pluck short IDs from a search.** +``` +cct search "rate limiter" -p myproject --json | jq -r '.[].short_id' +``` + +**Filter list by date and select fields.** +``` +cct list -p myproject --limit 50 --no-agents --json \ + | jq -r '.[] | "\(.short_id) \(.modified[:10]) \(.first_prompt[:80])"' +``` + +Common fields on list/search results: `id`, `short_id`, `project_name`, `project_path`, `created`, `modified`, `first_prompt`, `git_branch`, `message_count`, `is_agent`. Search adds `matches[].{role,snippet,source}` and `score`. + +## When to use cct vs. ad-hoc Bash + +**Always prefer cct over manual filesystem operations on `~/.claude/projects/`.** + +- Don't `grep -r ~/.claude/projects/` — use `cct search`. Faster (FTS5) and JSONL-aware. +- Don't `ls -lt ~/.claude/projects/` — use `cct list`. Filenames are UUIDs; cct surfaces project + first prompt + modified time. +- Don't `cat *.jsonl | jq ...` for ad-hoc inspection — use `cct info` or `cct export`. +- **There is no `cct show`.** Use `cct info ` for metadata, `cct export ` for full content. (Common mistake.) + +If `cct search` genuinely misses something, falling back to grep is fine — but try cct first. + +## Indexing + +cct keeps a SQLite FTS5 index at `~/.cache/cct/index.db`. It auto-syncs on every search/list. Rarely needed: `cct index rebuild` (wipe + re-index), `cct index status` (counts). + +## Troubleshooting + +- **Empty search when content clearly exists** → `cct index sync` forces a fresh scan. +- **Session not found by ID** → `cct backup status`; `cct backup restore ` to recover. +- **Stats JSON field name wrong** → schema is in the JSON section above; don't guess (`top_projects`, not `topProjects`). + +## Full reference + +- `references/commands.md` — every command, every flag, every alias, JSON schemas +- `references/search-syntax.md` — FTS5 quoting, operators, special characters diff --git a/skills/cct/references/commands.md b/skills/cct/references/commands.md new file mode 100644 index 0000000..e076c76 --- /dev/null +++ b/skills/cct/references/commands.md @@ -0,0 +1,159 @@ +# cct commands — full reference + +Scope: every cct subcommand, its flags, JSON schemas where applicable. SKILL.md covers the happy path; this file is the exhaustive enumeration. + +**Related**: [SKILL.md](../SKILL.md), [search-syntax.md](search-syntax.md). + +## Global flags + +- `--json` — emit JSON to stdout (where supported). Stable schemas; safe for `jq`. +- `-v`, `--version` — show version and exit. + +## search — full-text search + +``` +cct search [-p|--project ] [-n|--limit ] [--no-agents] [--json] +``` + +FTS5 query over indexed session content. Default limit 25 (use `-n 0` for unlimited). + +**JSON result fields:** +- `id`, `short_id` — full + 8-char UUID prefix +- `is_agent` — true for sub-agent sessions +- `project_name`, `project_path` +- `created`, `modified` (RFC3339) +- `first_prompt` +- `git_branch` +- `message_count` +- `matches[]` — array of `{role, snippet, source?}` objects (snippets contain the matched terms) +- `score` — FTS5 ranking; higher is better + +See [search-syntax.md](search-syntax.md) for query operators and special characters. + +## export — export messages + +``` +cct export [--format markdown|json] [--filter ] +``` + +Default format markdown. Accepts short ID prefix (≥8 chars). `--filter` supports message-level expressions (user/assistant/tool_use). + +## info — session metadata + +``` +cct info [--json] +``` + +Prints first prompt, project, git branch, message count, created/modified timestamps. + +## list — recent sessions + +``` +cct list [-p|--project ] [-n|--limit ] [-a|--all] [--agents|--no-agents] [--json] +``` + +Newest first by modified time. Default limit 15. `cct list` (no args) shows the 5 most recent. +Sub-agent sessions are excluded by default; `--agents` includes them, `--no-agents` is the explicit form (and works as kong's negation of `--agents`). + +**JSON result fields:** same as search minus `matches` and `score`. + +## stats — session statistics + +``` +cct stats [--json] +``` + +**JSON schema:** +```json +{ + "total_sessions": 1000, + "unique_projects": 50, + "sessions_this_week": 100, + "sessions_this_month": 400, + "top_projects": [{"name": "", "sessions": 200}], + "recent_projects": [{"name": "", "last_used": ""}], + "agent_types": [{"type": "", "count": 10}] +} +``` + +Field names are `top_projects` (not `topProjects`), `unique_projects`, `total_sessions` — exact snake_case. + +## resume — resume a session + +``` +cct resume +``` + +Auto-cd to the project directory and resume. Fails if the project dir was moved or deleted. Rarely needed for agent workflows; primarily a human convenience. + +## view — interactive TUI + +``` +cct view +``` + +Bubbletea TUI. Arrow keys to navigate, `/` to search, `q` to quit. Human-only; not useful for agents. + +## changelog — Claude Code release notes + +``` +cct changelog [] [--since ] [--all] [--search ] [--refresh] +``` + +Alias: `cct log`. Fetches upstream CHANGELOG.md (cached 6h at `~/.cache/cct/changelog.md`). + +## index — manage the search index + +``` +cct index sync # incremental: re-index modified-since-last-sync sessions +cct index rebuild # wipe + re-index from scratch +cct index status # session count, last sync, db size +``` + +Index lives at `~/.cache/cct/index.db`. Lockfile at `~/.cache/cct/index.db.lock`. + +## backup — guard against upstream cleanup + +``` +cct backup # default: sweep +cct backup sweep [--no-agents] [--include-active] [--quiet] +cct backup status # per-session drift report +cct backup restore ... [--dry-run] [--force] +``` + +Hard-links session JSONL files to `~/.cache/cct/backup/projects/`. Run `sweep` periodically. `restore` reverse-links a session back into `~/.claude/projects/`. + +## skill — manage the cct Claude Code skill + +``` +cct skill install # create symlink at ~/.claude/skills/cct +cct skill uninstall # remove symlink (live copy preserved) +cct skill status # install state, symlink target, sync state, nudge state +cct skill nudge on|off|status +``` + +Live copy at `~/.cache/cct/skills/cct/`. Auto-syncs from the embedded version on every cct invocation. + +## plans — saved plans (rarely used) + +``` +cct plans [list|search|show|cp] ... +``` + +Reads from `~/.claude/plans/`. `cct plans cp ` copies a plan into the current dir. Low-frequency command; reach for it only if the user explicitly references "the plan from session X". + +## schema — CLI structure as JSON + +``` +cct schema --json +``` + +Machine-readable manifest of commands and flags. + +## version + +``` +cct version [--json] +``` + +Prints cct version and detected Claude Code version. diff --git a/skills/cct/references/search-syntax.md b/skills/cct/references/search-syntax.md new file mode 100644 index 0000000..50870a2 --- /dev/null +++ b/skills/cct/references/search-syntax.md @@ -0,0 +1,66 @@ +# cct search syntax + +Scope: how `cct search ` interprets queries, plus filters, ranking, and snippet behavior. + +**Related**: [SKILL.md](../SKILL.md), [commands.md](commands.md) for full flag list. + +## Backend + +`cct search` runs against a SQLite FTS5 virtual table indexed over all message content under `~/.claude/projects/`. The index lives at `~/.cache/cct/index.db` and auto-syncs incrementally before each search. + +## Basic queries + +- **Single token**: `cct search kong` — matches any session containing `kong`. +- **Multiple tokens (implicit AND)**: `cct search kong subcommand` — matches sessions containing both tokens (any order, any distance). +- **Phrase**: `cct search "kong subcommand"` — matches the literal phrase. +- **OR**: `cct search 'kong OR cobra'` — FTS5 boolean operators (uppercase) work. +- **NOT**: `cct search 'kong NOT cobra'` — exclude sessions containing the second term. + +## Filters + +- `--project ` — restrict to one project (matches against `project_name` from session metadata). +- `--limit ` — cap results (default ~20). Useful with `--json | jq` pipelines. + +## JSON output + +`cct search --json` emits an array. Per-result fields: + +- `id`, `short_id` — full + 8-char UUID prefix +- `project_name`, `project_path` +- `created`, `modified` (RFC3339) +- `first_prompt` +- `git_branch` +- `message_count` +- `matches` — array of snippet strings with `...` around the matched terms +- `score` — FTS5 BM25 ranking; lower is better + +Example: + +``` +cct search "kong subcommand" --json | jq '.[] | {short_id, project_name, score}' +``` + +## Ranking + +Default sort is FTS5 BM25 (relevance). Use `--json` if you need to re-sort by `modified` or `created` downstream — cct doesn't currently expose a sort flag. + +## What's indexed vs. not + +Indexed: user messages, assistant messages, tool_use names + input text. +Not indexed: file-history-snapshot events, custom-title metadata events. + +If a search misses something you remember writing, possibilities: +1. Session was added since last sync → `cct index sync` +2. Content was in a tool_use's parameters that aren't indexed → fall back to grep on the raw JSONL + +## Special characters + +FTS5 treats most punctuation as a token boundary. To search for an identifier with hyphens or underscores, quote the phrase: + +- `cct search "claude-code"` ✓ +- `cct search claude-code` ✗ (treated as `claude code` — same tokens, different precision) + +## See also + +- `cct index status` — last sync time, total sessions indexed +- `cct list` — when you want recency, not relevance diff --git a/skills/embed.go b/skills/embed.go new file mode 100644 index 0000000..3b407b5 --- /dev/null +++ b/skills/embed.go @@ -0,0 +1,12 @@ +// Package skills exposes the bundled cct Claude Code skill as an embedded FS. +// The cct/ subtree is extracted to ~/.cache/cct/skills/cct/ at runtime by +// internal/skill, and symlinked from ~/.claude/skills/cct/ on install. +package skills + +import "embed" + +//go:embed cct +var FS embed.FS + +// Root is the prefix inside FS where the cct skill lives. +const Root = "cct"