From 7d4a1936845698d62540579dc57867d33a764612 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Wed, 8 Apr 2026 14:19:07 -0400 Subject: [PATCH] Implement issues #33, #50, #54, #59, #61 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #50 — Incremental updates (P0 + polish) - daemon: swap AnalyzeIncremental → AnalyzeSidecars (broken changedFiles field was causing UUID churn on every single-file update) - daemon: preserve domains on incremental merge (only full generate refreshes domains); add comment explaining why - api/client: delete dead AnalyzeIncremental + postIncrementalZip methods - analyze: use AnalyzeSidecars + GraphFromSidecarIR; write sidecar cache so files.Generate() reuses the result without a second upload - setup/wizard: hook command uses os.Executable() for absolute binary path - daemon: clean sidecar files on Ctrl+C shutdown - files/zip: eliminate global map mutation — per-call zipExclusions struct removes concurrent-write race on package-level maps - files/hook: warn to stderr when daemon is not running - daemon: assign new files to existing domains by directory-prefix matching ## #33 — Caching - cache/fingerprint: AnalysisKey now takes a version param — cache invalidates automatically on CLI upgrade - cache: add PutJSON/GetJSON for generic result types - deadcode: check cache before upload; store result after API call - blastradius: same; skip cache when --diff is supplied ## #59 — Language bar chart before upload - files/zip: add LanguageStats() + PrintLanguageBarChart() - daemon.fullGenerate + files.Generate: print bar chart before API upload ## #54 — Document .graph files for other agents - setup/wizard: rename step "Claude Code hook" → "Agent hook"; mention Cursor/Copilot/Windsurf/Aider; add detectCursor() helper that prints a note when Cursor is installed ## #61 — Watch for new commits - files/watcher: track lastCommitSHA; on HEAD change emit diff files as WatchEvents (handles git commit, pull, checkout, merge, stash pop) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + internal/analyze/handler.go | 24 ++++++- internal/api/types.go | 13 ++++ internal/blastradius/handler.go | 33 ++++++++- internal/cache/cache.go | 33 +++++++++ internal/cache/fingerprint.go | 6 +- internal/cache/fingerprint_test.go | 8 +-- internal/deadcode/handler.go | 22 ++++++ internal/files/daemon.go | 44 ++++++++++++ internal/files/handler.go | 7 +- internal/files/watcher.go | 19 +++++ internal/files/zip.go | 109 ++++++++++++++++++++++++----- internal/setup/wizard.go | 48 +++++++++++-- 13 files changed, 333 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 31190d8..97db51a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ npm/bin/ *.exe supermodel cli +.supermodel/ diff --git a/internal/analyze/handler.go b/internal/analyze/handler.go index 30e5366..82eadf7 100644 --- a/internal/analyze/handler.go +++ b/internal/analyze/handler.go @@ -2,11 +2,14 @@ package analyze import ( "context" + "encoding/json" "fmt" "io" "os" + "path/filepath" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/build" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" @@ -38,7 +41,7 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) ( if !force { fingerprint, err := cache.RepoFingerprint(dir) if err == nil { - key := cache.AnalysisKey(fingerprint, "graph") + key := cache.AnalysisKey(fingerprint, "graph", build.Version) if g, _ := cache.Get(key); g != nil { ui.Success("Using cached analysis (repoId: %s)", g.RepoID()) return g, key, nil @@ -70,16 +73,18 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) ( client := api.New(cfg) spin = ui.Start("Uploading and analyzing repository…") - g, err := client.Analyze(ctx, zipPath, "analyze-"+hash[:16]) + ir, err := client.AnalyzeSidecars(ctx, zipPath, "analyze-"+hash[:16]) spin.Stop() if err != nil { return nil, hash, err } + g := api.GraphFromSidecarIR(ir) + // Cache under both keys: fingerprint (fast lookup) and zip hash (fallback). fingerprint, fpErr := cache.RepoFingerprint(dir) if fpErr == nil { - fpKey := cache.AnalysisKey(fingerprint, "graph") + fpKey := cache.AnalysisKey(fingerprint, "graph", build.Version) if err := cache.Put(fpKey, g); err != nil { ui.Warn("could not write cache: %v", err) } @@ -88,6 +93,19 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) ( ui.Warn("could not write cache: %v", err) } + // Also populate the sidecar cache (.supermodel/graph.json) so that + // files.Generate() called after analyze reuses this result without a + // second API upload. + sidecarCacheFile := filepath.Join(dir, ".supermodel", "graph.json") + if irJSON, marshalErr := json.MarshalIndent(ir, "", " "); marshalErr == nil { + if mkErr := os.MkdirAll(filepath.Dir(sidecarCacheFile), 0o755); mkErr == nil { + tmp := sidecarCacheFile + ".tmp" + if writeErr := os.WriteFile(tmp, irJSON, 0o644); writeErr == nil { + _ = os.Rename(tmp, sidecarCacheFile) + } + } + } + ui.Success("Analysis complete (repoId: %s)", g.RepoID()) return g, hash, nil } diff --git a/internal/api/types.go b/internal/api/types.go index d5a61f2..92c9344 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -179,6 +179,19 @@ type SidecarSubdomain struct { KeyFiles []string `json:"keyFiles"` } +// GraphFromSidecarIR builds a display Graph from a SidecarIR response. +// SidecarIR uses the same Node/Relationship types, so this is a zero-copy +// extraction that also populates the repoId metadata field. +func GraphFromSidecarIR(ir *SidecarIR) *Graph { + return &Graph{ + Nodes: ir.Graph.Nodes, + Relationships: ir.Graph.Relationships, + Metadata: map[string]any{ + "repoId": ir.Repo, + }, + } +} + // JobResponse is the async envelope returned by the API for long-running jobs. type JobResponse struct { Status string `json:"status"` diff --git a/internal/blastradius/handler.go b/internal/blastradius/handler.go index 1e8f7b0..b057130 100644 --- a/internal/blastradius/handler.go +++ b/internal/blastradius/handler.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/build" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" @@ -22,6 +23,25 @@ type Options struct { // Run uploads the repo and runs impact analysis via the dedicated API endpoint. func Run(ctx context.Context, cfg *config.Config, dir string, targets []string, opts Options) error { + targetStr := strings.Join(targets, ",") + + // Fast-path: check cache by git fingerprint (skip when a diff is supplied, + // since the diff changes the result independently of repo state). + if !opts.Force && opts.Diff == "" { + if fp, err := cache.RepoFingerprint(dir); err == nil { + analysisType := "impact" + if targetStr != "" { + analysisType += ":" + targetStr + } + key := cache.AnalysisKey(fp, analysisType, build.Version) + var cached api.ImpactResult + if hit, _ := cache.GetJSON(key, &cached); hit { + ui.Success("Using cached impact analysis") + return printResults(os.Stdout, &cached, ui.ParseFormat(opts.Output)) + } + } + } + spin := ui.Start("Creating repository archive…") zipPath, err := createZip(dir) spin.Stop() @@ -36,7 +56,6 @@ func Run(ctx context.Context, cfg *config.Config, dir string, targets []string, } idempotencyKey := "impact-" + hash[:16] - targetStr := strings.Join(targets, ",") if targetStr != "" { idempotencyKey += "-" + targetStr } @@ -49,6 +68,18 @@ func Run(ctx context.Context, cfg *config.Config, dir string, targets []string, return err } + // Store result in cache. + if opts.Diff == "" { + if fp, err := cache.RepoFingerprint(dir); err == nil { + analysisType := "impact" + if targetStr != "" { + analysisType += ":" + targetStr + } + key := cache.AnalysisKey(fp, analysisType, build.Version) + _ = cache.PutJSON(key, result) + } + } + return printResults(os.Stdout, result, ui.ParseFormat(opts.Output)) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index f78468c..0af8a89 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -77,3 +77,36 @@ func Evict(hash string) error { } return err } + +// PutJSON serialises v as JSON and stores it under hash. Unlike Put, it works +// with any value type — useful for dead-code and blast-radius results. +func PutJSON(hash string, v any) error { + if err := os.MkdirAll(dir(), 0o700); err != nil { + return fmt.Errorf("create cache dir: %w", err) + } + data, err := json.Marshal(v) + if err != nil { + return err + } + tmp := filepath.Join(dir(), hash+".json.tmp") + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write cache: %w", err) + } + return os.Rename(tmp, filepath.Join(dir(), hash+".json")) +} + +// GetJSON reads the cached JSON for hash and unmarshals it into v. +// Returns (true, nil) on hit, (false, nil) on miss, (false, err) on error. +func GetJSON(hash string, v any) (bool, error) { + data, err := os.ReadFile(filepath.Join(dir(), hash+".json")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read cache: %w", err) + } + if err := json.Unmarshal(data, v); err != nil { + return false, fmt.Errorf("parse cache: %w", err) + } + return true, nil +} diff --git a/internal/cache/fingerprint.go b/internal/cache/fingerprint.go index 562b2bf..0d22814 100644 --- a/internal/cache/fingerprint.go +++ b/internal/cache/fingerprint.go @@ -48,8 +48,10 @@ func gitOutput(dir string, args ...string) (string, error) { } // AnalysisKey builds a cache key for a specific analysis type on a repo state. -func AnalysisKey(fingerprint, analysisType string) string { +// version is the CLI version string and is included so the cache is invalidated +// automatically after an upgrade. +func AnalysisKey(fingerprint, analysisType, version string) string { h := sha256.New() - fmt.Fprintf(h, "%s\x00%s", fingerprint, analysisType) + fmt.Fprintf(h, "%s\x00%s\x00%s", fingerprint, analysisType, version) return hex.EncodeToString(h.Sum(nil)) } diff --git a/internal/cache/fingerprint_test.go b/internal/cache/fingerprint_test.go index 0fbf630..6435f9d 100644 --- a/internal/cache/fingerprint_test.go +++ b/internal/cache/fingerprint_test.go @@ -98,16 +98,16 @@ func TestRepoFingerprint_NotGitRepo(t *testing.T) { func TestAnalysisKey_DifferentTypes(t *testing.T) { fp := "abc123" - k1 := AnalysisKey(fp, "graph") - k2 := AnalysisKey(fp, "dead-code") + k1 := AnalysisKey(fp, "graph", "dev") + k2 := AnalysisKey(fp, "dead-code", "dev") if k1 == k2 { t.Error("different analysis types should produce different keys") } } func TestAnalysisKey_Stable(t *testing.T) { - k1 := AnalysisKey("abc", "graph") - k2 := AnalysisKey("abc", "graph") + k1 := AnalysisKey("abc", "graph", "dev") + k2 := AnalysisKey("abc", "graph", "dev") if k1 != k2 { t.Error("same inputs should produce same key") } diff --git a/internal/deadcode/handler.go b/internal/deadcode/handler.go index 1fff5bd..9a93f42 100644 --- a/internal/deadcode/handler.go +++ b/internal/deadcode/handler.go @@ -7,6 +7,7 @@ import ( "os" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/build" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" @@ -24,6 +25,21 @@ type Options struct { // Run uploads the repo and runs dead code analysis via the dedicated API endpoint. func Run(ctx context.Context, cfg *config.Config, dir string, opts *Options) error { + // Fast-path: check cache by git fingerprint before creating the zip. + if !opts.Force { + if fp, err := cache.RepoFingerprint(dir); err == nil { + key := cache.AnalysisKey(fp, "dead-code", build.Version) + var cached api.DeadCodeResult + if hit, _ := cache.GetJSON(key, &cached); hit { + ui.Success("Using cached dead-code analysis") + if len(opts.Ignore) > 0 { + cached.DeadCodeCandidates = filterIgnored(cached.DeadCodeCandidates, opts.Ignore) + } + return printResults(os.Stdout, &cached, ui.ParseFormat(opts.Output)) + } + } + } + spin := ui.Start("Creating repository archive…") zipPath, err := createZip(dir) spin.Stop() @@ -45,6 +61,12 @@ func Run(ctx context.Context, cfg *config.Config, dir string, opts *Options) err return err } + // Store result in cache for subsequent calls. + if fp, err := cache.RepoFingerprint(dir); err == nil { + key := cache.AnalysisKey(fp, "dead-code", build.Version) + _ = cache.PutJSON(key, result) + } + if len(opts.Ignore) > 0 { result.DeadCodeCandidates = filterIgnored(result.DeadCodeCandidates, opts.Ignore) } diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 7d107dc..9e57ecc 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -127,6 +127,8 @@ func (d *Daemon) Run(ctx context.Context) error { debounceTimer.Stop() } d.logf("Shutting down...") + d.logf("Cleaning sidecar files...") + _ = Clean(context.Background(), nil, d.cfg.RepoDir, false) return nil case filePath, ok := <-d.notifyCh: @@ -191,6 +193,11 @@ func (d *Daemon) fullGenerate(ctx context.Context) error { d.logf("Fetching full graph from Supermodel API...") idemKey := newUUID() + if fileList, listErr := DryRunList(d.cfg.RepoDir); listErr == nil { + stats := LanguageStats(fileList) + PrintLanguageBarChart(stats, len(fileList)) + } + zipPath, err := CreateZipFile(d.cfg.RepoDir, nil) if err != nil { return fmt.Errorf("creating zip: %w", err) @@ -506,11 +513,48 @@ func (d *Daemon) mergeGraph(incremental *api.SidecarIR, changedFiles []string) { // contain domains classified from only the changed files, which are // incorrect for the repo as a whole. Domains only refresh on full generate. + // Assign new files to existing domains by directory-prefix matching. + d.assignNewFilesToDomains(newNodes) + if len(extRemap) > 0 { d.logf("Resolved %d external references to internal nodes", len(extRemap)) } } +// assignNewFilesToDomains assigns newly merged File nodes to the best-matching +// existing domain using longest common directory-prefix heuristic. +func (d *Daemon) assignNewFilesToDomains(newNodes []api.Node) { + if len(d.ir.Domains) == 0 { + return + } + + for _, n := range newNodes { + if !n.HasLabel("File") { + continue + } + fp := n.Prop("filePath") + if fp == "" { + continue + } + dir := filepath.Dir(fp) + + bestDomain := -1 + bestLen := -1 + for i, domain := range d.ir.Domains { + for _, kf := range domain.KeyFiles { + prefix := filepath.Dir(kf) + if strings.HasPrefix(dir+"/", prefix+"/") && len(prefix) > bestLen { + bestLen = len(prefix) + bestDomain = i + } + } + } + if bestDomain >= 0 { + d.ir.Domains[bestDomain].KeyFiles = append(d.ir.Domains[bestDomain].KeyFiles, fp) + } + } +} + // computeAffectedFiles returns changed files plus their 1-hop dependents. func (d *Daemon) computeAffectedFiles(changedFiles []string) []string { affected := make(map[string]bool) diff --git a/internal/files/handler.go b/internal/files/handler.go index 555c1e9..b409fda 100644 --- a/internal/files/handler.go +++ b/internal/files/handler.go @@ -80,6 +80,11 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate } } + if fileList, listErr := DryRunList(repoDir); listErr == nil { + stats := LanguageStats(fileList) + PrintLanguageBarChart(stats, len(fileList)) + } + spin := ui.Start("Creating repository archive…") zipPath, err := CreateZipFile(repoDir, nil) spin.Stop() @@ -314,7 +319,7 @@ func Hook(port int) error { addr := fmt.Sprintf("127.0.0.1:%d", port) conn, err := net.Dial("udp", addr) if err != nil { - // Daemon not running — silently exit + fmt.Fprintf(os.Stderr, "[supermodel] watch daemon not running on :%d — run `supermodel watch` to enable live updates\n", port) return nil } defer conn.Close() diff --git a/internal/files/watcher.go b/internal/files/watcher.go index 7208b5a..740e768 100644 --- a/internal/files/watcher.go +++ b/internal/files/watcher.go @@ -24,6 +24,7 @@ type Watcher struct { mu sync.Mutex lastKnownFiles map[string]struct{} lastIndexMod time.Time + lastCommitSHA string // HEAD at last poll; empty until first poll eventCh chan []WatchEvent } @@ -53,6 +54,7 @@ func (w *Watcher) Run(ctx context.Context) { defer close(w.eventCh) w.lastIndexMod = w.gitIndexMtime() + w.lastCommitSHA = w.runGit("rev-parse", "HEAD") for { select { @@ -71,6 +73,10 @@ func (w *Watcher) poll() { w.lastIndexMod = indexMod } + // Detect HEAD change (git commit, pull, checkout, merge, stash pop, etc.) + currentSHA := strings.TrimSpace(w.runGit("rev-parse", "HEAD")) + headChanged := currentSHA != "" && currentSHA != w.lastCommitSHA && w.lastCommitSHA != "" + currentDirty := w.gitDirtyFiles() w.mu.Lock() @@ -78,6 +84,19 @@ func (w *Watcher) poll() { var newEvents []WatchEvent now := time.Now() + + if headChanged { + // Emit all files that changed between the old and new HEAD. + diffOutput := w.runGit("diff", "--name-only", w.lastCommitSHA, currentSHA) + for _, line := range strings.Split(diffOutput, "\n") { + line = strings.TrimSpace(line) + if line != "" && isWatchSourceFile(line) { + newEvents = append(newEvents, WatchEvent{Path: line, Time: now}) + } + } + w.lastCommitSHA = currentSHA + } + for f := range currentDirty { if _, known := w.lastKnownFiles[f]; !known { newEvents = append(newEvents, WatchEvent{Path: f, Time: now}) diff --git a/internal/files/zip.go b/internal/files/zip.go index da86cd3..0b8e54f 100644 --- a/internal/files/zip.go +++ b/internal/files/zip.go @@ -44,16 +44,35 @@ var zipSkipExtensions = map[string]bool{ const maxFileSize = 500 * 1024 // 500KB -// loadCustomExclusions reads .supermodel.json from repoDir and adds any -// custom exclude_dirs and exclude_exts to the skip lists. -func loadCustomExclusions(repoDir string) { +// zipExclusions holds the merged skip lists for a single zip operation. +// It is built fresh for every CreateZipFile / DryRunList call so the global +// maps are never mutated (eliminates concurrent-write races). +type zipExclusions struct { + skipDirs map[string]bool + skipExts map[string]bool +} + +// buildExclusions merges the base skip lists with any custom entries read +// from .supermodel.json in repoDir and returns a per-call copy. +func buildExclusions(repoDir string) *zipExclusions { + ex := &zipExclusions{ + skipDirs: make(map[string]bool, len(zipSkipDirs)+4), + skipExts: make(map[string]bool, len(zipSkipExtensions)+4), + } + for k, v := range zipSkipDirs { + ex.skipDirs[k] = v + } + for k, v := range zipSkipExtensions { + ex.skipExts[k] = v + } + cfgPath := filepath.Join(repoDir, ".supermodel.json") if abs, err := filepath.Abs(cfgPath); err == nil { cfgPath = abs } data, err := os.ReadFile(cfgPath) if err != nil { - return + return ex } var cfg struct { ExcludeDirs []string `json:"exclude_dirs"` @@ -62,14 +81,15 @@ func loadCustomExclusions(repoDir string) { if err := json.Unmarshal(data, &cfg); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v — custom exclusions will be ignored\n", cfgPath, err) - return + return ex } for _, d := range cfg.ExcludeDirs { - zipSkipDirs[d] = true + ex.skipDirs[d] = true } for _, e := range cfg.ExcludeExts { - zipSkipExtensions[e] = true + ex.skipExts[e] = true } + return ex } // matchPattern does simple glob matching (*, ?). @@ -107,11 +127,11 @@ func matchPattern(pattern, name string) bool { return true } -func shouldInclude(relPath string, fileSize int64) bool { +func shouldInclude(relPath string, fileSize int64, ex *zipExclusions) bool { parts := strings.Split(filepath.ToSlash(relPath), "/") for _, part := range parts[:len(parts)-1] { - if zipSkipDirs[part] || hardBlocked[part] { + if ex.skipDirs[part] || hardBlocked[part] { return false } if strings.HasPrefix(part, ".") { @@ -136,7 +156,7 @@ func shouldInclude(relPath string, fileSize int64) bool { } ext := strings.ToLower(filepath.Ext(filename)) - if zipSkipExtensions[ext] { + if ex.skipExts[ext] { return false } @@ -156,7 +176,7 @@ func shouldInclude(relPath string, fileSize int64) bool { // for removing the file. // If onlyFiles is non-nil, only those relative paths are included (incremental mode). func CreateZipFile(repoDir string, onlyFiles []string) (string, error) { - loadCustomExclusions(repoDir) + ex := buildExclusions(repoDir) f, err := os.CreateTemp("", "supermodel-sidecars-*.zip") if err != nil { @@ -176,7 +196,7 @@ func CreateZipFile(repoDir string, onlyFiles []string) (string, error) { if info.Mode()&os.ModeSymlink != 0 { continue } - if !shouldInclude(rel, info.Size()) { + if !shouldInclude(rel, info.Size(), ex) { continue } if err := addFileToZip(zw, full, rel); err != nil { @@ -203,13 +223,13 @@ func CreateZipFile(repoDir string, onlyFiles []string) (string, error) { if info.IsDir() { name := info.Name() - if zipSkipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { + if ex.skipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { return filepath.SkipDir } return nil } - if !shouldInclude(rel, info.Size()) { + if !shouldInclude(rel, info.Size(), ex) { return nil } @@ -237,7 +257,7 @@ func CreateZipFile(repoDir string, onlyFiles []string) (string, error) { // DryRunList returns the list of files that would be included in the zip. func DryRunList(repoDir string) ([]string, error) { - loadCustomExclusions(repoDir) + ex := buildExclusions(repoDir) var files []string err := filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { @@ -256,13 +276,13 @@ func DryRunList(repoDir string) ([]string, error) { if info.IsDir() { name := info.Name() - if zipSkipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { + if ex.skipDirs[name] || hardBlocked[name] || strings.HasPrefix(name, ".") { return filepath.SkipDir } return nil } - if shouldInclude(rel, info.Size()) { + if shouldInclude(rel, info.Size(), ex) { files = append(files, rel) } return nil @@ -271,6 +291,61 @@ func DryRunList(repoDir string) ([]string, error) { return files, err } +// LangStat holds a file count for a single file extension. +type LangStat struct { + Ext string + Count int +} + +// LanguageStats counts files by extension (without leading dot), sorted by +// count descending. Only non-empty extensions are included; at most 10 are returned. +func LanguageStats(files []string) []LangStat { + counts := make(map[string]int) + for _, f := range files { + ext := strings.ToLower(filepath.Ext(f)) + if ext == "" { + continue + } + counts[strings.TrimPrefix(ext, ".")] ++ + } + stats := make([]LangStat, 0, len(counts)) + for ext, n := range counts { + stats = append(stats, LangStat{Ext: ext, Count: n}) + } + // sort descending by count, then alphabetically for ties + for i := 1; i < len(stats); i++ { + for j := i; j > 0; j-- { + a, b := stats[j-1], stats[j] + if a.Count < b.Count || (a.Count == b.Count && a.Ext > b.Ext) { + stats[j-1], stats[j] = b, a + } + } + } + if len(stats) > 10 { + stats = stats[:10] + } + return stats +} + +// PrintLanguageBarChart writes a compact bar chart of language stats to stderr. +func PrintLanguageBarChart(stats []LangStat, totalFiles int) { + if len(stats) == 0 { + return + } + maxCount := stats[0].Count + const maxBar = 28 + fmt.Fprintf(os.Stderr, "\n %d files to upload\n\n", totalFiles) + for _, s := range stats { + barLen := maxBar * s.Count / maxCount + if barLen < 1 { + barLen = 1 + } + bar := strings.Repeat("█", barLen) + fmt.Fprintf(os.Stderr, " %-6s %s %d\n", s.Ext, bar, s.Count) + } + fmt.Fprintln(os.Stderr) +} + // isSidecarFile checks if a filename is a generated sidecar (e.g. foo.graph.ts). func isSidecarFile(filename string) bool { ext := filepath.Ext(filename) diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 93ec807..7d612b9 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -80,16 +80,25 @@ func Run(ctx context.Context, cfg *config.Config) error { fmt.Printf(" %s✓%s Repository\n", green, reset) fmt.Println() - // ── Step 3: Claude Code hook ─────────────────────────────────── + // ── Step 3: Agent hook ──────────────────────────────────────── hookNote := "" - fmt.Printf(" %s◆%s Claude Code hook\n", cyan, reset) + fmt.Printf(" %s◆%s Agent hook\n", cyan, reset) + fmt.Println() + fmt.Printf(" %s.graph files work with any agent that can read files — Claude Code,%s\n", dWhite, reset) + fmt.Printf(" %sCursor, Copilot, Windsurf, Aider, and more.%s\n", dWhite, reset) fmt.Println() + if detectCursor(repoDir) { + fmt.Printf(" %sCursor detected%s — .graph files appear in context automatically\n", green, reset) + fmt.Printf(" %swhen you open a source file. No extra configuration needed.%s\n", dWhite, reset) + fmt.Println() + } + switch detectClaude() { case true: - fmt.Printf(" %sInstalls a PostToolUse hook that regenerates .graph files every%s\n", dWhite, reset) - fmt.Printf(" %stime Claude writes or edits a file — keeps context always fresh.%s\n", dWhite, reset) + fmt.Printf(" %sInstalls a Claude Code PostToolUse hook that regenerates .graph%s\n", dWhite, reset) + fmt.Printf(" %sfiles every time Claude writes or edits a file.%s\n", dWhite, reset) fmt.Println() if confirmYN("Install Claude Code hook?", true) { @@ -108,7 +117,8 @@ func Run(ctx context.Context, cfg *config.Config) error { fmt.Printf(" %s–%s Skipped\n", dim, reset) } default: - fmt.Printf(" %sClaude Code not detected. Add this to .claude/settings.json:%s\n", dWhite, reset) + fmt.Printf(" %sClaude Code not detected. To enable live updates, add this%s\n", dWhite, reset) + fmt.Printf(" %sto .claude/settings.json:%s\n", dWhite, reset) fmt.Println() fmt.Printf(" %s{%s\n", dim, reset) fmt.Printf(" %s \"hooks\": {%s\n", dim, reset) @@ -118,6 +128,9 @@ func Run(ctx context.Context, cfg *config.Config) error { fmt.Printf(" %s }]%s\n", dim, reset) fmt.Printf(" %s }%s\n", dim, reset) fmt.Printf(" %s}%s\n", dim, reset) + fmt.Println() + fmt.Printf(" %sOther agents (Cursor, Copilot, Windsurf, Aider) read .graph%s\n", dWhite, reset) + fmt.Printf(" %sfiles directly — no hook needed, just run `supermodel watch`.%s\n", dWhite, reset) } fmt.Println() @@ -211,6 +224,26 @@ func detectClaude() bool { return false } +// detectCursor checks if Cursor is installed or configured in repoDir. +func detectCursor(repoDir string) bool { + // Repo-level .cursor directory + if _, err := os.Stat(filepath.Join(repoDir, ".cursor")); err == nil { + return true + } + // Global ~/.cursor directory (macOS / Linux) + home, _ := os.UserHomeDir() + if home != "" { + if _, err := os.Stat(filepath.Join(home, ".cursor")); err == nil { + return true + } + // macOS app support directory + if _, err := os.Stat(filepath.Join(home, "Library", "Application Support", "Cursor")); err == nil { + return true + } + } + return false +} + // installHook writes the PostToolUse hook to .claude/settings.json in repoDir. // Returns true if newly installed, false if already present. Error on failure. func installHook(repoDir string) (bool, error) { @@ -231,7 +264,10 @@ func installHook(repoDir string) (bool, error) { settings = make(map[string]interface{}) } - const hookCmd = "supermodel hook" + hookCmd := "supermodel hook" + if exe, err := os.Executable(); err == nil { + hookCmd = exe + " hook" + } // Check if already installed. if hooks, ok := settings["hooks"].(map[string]interface{}); ok {