Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ cct resume <id> # 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 <query>` to find relevant past sessions.
Use `cct export <id> --full` to read full conversation context.
Use `cct changelog --search <regex>` 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:
Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"errors"
"fmt"
"os"
"strings"

"github.com/alecthomas/kong"

"github.com/andyhtran/cct/internal/skill"
)

type ExitError struct{ Code int }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions internal/app/skill.go
Original file line number Diff line number Diff line change
@@ -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"
}
31 changes: 31 additions & 0 deletions internal/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
48 changes: 48 additions & 0 deletions internal/skill/embed.go
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions internal/skill/nudge.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading