diff --git a/AGENTS.md b/AGENTS.md index 8daafae7..642f1f94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,7 +228,7 @@ This project is indexed by GitNexus as **graycode** (97743 symbols, 322940 relat | `gitnexus://repo/graycode/context` | Codebase overview, check index freshness | | `gitnexus://repo/graycode/clusters` | All functional areas | | `gitnexus://repo/graycode/processes` | All execution flows | -| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution swift | +| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution trace | ## CLI diff --git a/CLAUDE.md b/CLAUDE.md index 40345896..c3396ac3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ This project is indexed by GitNexus as **graycode** (97743 symbols, 322940 relat | `gitnexus://repo/graycode/context` | Codebase overview, check index freshness | | `gitnexus://repo/graycode/clusters` | All functional areas | | `gitnexus://repo/graycode/processes` | All execution flows | -| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution swift | +| `gitnexus://repo/graycode/process/{name}` | Step-by-step execution trace | ## CLI diff --git a/README.md b/README.md index b15fd0b3..52d489b6 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,11 @@ graycode cloud graph sync graycode cloud graph sync --mission-dir /path/to/mission ``` +Cloud commands require an endpoint. There is no default: pass `--endpoint` or +set `GRAYCODE_CLOUD_URL` to your Graycode Cloud worker URL before running +`graycode cloud login`. `https://api.graycodeai.com` is the browser BFF and +will reject a device token. + The export contains metadata and hashes, not prompts, tool arguments/results, policy reasons, verification evidence, or runtime output. Swift remains available separately as `graycode swift graph export`. Persisted chat sessions @@ -483,6 +488,14 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on |---|---|---| | **graycode** | This repo | AI coding agent | | **graycode-router** | [GrayCodeAI/graycode-router](https://github.com/GrayCodeAI/graycode-router) | LLM provider runtime | +| **graycode-skills** | [GrayCodeAI/graycode-skills](https://github.com/GrayCodeAI/graycode-skills) | Community skill registry | +| **graycode-platform** | [GrayCodeAI/graycode-platform](https://github.com/GrayCodeAI/graycode-platform) | Web, BFF, and Graycode Cloud | + +`ecosystem.yaml` is the canonical inventory of repositories cloned as +siblings in this local workspace; tooling reads it rather than carrying its +own repo-name list. `harrier`, `shrike`, `swift`, `kestrel`, `merlin` and +`falcon` above are consumed as pinned `go.mod` module dependencies rather +than local workspace clones, so they are not listed there. For the consolidated repo map and the current-vs-proposed architecture diagrams, see [docs/architecture/graycode-current-vs-proposed.md](docs/architecture/graycode-current-vs-proposed.md). For execution-graph ownership, automatic capture seams, export/sync commands, diff --git a/cmd/agent.go b/cmd/agent.go index 4e4c6549..c92ac8db 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -81,8 +81,8 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return nil } if len(all) == 0 { - fmt.Printf("No agents found. Create one with: graycode agent create \n") - fmt.Printf("Agent directory: %s\n", agents.DefaultDir()) + fmt.Printf("%s\n", auditTint("No agents found. Create one with: graycode agent create ", textMuted)) + fmt.Printf("%s\n", auditTint("Agent directory: "+agents.DefaultDir(), textPrimary)) return nil } @@ -146,8 +146,8 @@ You are a specialized agent. Complete tasks according to your expertise. return err } - fmt.Printf("Created agent %q at %s\n", name, path) - fmt.Printf("Edit the file to customize the system prompt.\n") + fmt.Printf("%s\n", auditTint("Created agent "+name+" at ", doneGreen)+auditTint(path, textPrimary)) + fmt.Printf("%s\n", auditTint("Edit the file to customize the system prompt.", textMuted)) return nil } @@ -157,15 +157,15 @@ func runAgentShow(_ *cobra.Command, args []string) error { return err } - fmt.Printf("Name: %s\n", a.Name) - fmt.Printf("Description: %s\n", a.Description) + fmt.Printf("%s %s\n", auditTint("Name:", textMuted), auditTint(a.Name, textPrimary)) + fmt.Printf("%s %s\n", auditTint("Description:", textMuted), auditTint(a.Description, textPrimary)) model := a.Model if model == "" { model = "(inherit from session)" } - fmt.Printf("Model: %s\n", model) - fmt.Printf("File: %s\n", a.FilePath) - fmt.Printf("\n--- Prompt ---\n%s\n", a.Prompt) + fmt.Printf("%s %s\n", auditTint("Model:", textMuted), auditTint(model, textPrimary)) + fmt.Printf("%s %s\n", auditTint("File:", textMuted), auditTint(a.FilePath, textPrimary)) + fmt.Printf("\n%s\n%s\n", auditTint("--- Prompt ---", graycodeColor), a.Prompt) return nil } @@ -178,6 +178,6 @@ func runAgentRemove(_ *cobra.Command, args []string) error { if err := os.Remove(a.FilePath); err != nil { return fmt.Errorf("remove %s: %w", a.FilePath, err) } - fmt.Printf("Removed agent %q (%s)\n", a.Name, a.FilePath) + fmt.Printf("%s\n", auditTint("Removed agent "+a.Name+" ("+a.FilePath+")", textPrimary)) return nil } diff --git a/cmd/ai_comments.go b/cmd/ai_comments.go index 8cd66252..9cf661f3 100644 --- a/cmd/ai_comments.go +++ b/cmd/ai_comments.go @@ -150,7 +150,7 @@ func processAIDirectives(dir string, ignore []string) int { processed := 0 for _, d := range directives { if err := aiDispatchFn(d); err != nil { - fmt.Fprintf(os.Stderr, "AI directive %s:%d failed: %v\n", d.Path, d.Line, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("AI directive %s:%d failed: %v", d.Path, d.Line, err), errorCoral)) continue } // Resolve back to an absolute path for removal; scan returns paths @@ -160,7 +160,7 @@ func processAIDirectives(dir string, ignore []string) int { full = filepath.Join(dir, d.Path) } if err := removeAIComment(full, d.Line); err != nil { - fmt.Fprintf(os.Stderr, "AI directive %s:%d: failed to strip token: %v\n", d.Path, d.Line, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("AI directive %s:%d: failed to strip token: %v", d.Path, d.Line, err), errorCoral)) continue } processed++ diff --git a/cmd/audit.go b/cmd/audit.go index 695235f8..34bc9f48 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -3,12 +3,14 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "path/filepath" "sort" "strings" "time" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/graycode-cli/internal/hooks/audit" "github.com/GrayCodeAI/graycode-cli/internal/storage" "github.com/spf13/cobra" @@ -65,6 +67,13 @@ type AuditResult struct { Detectors []AuditCount `json:"detectors"` } +// auditProgressEnabled reports whether per-session progress should be shown. +// JSON output must stay pure (progress lines would corrupt it) and piped +// text should not be spammed with per-session lines. +func auditProgressEnabled(format string, tty bool) bool { + return format != "json" && tty +} + func runAudit(cmd *cobra.Command, args []string) error { if auditJSON { auditFormat = "json" @@ -77,18 +86,37 @@ func runAudit(cmd *cobra.Command, args []string) error { } if len(sessions) == 0 { - cmd.Println("No session transcripts found for the specified time period.") + cmd.Println(auditTint("No session transcripts found for the specified time period.", textMuted)) return nil } + // Animated per-session progress only on a TTY and only for text output. + // JSON output must stay pure (progress lines would corrupt it) and piped + // text should not be spammed with per-session lines. + var prog *CLIProgress + if auditProgressEnabled(auditFormat, stdoutIsTerminal()) && !IsQuiet() { + names := make([]string, len(sessions)) + for i := range sessions { + names[i] = fmt.Sprintf("Scanning session %d/%d", i+1, len(sessions)) + } + prog = NewCLIProgress("Audit", names) + defer prog.Abort() + } + // Run audit detectors on each session detectors := audit.AllDetectors() counts := make(map[string]*AuditCount) totalHits := 0 - for _, sess := range sessions { + for i, sess := range sessions { + if prog != nil { + prog.StartStep(i) + } events, err := loadSessionEvents(sess.Path) if err != nil { + if prog != nil { + prog.FailStep(i, "load failed") + } continue } @@ -125,6 +153,12 @@ func runAudit(cmd *cobra.Command, args []string) error { } } } + if prog != nil { + prog.CompleteStep(i) + } + } + if prog != nil { + prog.Done() } // Count unique projects per detector @@ -250,25 +284,54 @@ func loadSessionEvents(path string) ([]audit.ToolEvent, error) { return events, nil } +// auditTint applies a theme foreground color when color output is appropriate +// (honors --quiet, NO_COLOR, FORCE_COLOR, and TTY state via ShouldColor). +// Piped output stays plain so scripts never see stray ANSI escapes. +func auditTint(s string, color color.Color) string { + if !ShouldColor() || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(color).Render(s) +} + +// auditSeverityColor maps a detector severity to a semantic theme color. +func auditSeverityColor(sev string) color.Color { + switch sev { + case "high", "critical": + return errorCoral + case "medium": + return warnAmber + default: // info, low + return infoSky + } +} + func printAuditText(cmd *cobra.Command, result AuditResult) { w := cmd.OutOrStdout() _, _ = fmt.Fprintf(w, "\n") _, _ = fmt.Fprintf(w, "═══════════════════════════════════════════════════════════════\n") - _, _ = fmt.Fprintf(w, " Graycode Audit Report\n") + _, _ = fmt.Fprintf(w, " %s\n", auditTint("Graycode Audit Report", graycodeColor)) _, _ = fmt.Fprintf(w, "═══════════════════════════════════════════════════════════════\n") _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, " Scanned: %d sessions (last %d days)\n", result.Sessions, result.Days) - _, _ = fmt.Fprintf(w, " Total hits: %d\n", result.TotalHits) - _, _ = fmt.Fprintf(w, " Scanned at: %s\n", result.ScannedAt) + _, _ = fmt.Fprintf(w, " %s %d sessions (last %d days)\n", + auditTint("Scanned:", textMuted), result.Sessions, result.Days) + hitsColor := doneGreen + if result.TotalHits > 0 { + hitsColor = errorCoral + } + _, _ = fmt.Fprintf(w, " %s %s\n", + auditTint("Total hits:", textMuted), auditTint(fmt.Sprintf("%d", result.TotalHits), hitsColor)) + _, _ = fmt.Fprintf(w, " %s %s\n", + auditTint("Scanned at:", textMuted), auditTint(result.ScannedAt, textPrimary)) if len(result.Detectors) == 0 { - _, _ = fmt.Fprintf(w, "\n No wasteful patterns detected. Great job!\n\n") + _, _ = fmt.Fprintf(w, "\n %s\n\n", auditTint("No wasteful patterns detected. Great job!", doneGreen)) return } _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Detected Patterns ───\n\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n\n", auditTint("Detected Patterns", infoSky)) _, _ = fmt.Fprintf(w, " %-30s %6s %8s %s\n", "DETECTOR", "HITS", "SEVERITY", "EXAMPLE") _, _ = fmt.Fprintf(w, " %-30s %6s %8s %s\n", strings.Repeat("─", 30), strings.Repeat("─", 6), strings.Repeat("─", 8), strings.Repeat("─", 30)) @@ -277,11 +340,14 @@ func printAuditText(cmd *cobra.Command, result AuditResult) { if len(d.Examples) > 0 { example = d.Examples[0] } - _, _ = fmt.Fprintf(w, " %-30s %6d %8s %s\n", d.Name, d.Hits, d.Severity, example) + // Pad to the column width first, then colorize, so ANSI escapes + // (zero-width) don't break the fixed-width alignment. + sev := auditTint(fmt.Sprintf("%8s", d.Severity), auditSeverityColor(d.Severity)) + _, _ = fmt.Fprintf(w, " %-30s %6d %s %s\n", d.Name, d.Hits, sev, example) } _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Remediation Tips ───\n\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n\n", auditTint("Remediation Tips", infoSky)) for _, d := range result.Detectors { switch d.Name { diff --git a/cmd/audit_progress_test.go b/cmd/audit_progress_test.go new file mode 100644 index 00000000..6f4a2060 --- /dev/null +++ b/cmd/audit_progress_test.go @@ -0,0 +1,46 @@ +package cmd + +import "testing" + +// TestAuditProgressEnabled guards the JSON-purity contract: per-session +// progress must never render when output is JSON (it would corrupt the +// payload) or when stdout is piped (it would spam the report). +func TestAuditProgressEnabled(t *testing.T) { + cases := []struct { + format string + tty bool + want bool + }{ + {"json", true, false}, // JSON output must stay pure even on a TTY + {"json", false, false}, // JSON + piped: never + {"text", false, false}, // piped text: no per-session spam + {"text", true, true}, // interactive text: animate + {"", true, true}, // default format on a TTY: animate + {"", false, false}, // default format piped: no spam + } + for _, c := range cases { + if got := auditProgressEnabled(c.format, c.tty); got != c.want { + t.Errorf("auditProgressEnabled(%q, %v) = %v, want %v", c.format, c.tty, got, c.want) + } + } +} + +// TestAuditSeverityColor verifies severity maps to the expected semantic +// theme color so the report's severity column reads consistently. +func TestAuditSeverityColor(t *testing.T) { + if auditSeverityColor("high") != errorCoral { + t.Error("high should map to errorCoral") + } + if auditSeverityColor("critical") != errorCoral { + t.Error("critical should map to errorCoral") + } + if auditSeverityColor("medium") != warnAmber { + t.Error("medium should map to warnAmber") + } + if auditSeverityColor("low") != infoSky { + t.Error("low should map to infoSky") + } + if auditSeverityColor("info") != infoSky { + t.Error("info should map to infoSky") + } +} diff --git a/cmd/bg_sessions.go b/cmd/bg_sessions.go index af1e0481..878687d7 100644 --- a/cmd/bg_sessions.go +++ b/cmd/bg_sessions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "image/color" "os" "os/exec" "path/filepath" @@ -160,14 +161,30 @@ func StartBGSession(prompt string, args []string) (*BGSessionInfo, error) { return info, nil } +// bgStatusColor maps a background-session status to a theme color. +func bgStatusColor(status string) color.Color { + switch status { + case "running": + return infoSky + case "completed": + return doneGreen + case "failed": + return errorCoral + case "killed": + return textMuted + default: + return textPrimary + } +} + // FormatBGSessions formats background sessions for display. func FormatBGSessions(sessions []*BGSessionInfo) string { if len(sessions) == 0 { - return "No background sessions." + return auditTint("No background sessions.", textMuted) } var b strings.Builder - b.WriteString(fmt.Sprintf("Background sessions (%d):\n", len(sessions))) + b.WriteString(auditTint(fmt.Sprintf("Background sessions (%d):", len(sessions)), textPrimary) + "\n") b.WriteString(strings.Repeat("─", 60) + "\n") for _, s := range sessions { @@ -180,8 +197,8 @@ func FormatBGSessions(sessions []*BGSessionInfo) string { preview = string(runes[:50]) + "..." } age := time.Since(s.StartedAt).Round(time.Minute) - b.WriteString(fmt.Sprintf(" [%s] %s — %s\n", shortID, s.Status, preview)) - b.WriteString(fmt.Sprintf(" PID: %d · started %s ago · %s\n\n", s.PID, age, s.CWD)) + b.WriteString(fmt.Sprintf(" %s %s %s\n", auditTint("["+shortID+"]", textPrimary), auditTint(s.Status, bgStatusColor(s.Status)), auditTint(preview, textMuted))) + b.WriteString(fmt.Sprintf(" %s %s · %s %s · %s\n\n", auditTint("PID:", textMuted), auditTint(fmt.Sprintf("%d", s.PID), textPrimary), auditTint("started", textMuted), auditTint(age.String()+" ago", textPrimary), auditTint(s.CWD, textMuted))) } return b.String() @@ -206,13 +223,13 @@ Examples: return err } - cmd.Printf("Background session started: %s (PID %d)\n", info.ID, info.PID) - cmd.Printf("View logs: tail -f %s\n", info.LogFile) + cmd.Printf("%s\n", auditTint("Background session started: ", doneGreen)+auditTint(info.ID, textPrimary)+auditTint(fmt.Sprintf(" (PID %d)", info.PID), textMuted)) + cmd.Printf("%s\n", auditTint("View logs: tail -f "+info.LogFile, textMuted)) attachID := info.ID if len(attachID) > 8 { attachID = attachID[:8] } - cmd.Printf("Attach: graycode attach %s\n", attachID) + cmd.Printf("%s\n", auditTint("Attach: graycode attach "+attachID, textMuted)) return nil }, } @@ -245,13 +262,13 @@ var attachCmd = &cobra.Command{ } if target.Status != "running" { - cmd.Printf("Session %s is %s\n", target.ID, target.Status) - cmd.Println("Recent log output:") + cmd.Printf("%s\n", auditTint("Session "+target.ID+" is ", textPrimary)+auditTint(target.Status, warnAmber)) + cmd.Println(auditTint("Recent log output:", textPrimary)) return tailLog(cmd, target.LogFile, 20) } - cmd.Printf("Attaching to session %s (PID %d)\n", target.ID, target.PID) - cmd.Println("Recent output:") + cmd.Printf("%s\n", auditTint("Attaching to session "+target.ID+" (PID "+fmt.Sprint(target.PID)+")", textPrimary)) + cmd.Println(auditTint("Recent output:", textPrimary)) return tailLog(cmd, target.LogFile, 30) }, } @@ -289,7 +306,7 @@ var sessionsKillCmd = &cobra.Command{ if err := KillBGSession(args[0]); err != nil { return err } - cmd.Println("Session killed:", args[0]) + cmd.Println(auditTint("Session killed: "+args[0], textPrimary)) return nil }, } diff --git a/cmd/chat.go b/cmd/chat.go index 9ecee297..69e82398 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -72,7 +72,7 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { } if sessionIDFlag != "" && (resumeID != "" || continueFlag) { // --session-id is ignored when --resume or --continue is also given. - fmt.Fprintf(os.Stderr, "graycode: --session-id ignored during resume/continue\n") + fmt.Fprintf(os.Stderr, "%s\n", auditTint("graycode: --session-id ignored during resume/continue", textMuted)) } if resumeID == "" && !continueFlag { return id, nil, nil @@ -450,7 +450,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings grayco runtime := plugin.NewRuntime() if err := runtime.LoadAll(); err != nil { // Surface plugin load failure so users know plugins are missing. - fmt.Fprintf(os.Stderr, "Warning: failed to load plugins: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Warning: failed to load plugins: %v", err), warnAmber)) return } runtime.RegisterHooks() diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 4970e755..6fefebee 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -96,7 +96,7 @@ func runPrint(text string) error { // surface the remaining time budget once, on the first content. if countdown && !countdownShown { if rem := lifecycle.RemainingTime(ctx); rem != "" { - fmt.Fprintf(os.Stderr, "[time remaining] %s\n", rem) + fmt.Fprintf(os.Stderr, "%s\n", auditTint("[time remaining] "+rem, warnAmber)) countdownShown = true } } @@ -104,7 +104,7 @@ func runPrint(text string) error { if outputFormat == "stream-json" { writePrintEvent(sessionID, "tool_use", "", ev.ToolName) } else { - _, _ = fmt.Fprintf(os.Stderr, "\n[%s]\n", ev.ToolName) + _, _ = fmt.Fprintf(os.Stderr, "\n%s\n", auditTint("["+ev.ToolName+"]", infoSky)) } case "tool_result": content := ev.Content @@ -115,7 +115,7 @@ func runPrint(text string) error { if outputFormat == "stream-json" { writePrintEvent(sessionID, "tool_result", content, ev.ToolName) } else { - _, _ = fmt.Fprintf(os.Stderr, "[%s] %s\n", ev.ToolName, content) + _, _ = fmt.Fprintf(os.Stderr, "%s %s\n", auditTint("["+ev.ToolName+"]", infoSky), content) } case "usage": if outputFormat == "stream-json" && ev.Usage != nil { @@ -262,7 +262,7 @@ func saveGraycodeRouterSession(id string, sess *engine.Session) { // runRepl starts an interactive REPL mode for multi-turn conversation without TUI. func runRepl() error { - fmt.Fprintln(os.Stderr, "Graycode REPL — type 'exit' or 'quit' to leave, 'help' for commands") + fmt.Fprintln(os.Stderr, auditTint("Graycode REPL", textPrimary)+auditTint(" — type 'exit' or 'quit' to leave, 'help' for commands", textMuted)) fmt.Fprintln(os.Stderr) systemPrompt, err := buildSystemPrompt() @@ -354,7 +354,7 @@ func runRepl() error { } if output, handled, builtinErr := replBuiltinResponse(input, sess, settings, sessionID); handled { if builtinErr != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", builtinErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %v", builtinErr), errorCoral)) continue } if output != "" { @@ -367,7 +367,7 @@ func runRepl() error { ch, err := sess.Stream(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %v", err), errorCoral)) continue } @@ -413,7 +413,7 @@ func runRepl() error { if outputFormat == "stream-json" { writePrintResult(printed.String(), sessionID, sess, true, []string{ev.Content}) } - fmt.Fprintf(os.Stderr, "Error: %s\n", ev.Content) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Error: %s", ev.Content), errorCoral)) case "done": switch outputFormat { case "text": @@ -524,16 +524,16 @@ func runWatch(initialPrompt string) error { // Optional initial run to seed context, matching the prior behaviour. if strings.TrimSpace(initialPrompt) != "" { if err := runPrint(initialPrompt); err != nil { - fmt.Fprintf(os.Stderr, "Initial run failed: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint("Initial run failed: "+err.Error(), errorCoral)) } } root := "." - fmt.Fprintln(os.Stderr, "\n[Watching for AI!/AI? comment directives — press Ctrl+C to stop]") + fmt.Fprintln(os.Stderr, "\n"+auditTint("[Watching for AI!/AI? comment directives — press Ctrl+C to stop]", textPrimary)) // Process any directives already present before the first change event. if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } // Prefer the fsnotify event-driven backend. The AI!/AI? directive grammar @@ -543,14 +543,14 @@ func runWatch(initialPrompt string) error { watcher := aiwatch.NewAIWatcher(root, nil) watcher.OnChange = func() { if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } } ctx := context.Background() if err := watcher.StartFsnotify(ctx); err != nil { // fsnotify unavailable — fall back to the polling backstop. - fmt.Fprintf(os.Stderr, "[watch] fsnotify unavailable (%v), using polling fallback\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[watch] fsnotify unavailable (%v), using polling fallback", err), warnAmber)) return runWatchPolling(root) } return nil @@ -569,7 +569,7 @@ func runWatchPolling(root string) error { if currentMod.After(lastMod) { lastMod = currentMod if n := processAIDirectives(root, watchIgnoreDirs); n > 0 { - fmt.Fprintf(os.Stderr, "[%s] processed %d AI directive(s)\n", time.Now().Format("15:04:05"), n) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("[%s] processed %d AI directive(s)", time.Now().Format("15:04:05"), n), textPrimary)) } } } diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index cce02727..edb316cd 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "sort" "strings" @@ -421,22 +422,32 @@ func configCommandSummary(settings graycodeconfig.Settings) string { _ = settings providerName := displayConfigValue(graycodeconfig.ActiveProvider(context.Background())) modelName := displayConfigValue(graycodeconfig.ActiveModel(context.Background())) - return fmt.Sprintf(`Setup (graycode-router) + keys := configuredKeyList() + keysColor := infoSky + if keys == "(none)" { + keysColor = textMuted + } + return fmt.Sprintf(`%s /config → paste API key (OS keychain) + pick model /path → verify readiness in TUI graycode path (CLI) -Current: - provider: %s - model: %s - keys: %s - -Model catalog and routing live in graycode-router — graycode is the UI only.`, providerName, modelName, configuredKeyList()) +%s: + %s %s + %s %s + %s %s + +Model catalog and routing live in graycode-router — graycode is the UI only.`, + auditTint("Setup (graycode-router)", textPrimary), + auditTint("Current", textPrimary), + auditTint("provider:", textMuted), auditTint(providerName, infoSky), + auditTint("model:", textMuted), auditTint(modelName, infoSky), + auditTint("keys:", textMuted), auditTint(keys, keysColor)) } func apiKeyConfigSummary() string { - return "API keys (" + graycodeconfig.CredentialStoreName() + ")\n" + indentedAPIKeyLines() + return auditTint("API keys ("+graycodeconfig.CredentialStoreName()+")", textPrimary) + "\n" + indentedAPIKeyLines() } func configuredKeyList() string { @@ -456,9 +467,29 @@ func configuredKeyList() string { func indentedAPIKeyLines() string { lines := apiKeyStatusLines() if len(lines) == 0 { - return " (empty)" + return " " + auditTint("(empty)", textMuted) + } + var b strings.Builder + for _, line := range lines { + name, status, ok := strings.Cut(line, ": ") + if !ok { + b.WriteString(" " + line + "\n") + continue + } + b.WriteString(" " + auditTint(name, textPrimary) + ": " + auditTint(status, apiKeyStatusColor(status)) + "\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func apiKeyStatusColor(status string) color.Color { + switch status { + case "set": + return doneGreen + case "local": + return infoSky + default: + return textMuted } - return " " + strings.Join(lines, "\n ") } func apiKeyStatusLines() []string { diff --git a/cmd/checkpoint.go b/cmd/checkpoint.go index 2a506637..46b87b46 100644 --- a/cmd/checkpoint.go +++ b/cmd/checkpoint.go @@ -48,9 +48,8 @@ var checkpointSaveCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Saved checkpoint %q (session %s, %d messages, %s/%s)\n", - cp.Name, cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model) - cmd.Printf("Resume with: graycode resume %s\n", name) + cmd.Printf("%s\n", auditTint("Saved checkpoint ", doneGreen)+auditTint(cp.Name, textPrimary)+auditTint(fmt.Sprintf(" (session %s, %d messages, %s/%s)", cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model), textMuted)) + cmd.Printf("%s\n", auditTint("Resume with: graycode resume "+name, textMuted)) return nil }, } @@ -75,10 +74,10 @@ var checkpointListCmd = &cobra.Command{ return nil } if len(cps) == 0 { - cmd.Println("No named checkpoints.") + cmd.Println(auditTint("No named checkpoints.", textMuted)) return nil } - cmd.Printf("Named checkpoints (%d):\n", len(cps)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Named checkpoints (%d):", len(cps)), textPrimary)) now := time.Now() for _, cp := range cps { age := now.Sub(cp.CreatedAt).Round(time.Second) @@ -86,7 +85,7 @@ var checkpointListCmd = &cobra.Command{ if cp.Session != nil { msgs = len(cp.Session.Messages) } - cmd.Printf(" %-20s %d msgs (%s ago)\n", cp.Name, msgs, age) + cmd.Printf(" %s %s\n", auditTint(fmt.Sprintf("%-20s", cp.Name), textPrimary), auditTint(fmt.Sprintf("%d msgs (%s ago)", msgs, age), textMuted)) } return nil }, @@ -109,7 +108,7 @@ var checkpointDeleteCmd = &cobra.Command{ if err := session.DeleteNamedCheckpoint(args[0]); err != nil { return err } - cmd.Printf("Deleted checkpoint %q\n", args[0]) + cmd.Printf("%s\n", auditTint("Deleted checkpoint "+args[0], textPrimary)) return nil }, } @@ -142,9 +141,8 @@ func restoreNamedCheckpoint(cmd *cobra.Command, name string) error { if err := session.Save(cp.Session); err != nil { return fmt.Errorf("restore session: %w", err) } - cmd.Printf("Restored checkpoint %q into session %s (%d messages, %s/%s)\n", - cp.Name, cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model) - cmd.Printf("Continue with: graycode --resume %s\n", cp.Session.ID) + cmd.Printf("%s\n", auditTint("Restored checkpoint ", doneGreen)+auditTint(cp.Name, textPrimary)+auditTint(fmt.Sprintf(" into session %s (%d messages, %s/%s)", cp.Session.ID, len(cp.Session.Messages), cp.Session.Provider, cp.Session.Model), textMuted)) + cmd.Printf("%s\n", auditTint("Continue with: graycode --resume "+cp.Session.ID, textMuted)) return nil } diff --git a/cmd/cloud.go b/cmd/cloud.go index b3cee47c..f4f5347c 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -27,7 +27,7 @@ var cloudConnectCmd = &cobra.Command{ if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { return err } - cmd.Println("Graycode Cloud connected. Usage synchronization is opt-in and fail-open.") + cmd.Println(auditTint("Graycode Cloud connected. Usage synchronization is opt-in and fail-open.", doneGreen)) return nil }, } @@ -54,38 +54,49 @@ var cloudLoginCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Open %s and enter code %s\n", start.VerificationURI, start.UserCode) + cmd.Printf("%s\n", auditTint("Open ", textPrimary)+auditTint(start.VerificationURI, infoSky)+auditTint(" and enter code ", textPrimary)+auditTint(start.UserCode, graycodeColor)) if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { - cmd.Printf("Could not open the browser automatically: %v\n", err) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Could not open the browser automatically: %v", err), textMuted)) } interval := time.Duration(start.Interval) * time.Second if interval < time.Second { interval = 5 * time.Second } + prog := NewCLIProgress("Cloud", []string{"Waiting for browser approval"}) + defer prog.Abort() + prog.StartStep(0) for { poll, pollErr := client.PollDeviceLogin(ctx, start.DeviceCode) if pollErr != nil { + prog.FailStep(0, pollErr.Error()) return pollErr } switch poll.Status { case "pending": select { case <-ctx.Done(): + prog.FailStep(0, ctx.Err().Error()) return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) case <-time.After(interval): } case "approved": if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { + prog.FailStep(0, "incomplete device authorization") return fmt.Errorf("graycode cloud returned an incomplete device authorization") } if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + prog.FailStep(0, err.Error()) return err } - cmd.Printf("Graycode Cloud connected for project %s.\n", poll.ProjectID) + prog.CompleteStep(0) + prog.Done() + cmd.Println(auditTint("Graycode Cloud connected for project ", doneGreen) + auditTint(poll.ProjectID, textPrimary) + auditTint(".", doneGreen)) return nil case "expired": + prog.FailStep(0, "device authorization expired") return fmt.Errorf("graycode cloud device authorization expired") default: + prog.FailStep(0, fmt.Sprintf("unknown status %q", poll.Status)) return fmt.Errorf("graycode cloud returned unknown device authorization status %q", poll.Status) } } @@ -97,10 +108,10 @@ var cloudStatusCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, _ []string) error { client, cfg, err := cloud.LoadClient() if err != nil || !client.Enabled() { - cmd.Println("Graycode Cloud is not connected.") + cmd.Println(auditTint("Graycode Cloud is not connected.", textMuted)) return nil } - cmd.Printf("Graycode Cloud connected: %s (device %s, project %s)\n", cfg.Endpoint, cfg.DeviceID, cfg.ProjectID) + cmd.Println(auditTint("Graycode Cloud connected: ", doneGreen) + auditTint(cfg.Endpoint, textPrimary) + auditTint(fmt.Sprintf(" (device %s, project %s)", cfg.DeviceID, cfg.ProjectID), textMuted)) return nil }, } @@ -171,7 +182,7 @@ var cloudContextCmd = &cobra.Command{ event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} } client.RecordDeliveryContext(cmd.Context(), event) - cmd.Println("Repository context queued for Graycode Cloud.") + cmd.Println(auditTint("Repository context queued for Graycode Cloud.", doneGreen)) return nil }, } diff --git a/cmd/cloud_graph.go b/cmd/cloud_graph.go index 12d514ee..1d7d839d 100644 --- a/cmd/cloud_graph.go +++ b/cmd/cloud_graph.go @@ -68,12 +68,10 @@ execution never depends on cloud synchronization.`, if result.Duplicate { status = "already synchronized" } - cmd.Printf( - "Graph %s: %d facts (digest %s).\n", - status, - prepared.Facts, - result.GraphDigest, - ) + cmd.Printf("%s\n", + auditTint("Graph "+status+": ", doneGreen)+ + auditTint(fmt.Sprintf("%d facts", prepared.Facts), textPrimary)+ + auditTint(fmt.Sprintf(" (digest %s).", result.GraphDigest), textMuted)) return nil }, } diff --git a/cmd/cmdhistory_cmd.go b/cmd/cmdhistory_cmd.go index 5bdc7a68..8038c1f5 100644 --- a/cmd/cmdhistory_cmd.go +++ b/cmd/cmdhistory_cmd.go @@ -60,7 +60,7 @@ var cmdHistorySearchCmd = &cobra.Command{ } if len(entries) == 0 { - cmd.Println("No matching commands found.") + cmd.Println(auditTint("No matching commands found.", textMuted)) return nil } @@ -97,7 +97,7 @@ var cmdHistoryRecentCmd = &cobra.Command{ } if len(entries) == 0 { - cmd.Println("No command history found.") + cmd.Println(auditTint("No command history found.", textMuted)) return nil } @@ -123,23 +123,23 @@ var cmdHistoryStatsCmd = &cobra.Command{ return fmt.Errorf("stats query failed: %w", err) } - cmd.Println(fmt.Sprintf("Total commands: %d", stats.TotalCommands)) - cmd.Println(fmt.Sprintf("Unique commands: %d", stats.UniqueCommands)) - cmd.Println(fmt.Sprintf("Success rate: %.1f%%", stats.SuccessRate*100)) + cmd.Println(auditTint("Total commands: ", textMuted) + auditTint(fmt.Sprintf("%d", stats.TotalCommands), textPrimary)) + cmd.Println(auditTint("Unique commands: ", textMuted) + auditTint(fmt.Sprintf("%d", stats.UniqueCommands), textPrimary)) + cmd.Println(auditTint("Success rate: ", textMuted) + auditTint(fmt.Sprintf("%.1f%%", stats.SuccessRate*100), textPrimary)) cmd.Println() if len(stats.TopCommands) > 0 { - cmd.Println("Top commands:") + cmd.Println(auditTint("Top commands:", textPrimary)) for _, tc := range stats.TopCommands { - cmd.Println(fmt.Sprintf(" %4d %s", tc.Count, tc.Command)) + cmd.Println(auditTint(fmt.Sprintf(" %4d %s", tc.Count, tc.Command), textMuted)) } cmd.Println() } if len(stats.TopDirectories) > 0 { - cmd.Println("Top directories:") + cmd.Println(auditTint("Top directories:", textPrimary)) for _, td := range stats.TopDirectories { - cmd.Println(fmt.Sprintf(" %4d %s", td.Count, td.Dir)) + cmd.Println(auditTint(fmt.Sprintf(" %4d %s", td.Count, td.Dir), textMuted)) } } @@ -175,20 +175,19 @@ func openCmdHistoryStore() (*cmdhistory.Store, error) { func printCmdHistoryEntry(cmd *cobra.Command, e cmdhistory.Entry) { exitLabel := "ok" + exitColor := doneGreen if e.ExitCode != 0 { exitLabel = fmt.Sprintf("exit:%d", e.ExitCode) + exitColor = errorCoral } - cmd.Println(fmt.Sprintf( - "[%s] [%s] [%s] %s", - e.CreatedAt.Format("2006-01-02 15:04:05"), - exitLabel, - e.Duration.Round(1), - e.Command, - )) + cmd.Println(auditTint("["+e.CreatedAt.Format("2006-01-02 15:04:05")+"] ", textMuted) + + auditTint("["+exitLabel+"] ", exitColor) + + auditTint("["+e.Duration.Round(1).String()+"] ", textMuted) + + auditTint(e.Command, textPrimary)) if e.CWD != "" { - cmd.Println(fmt.Sprintf(" cwd: %s", e.CWD)) + cmd.Println(auditTint(" cwd: ", textMuted) + auditTint(e.CWD, textPrimary)) } if e.GitBranch != "" { - cmd.Println(fmt.Sprintf(" branch: %s", e.GitBranch)) + cmd.Println(auditTint(" branch: ", textMuted) + auditTint(e.GitBranch, textPrimary)) } } diff --git a/cmd/cost.go b/cmd/cost.go index 0b479fe6..bfb61476 100644 --- a/cmd/cost.go +++ b/cmd/cost.go @@ -51,20 +51,20 @@ var costAnalyzeCmd = &cobra.Command{ return nil } - cmd.Println("[Experimental] Cost tracking is not yet fully available.") + cmd.Println(auditTint("[Experimental] Cost tracking is not yet fully available.", warnAmber)) cmd.Println() if report.TotalSpend == 0 { - cmd.Println("No cost data collected in this session.") + cmd.Println(auditTint("No cost data collected in this session.", textMuted)) cmd.Println() - cmd.Println("Once session data integration is complete, the analyzer will support:") - cmd.Println(" - Spend breakdown by model and task type") - cmd.Println(" - Wasted spend detection (expensive models for simple tasks)") - cmd.Println(" - Abandoned output tracking") - cmd.Println(" - Model routing recommendations") - cmd.Println(" - Prompt caching suggestions") + cmd.Println(auditTint("Once session data integration is complete, the analyzer will support:", textMuted)) + cmd.Println(auditTint(" - Spend breakdown by model and task type", textMuted)) + cmd.Println(auditTint(" - Wasted spend detection (expensive models for simple tasks)", textMuted)) + cmd.Println(auditTint(" - Abandoned output tracking", textMuted)) + cmd.Println(auditTint(" - Model routing recommendations", textMuted)) + cmd.Println(auditTint(" - Prompt caching suggestions", textMuted)) cmd.Println() - cmd.Println("To track progress: https://github.com/GrayCodeAI/graycode-cli/issues") + cmd.Println(auditTint("To track progress: https://github.com/GrayCodeAI/graycode-cli/issues", textMuted)) return nil } @@ -89,25 +89,25 @@ var costSummaryCmd = &cobra.Command{ return nil } - cmd.Println("[Experimental] Cost tracking is not yet fully available.") + cmd.Println(auditTint("[Experimental] Cost tracking is not yet fully available.", warnAmber)) cmd.Println() if report.TotalSpend == 0 { - cmd.Println("No cost data collected in this session.") - cmd.Println("Cost tracking will be available once session data integration is complete.") + cmd.Println(auditTint("No cost data collected in this session.", textMuted)) + cmd.Println(auditTint("Cost tracking will be available once session data integration is complete.", textMuted)) return nil } - cmd.Println(fmt.Sprintf("Total spend: $%.4f", report.TotalSpend)) - cmd.Println(fmt.Sprintf("Productive spend: $%.4f", report.ProductiveSpend)) - cmd.Println(fmt.Sprintf("Wasted spend: $%.4f", report.WastedSpend)) - cmd.Println(fmt.Sprintf("Yield rate: %.1f%%", report.YieldRate*100)) + cmd.Println(auditTint("Total spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.TotalSpend), textPrimary)) + cmd.Println(auditTint("Productive spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.ProductiveSpend), textPrimary)) + cmd.Println(auditTint("Wasted spend: ", textMuted) + auditTint(fmt.Sprintf("$%.4f", report.WastedSpend), errorCoral)) + cmd.Println(auditTint("Yield rate: ", textMuted) + auditTint(fmt.Sprintf("%.1f%%", report.YieldRate*100), textPrimary)) if len(report.Recommendations) > 0 { cmd.Println() - cmd.Println("Top recommendation:") + cmd.Println(auditTint("Top recommendation:", textPrimary)) rec := report.Recommendations[0] - cmd.Println(fmt.Sprintf(" [%s] %s (est. savings: $%.4f)", rec.Type, rec.Description, rec.Savings)) + cmd.Println(auditTint(fmt.Sprintf(" [%s] %s (est. savings: $%.4f)", rec.Type, rec.Description, rec.Savings), textPrimary)) } return nil }, diff --git a/cmd/credentials.go b/cmd/credentials.go index 5ea3f048..a9cac742 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -34,7 +34,7 @@ var credentialsRemoveCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Removed %d key(s) from %s: %s\n", len(removed), graycodeconfig.CredentialStoreName(), strings.Join(removed, ", ")) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Removed %d key(s) from %s: %s", len(removed), graycodeconfig.CredentialStoreName(), strings.Join(removed, ", ")), doneGreen)) return nil }, } @@ -53,9 +53,9 @@ var credentialsMigrateCmd = &cobra.Command{ return err } if n == 0 { - cmd.Println("No plaintext credential files found (already using secure storage).") + cmd.Println(auditTint("No plaintext credential files found (already using secure storage).", textMuted)) } else { - cmd.Printf("Migrated %d key(s) to %s and removed plaintext credential files.\n", n, graycodeconfig.CredentialStoreName()) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Migrated %d key(s) to %s and removed plaintext credential files.", n, graycodeconfig.CredentialStoreName()), doneGreen)) } return nil }, diff --git a/cmd/daemon.go b/cmd/daemon.go index bce69d3d..bfb167cc 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -86,7 +86,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { // Initialize OpenTelemetry telemetry (opt-in via GRAYCODE_ENABLE_TELEMETRY=1). telemetryProviders, telemetryErr := oteltrace.InitTelemetry(oteltrace.DefaultTelemetryConfig()) if telemetryErr != nil { - fmt.Fprintln(os.Stderr, "warning: telemetry initialization failed:", telemetryErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: telemetry initialization failed: %v", telemetryErr), warnAmber)) } if telemetryProviders != nil && telemetryErr == nil && telemetryProviders.IsEnabled() { defer func() { @@ -100,7 +100,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { // tracing; failures are non-fatal. logBackend, logBackendErr := otellog.NewBackend(otellog.DefaultConfig()) if logBackendErr != nil { - fmt.Fprintln(os.Stderr, "warning: telemetry log backend initialization failed:", logBackendErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: telemetry log backend initialization failed: %v", logBackendErr), warnAmber)) } if logBackend != nil && logBackend.Sharing() != otellog.SharingDisabled { defer func() { @@ -117,7 +117,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { if logErr != nil { // Fall back to stderr if file logging fails. daemonLogger = logger.New(os.Stderr, logLevelFromString(daemonLogLevel)) - fmt.Fprintln(os.Stderr, "warning: daemon file logging failed, falling back to stderr:", logErr) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: daemon file logging failed, falling back to stderr: %v", logErr), warnAmber)) } else { daemonLogger = logger.New(logFile, logLevelFromString(daemonLogLevel)) } @@ -253,7 +253,7 @@ func runDaemonStart(_ *cobra.Command, _ []string) error { fmt.Printf(" ssh -L %d:127.0.0.1:%d \n", daemonPort, daemonPort) fmt.Printf(" curl http://localhost:%d/v1/health\n", daemonPort) } else { - fmt.Println("\nWARNING: Bound to non-localhost. Ensure TLS is configured for production use.") + fmt.Println(auditTint("\nWARNING: Bound to non-localhost. Ensure TLS is configured for production use.", warnAmber)) } fmt.Println("Press Ctrl+C to stop.") @@ -451,7 +451,7 @@ func runDaemonStop(_ *cobra.Command, _ []string) error { } _ = os.Remove(pidFile) - fmt.Printf("Stopped daemon (PID %d)\n", info.PID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Stopped daemon (PID %d)", info.PID), doneGreen)) return nil } @@ -463,7 +463,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running"}`) } else { - fmt.Println("Status: not running") + fmt.Println(auditTint("Status: not running", textMuted)) } return nil } @@ -477,7 +477,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"unknown","error":"invalid PID file"}`) } else { - fmt.Println("Status: unknown (invalid PID file)") + fmt.Println(auditTint("Status: unknown (invalid PID file)", warnAmber)) } return nil } @@ -488,7 +488,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running","error":"stale PID file"}`) } else { - fmt.Println("Status: not running (stale PID file)") + fmt.Println(auditTint("Status: not running (stale PID file)", warnAmber)) } _ = os.Remove(pidFile) return nil @@ -497,7 +497,7 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { if daemonJSON { fmt.Println(`{"status":"not running","error":"stale PID file"}`) } else { - fmt.Println("Status: not running (stale PID file)") + fmt.Println(auditTint("Status: not running (stale PID file)", warnAmber)) } _ = os.Remove(pidFile) return nil @@ -514,9 +514,9 @@ func runDaemonStatus(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Status: running\n") - fmt.Printf(" PID: %d\n", info.PID) - fmt.Printf(" Address: http://%s\n", info.Addr) - fmt.Printf(" Started: %s\n", info.StartedAt) + fmt.Printf("%s\n", auditTint("Status: running", doneGreen)) + fmt.Printf(" %s %d\n", auditTint("PID:", textMuted), info.PID) + fmt.Printf(" %s %s\n", auditTint("Address:", textMuted), auditTint("http://"+info.Addr, textPrimary)) + fmt.Printf(" %s %s\n", auditTint("Started:", textMuted), auditTint(info.StartedAt, textPrimary)) return nil } diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index f47fef38..23998c5b 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "image/color" "os" "os/exec" "path/filepath" @@ -172,13 +173,16 @@ func healthCheckReport(settings graycodeconfig.Settings, provider string) string var b strings.Builder b.WriteString("Health checks:\n") for _, check := range results { - status := icons.CheckBold() + " " + status := auditTint(icons.CheckBold()+" ", doneGreen) + var msgColor color.Color = textMuted if check.Status == health.Unhealthy { - status = icons.CloseThick() + " " + status = auditTint(icons.CloseThick()+" ", errorCoral) + msgColor = errorCoral } else if check.Status == health.Degraded { - status = icons.Alert() + " " + status = auditTint(icons.Alert()+" ", warnAmber) + msgColor = warnAmber } - b.WriteString(fmt.Sprintf(" %s %s: %s\n", status, check.Name, check.Message)) + b.WriteString(fmt.Sprintf(" %s %s: %s\n", status, auditTint(check.Name, textPrimary), auditTint(check.Message, msgColor))) } return strings.TrimRight(b.String(), "\n") } @@ -228,16 +232,16 @@ func settingsSummary(settings graycodeconfig.Settings) string { func mcpConfigSummary(settings graycodeconfig.Settings) string { if len(settings.MCPServers) == 0 && len(mcpServers) == 0 { - return "No MCP servers configured." + return auditTint("No MCP servers configured.", textMuted) } var b strings.Builder - b.WriteString("MCP servers:\n") + b.WriteString(auditTint("MCP servers:", textPrimary) + "\n") for _, cfg := range settings.MCPServers { name := cfg.Name if name == "" { name = cfg.Command } - b.WriteString(fmt.Sprintf(" %s: %s %s\n", name, cfg.Command, strings.Join(cfg.Args, " "))) + b.WriteString(fmt.Sprintf(" %s: %s %s\n", auditTint(name, textPrimary), cfg.Command, strings.Join(cfg.Args, " "))) } for _, cmd := range mcpServers { b.WriteString(" cli: " + cmd + "\n") @@ -248,16 +252,16 @@ func mcpConfigSummary(settings graycodeconfig.Settings) string { func sessionsSummary() string { entries, err := session.List() if err != nil || len(entries) == 0 { - return "No saved sessions." + return auditTint("No saved sessions.", textMuted) } var b strings.Builder - b.WriteString("Saved sessions:\n") + b.WriteString(auditTint("Saved sessions:", textPrimary) + "\n") for _, e := range entries { cwd := e.CWD if cwd == "" { cwd = "-" } - b.WriteString(fmt.Sprintf(" %s %s %s %s\n", e.ID, e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview)) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview)) } return strings.TrimRight(b.String(), "\n") } @@ -266,16 +270,16 @@ func builtInToolsSummary() string { essential := essentialTools() optional := optionalTools() var b strings.Builder - b.WriteString(fmt.Sprintf("Built-in tools (%d total: %d essential, %d optional):\n", len(essential)+len(optional), len(essential), len(optional))) - b.WriteString(" Essential (loaded at startup):\n") + b.WriteString(auditTint(fmt.Sprintf("Built-in tools (%d total: %d essential, %d optional):", len(essential)+len(optional), len(essential), len(optional)), textPrimary) + "\n") + b.WriteString(" " + auditTint("Essential (loaded at startup):", textMuted) + "\n") for _, t := range essential { - b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description())) + b.WriteString(fmt.Sprintf(" %s - %s\n", auditTint(t.Name(), textPrimary), t.Description())) } - b.WriteString(" Optional (lazy-loaded):\n") + b.WriteString(" " + auditTint("Optional (lazy-loaded):", textMuted) + "\n") for _, t := range optional { - b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description())) + b.WriteString(fmt.Sprintf(" %s - %s\n", auditTint(t.Name(), textPrimary), t.Description())) } - b.WriteString("\nIntent bundles:\n") + b.WriteString("\n" + auditTint("Intent bundles:", textMuted) + "\n") for _, summary := range tool.IntentBundleSummary() { b.WriteString(" " + summary + "\n") } diff --git a/cmd/dx.go b/cmd/dx.go index ea87949a..fb890042 100644 --- a/cmd/dx.go +++ b/cmd/dx.go @@ -144,6 +144,149 @@ func doctorOutput(settings graycodeconfig.Settings) string { return strings.TrimRight(b.String(), "\n") } +// doctorJSON returns the doctor diagnostics as indented JSON, mirroring the +// fields of doctorOutput but as machine-parseable structured data. +func doctorJSON(settings graycodeconfig.Settings) string { + type sessionDirInfo struct { + Path string `json:"path"` + Status string `json:"status"` + Writable bool `json:"writable"` + Files int `json:"files"` + } + type gitInfo struct { + Repository bool `json:"repository"` + Branch string `json:"branch,omitempty"` + Head string `json:"head,omitempty"` + Clean bool `json:"clean"` + Modified int `json:"modified"` + } + + effectiveProvider := strings.TrimSpace(settings.Provider) + if effectiveProvider == "" { + effectiveProvider = "(not configured)" + } + effectiveModel := strings.TrimSpace(graycodeconfig.ActiveModel(context.Background())) + if effectiveModel == "" { + effectiveModel = "(not configured)" + } + + shell := os.Getenv("SHELL") + if shell == "" { + shell = "(not set)" + } + termVal := os.Getenv("TERM") + if termVal == "" { + termVal = "(not set)" + } + colorTerm := os.Getenv("COLORTERM") + if colorTerm == "" { + colorTerm = "(not set)" + } + + v := version + if v == "" { + v = "(dev)" + } + + var sessDir *sessionDirInfo + if dir := storage.SessionsDir(); dir != "" { + info := &sessionDirInfo{Path: dir} + if st, err := os.Stat(dir); err != nil { + info.Status = "missing" + } else if !st.IsDir() { + info.Status = "not a directory" + } else { + testFile := filepath.Join(dir, ".dx_write_test") + // #nosec G304 -- testFile built from internal sessions directory path + writable := true + if f, err := os.Create(testFile); err != nil { + writable = false + } else { + _ = f.Close() + _ = os.Remove(testFile) + } + entries, _ := os.ReadDir(dir) + info.Status = "exists" + info.Writable = writable + info.Files = len(entries) + } + sessDir = info + } + + mcpCount := len(settings.MCPServers) + len(mcpServers) + plugins := 0 + if manifests, err := plugin.List(); err == nil { + plugins = len(manifests) + } + + agentsMD := graycodeconfig.LoadAgentsMD() + agentsState := "not found" + if agentsMD != "" { + agentsState = "found" + } + + var git *gitInfo + if branch, err := gitOutput("rev-parse", "--abbrev-ref", "HEAD"); err == nil && branch != "" { + g := &gitInfo{Repository: true, Branch: branch} + if head, err := gitOutput("rev-parse", "--short", "HEAD"); err == nil { + g.Head = head + } + if status, err := gitOutput("status", "--short"); err == nil { + if status == "" { + g.Clean = true + } else { + g.Modified = len(strings.Split(status, "\n")) + } + } + git = g + } + + buildDate := buildDate + if buildDate == "unknown" { + buildDate = "" + } + + d := struct { + GoVersion string `json:"go_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Shell string `json:"shell"` + Term string `json:"term"` + ColorTerm string `json:"colorterm"` + Version string `json:"version"` + BuildDate string `json:"build_date,omitempty"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` + Model string `json:"model"` + SessionDir *sessionDirInfo `json:"session_directory,omitempty"` + MCPServers int `json:"mcp_servers"` + Plugins int `json:"plugins"` + AgentsMD string `json:"agents_md"` + Git *gitInfo `json:"git,omitempty"` + Disk string `json:"disk"` + }{ + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Shell: shell, + Term: termVal, + ColorTerm: colorTerm, + Version: v, + BuildDate: buildDate, + Provider: effectiveProvider, + APIKey: maskedKeyStatus(graycodeconfig.ActiveProvider(context.Background())), + Model: effectiveModel, + SessionDir: sessDir, + MCPServers: mcpCount, + Plugins: plugins, + AgentsMD: agentsState, + Git: git, + Disk: diskSpaceInfo(), + } + out, _ := json.MarshalIndent(d, "", " ") + return string(out) +} + // maskedKeyStatus returns the API key status for a provider, masking the actual key. func maskedKeyStatus(provider string) string { provider = strings.TrimSpace(provider) diff --git a/cmd/dx_test.go b/cmd/dx_test.go index 28ef4f14..5c9a07f8 100644 --- a/cmd/dx_test.go +++ b/cmd/dx_test.go @@ -64,6 +64,40 @@ func TestDoctorOutputWithMCPServers(t *testing.T) { } } +func TestDoctorJSONIsValidStructuredOutput(t *testing.T) { + preserveCLICompilerVersionState(t) + version = "test-dx-version" + settings := graycodeconfig.Settings{ + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + MCPServers: []graycodeconfig.MCPServerConfig{ + {Name: "test-mcp", Command: "test-cmd"}, + }, + } + + var d struct { + Version string `json:"version"` + Provider string `json:"provider"` + MCPServers int `json:"mcp_servers"` + GoVersion string `json:"go_version"` + } + if err := json.Unmarshal([]byte(doctorJSON(settings)), &d); err != nil { + t.Fatalf("doctorJSON produced invalid JSON: %v", err) + } + if d.Version != "test-dx-version" { + t.Errorf("expected version %q, got %q", "test-dx-version", d.Version) + } + if d.Provider != "anthropic" { + t.Errorf("expected provider %q, got %q", "anthropic", d.Provider) + } + if d.MCPServers != 1 { + t.Errorf("expected mcp_servers 1, got %d", d.MCPServers) + } + if d.GoVersion == "" { + t.Error("expected go_version to be populated") + } +} + func TestDebugOutputHasMemoryStats(t *testing.T) { sess := engine.NewSession("openai", "gpt-4o", "test system", tool.NewRegistry()) sess.AddUser("hello") diff --git a/cmd/eval.go b/cmd/eval.go index 61d0bde7..c0add1e7 100644 --- a/cmd/eval.go +++ b/cmd/eval.go @@ -61,7 +61,7 @@ var evalCacheCmd = &cobra.Command{ if err := cache.Clear(); err != nil { return err } - fmt.Println("Cache cleared.") + fmt.Println(auditTint("Cache cleared.", doneGreen)) return nil }, } @@ -121,10 +121,19 @@ func runEvalLoop(cmd *cobra.Command, _ []string) error { cfg := evalloop.DefaultConfig() runtime := evalloop.NewSessionRuntime(gw.ChatClient(), "eval", model, tool.NewRegistry(), cfg) + + // The agent loop is the slow part; show a TTY-only animated indicator. + // It clears before the JSON report prints, so piped and structured + // output stay pure. + prog := NewCLIProgress("Eval", []string{"Running agent loop"}) + defer prog.Abort() + prog.StartStep(0) result, err := runtime.Run(ctx, workDir, evalLoopPrompt) if err != nil { return fmt.Errorf("eval loop: %w", err) } + prog.CompleteStep(0) + prog.Done() transcriptPath := "" if len(result.Transcript) > 0 { @@ -214,7 +223,7 @@ func runEval(_ *cobra.Command, _ []string) error { modelName = "default" } - fmt.Printf("Running %d tasks with model %s...\n", len(tasks), modelName) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Running %d tasks with model %s...", len(tasks), modelName), textPrimary)) suite := &eval.BenchmarkSuite{Name: "graycode-eval", Tasks: tasks} runner := eval.NewRunner(modelName, "") @@ -223,6 +232,26 @@ func runEval(_ *cobra.Command, _ []string) error { runner.Cache = eval.DefaultCache() } runner.Filters = []eval.Filter{eval.ExtractCodeBlock("go")} + + // Animate one step per benchmark task. The eval runner invokes the + // callback before each task, so we close the previous step and open the + // next. Quiet mode suppresses the animation entirely. + var prog *CLIProgress + if !IsQuiet() { + names := make([]string, len(tasks)) + for i := range tasks { + names[i] = tasks[i].ID + } + prog = NewCLIProgress("Eval", names) + defer prog.Abort() + runner.Progress = func(i, _ int, _ string) { + if i > 0 { + prog.CompleteStep(i - 1) + } + prog.StartStep(i) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() @@ -230,6 +259,10 @@ func runEval(_ *cobra.Command, _ []string) error { if err != nil { return err } + if prog != nil { + prog.CompleteStep(len(tasks) - 1) + prog.Done() + } // Compute reproducibility hash hash := eval.ComputeHash(tasks) @@ -238,9 +271,9 @@ func runEval(_ *cobra.Command, _ []string) error { store := eval.DefaultResultStore() path, err := store.Save(result, model, "", hash) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to save results: %v\n", err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("Warning: failed to save results: %v", err), warnAmber)) } else { - fmt.Printf("Results saved to: %s\n", path) + fmt.Printf("%s\n", auditTint("Results saved to: ", doneGreen)+auditTint(path, textPrimary)) } // Group results @@ -287,7 +320,28 @@ func runEvalList(_ *cobra.Command, _ []string) error { } if evalListJSON { - out, err := json.MarshalIndent(tasks, "", " ") + // BenchmarkTask carries func fields (SetupFn/ValidateFn) that + // encoding/json cannot marshal; project to the display-safe fields. + type jsonTask struct { + ID string `json:"id"` + Description string `json:"description"` + Prompt string `json:"prompt"` + TimeLimit float64 `json:"time_limit_seconds"` + Tags []string `json:"tags"` + MaxAttempts int `json:"max_attempts"` + } + view := make([]jsonTask, len(tasks)) + for i, t := range tasks { + view[i] = jsonTask{ + ID: t.ID, + Description: t.Description, + Prompt: t.Prompt, + TimeLimit: t.TimeLimit.Seconds(), + Tags: t.Tags, + MaxAttempts: t.MaxAttempts, + } + } + out, err := json.MarshalIndent(view, "", " ") if err != nil { return fmt.Errorf("marshaling tasks: %w", err) } @@ -295,7 +349,7 @@ func runEvalList(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Available tasks (%d):\n\n", len(tasks)) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Available tasks (%d):", len(tasks)), textPrimary)) fmt.Println("| ID | Description | Tags |") fmt.Println("|----|-------------|------|") for _, t := range tasks { @@ -313,7 +367,7 @@ func runEvalResults(_ *cobra.Command, _ []string) error { return err } if len(files) == 0 { - fmt.Println("No saved results found.") + fmt.Println(auditTint("No saved results found.", textMuted)) return nil } @@ -334,16 +388,17 @@ func runEvalResults(_ *cobra.Command, _ []string) error { return nil } - fmt.Printf("Saved results (%d):\n\n", len(files)) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Saved results (%d):", len(files)), textPrimary)) for _, f := range files { r, err := store.Load(f) if err != nil { continue } - fmt.Printf(" %s %s %s %.0f%% (%d/%d)\n", - r.Timestamp.Format("2006-01-02 15:04"), - r.Model, r.Suite, - r.Summary.PassRate*100, r.Summary.Passed, r.Summary.TotalTasks) + fmt.Printf(" %s %s %s %s\n", + auditTint(r.Timestamp.Format("2006-01-02 15:04"), textMuted), + auditTint(r.Model, textPrimary), + auditTint(r.Suite, textPrimary), + auditTint(fmt.Sprintf("%.0f%% (%d/%d)", r.Summary.PassRate*100, r.Summary.Passed, r.Summary.TotalTasks), doneGreen)) } return nil } diff --git a/cmd/eval_tools.go b/cmd/eval_tools.go index 489ae143..5e1d2c68 100644 --- a/cmd/eval_tools.go +++ b/cmd/eval_tools.go @@ -106,7 +106,7 @@ func runEvalTools(cmd *cobra.Command, _ []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) defer cancel() - cmd.Printf("Evaluating tool selection on %d cases with model %s...\n", len(defaultToolUseCases()), model) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Evaluating tool selection on %d cases with model %s...", len(defaultToolUseCases()), model), textPrimary)) report := eval.ScoreToolUse(ctx, defaultToolUseCases(), caller) switch evalToolsOutput { diff --git a/cmd/exec.go b/cmd/exec.go index 665a55b5..d22bccf3 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -253,9 +253,7 @@ func runExec(_ *cobra.Command, args []string) error { if ghaCtx.Active && !ghaCtx.Trusted { const ceiling = engine.AutonomyBasic if sess.PermSvc().Autonomy() > ceiling { - fmt.Fprintf(os.Stderr, - "graycode: untrusted GitHub event (author_association=%q); capping autonomy at %s\n", - ghaCtx.AuthorAssociation, ceiling) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("graycode: untrusted GitHub event (author_association=%q); capping autonomy at %s", ghaCtx.AuthorAssociation, ceiling), warnAmber)) sess.PermSvc().SetAutonomy(ceiling) } } @@ -322,9 +320,9 @@ func runExec(_ *cobra.Command, args []string) error { case "error": execErr = ev.Content if execOutputFormat == "text" { - _, _ = fmt.Fprintf(os.Stderr, "\nerror: %s\n", ev.Content) + _, _ = fmt.Fprintf(os.Stderr, "\n%s\n", auditTint("error: "+ev.Content, errorCoral)) if h := errhint.CLIHint(errors.New(ev.Content)); h != "" { - _, _ = fmt.Fprintf(os.Stderr, " hint: %s\n", h) + _, _ = fmt.Fprintf(os.Stderr, "%s\n", auditTint(" hint: "+h, textMuted)) } } if execOutputFormat == "stream-json" { @@ -390,6 +388,13 @@ func runExec(_ *cobra.Command, args []string) error { if !strings.HasSuffix(response.String(), "\n") { fmt.Println() } + if !IsQuiet() { + fmt.Fprintf(os.Stderr, "%s\n", auditTint( + fmt.Sprintf("graycode: %d tokens in / %d out · %d turn(s) · %s · %s", + totalIn, totalOut, turns, time.Since(start).Round(time.Millisecond), effectiveModel), + textMuted, + )) + } if exitCode != 0 { return fmt.Errorf("exec failed: %s", execErr) } @@ -681,7 +686,7 @@ func persistExecSession(id, model, provider, userMsg, assistantMsg string) { }, } if err := session.Save(s); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to persist exec session %s: %v\n", id, err) + fmt.Fprintf(os.Stderr, "%s\n", auditTint(fmt.Sprintf("warning: failed to persist exec session %s: %v", id, err), warnAmber)) } } @@ -753,7 +758,7 @@ func runExecFanout(prompt string, n int) error { attempts := make([]fanoutAttempt, 0, n) anyOK := false for i := 1; i <= n; i++ { - fmt.Fprintf(os.Stderr, "\n=== fanout attempt %d/%d ===\n", i, n) + fmt.Fprintf(os.Stderr, "\n%s\n", auditTint(fmt.Sprintf("=== fanout attempt %d/%d ===", i, n), infoSky)) att := fanoutAttempt{Attempt: i} branch := fmt.Sprintf("graycode-exec/%d-fanout%d-%s", start.UnixMilli(), i, randomHex(4)) @@ -860,13 +865,13 @@ func fanoutSummaryLines(attempts []fanoutAttempt) string { } func printFanoutReport(attempts []fanoutAttempt) { - fmt.Fprintln(os.Stderr, "\n=== fan-out comparison (worktrees kept for inspection) ===") + fmt.Fprintln(os.Stderr, auditTint("\n=== fan-out comparison (worktrees kept for inspection) ===", infoSky)) for _, a := range attempts { - status := icons.Check() + " ok" + status := auditTint(icons.Check()+" ok", doneGreen) if !a.OK { - status = icons.Close() + " failed" + status = auditTint(icons.Close()+" failed", errorCoral) if a.Error != "" { - status += " — " + a.Error + status += auditTint(" — "+a.Error, errorCoral) } } fmt.Fprintf(os.Stderr, "\n#%d %s\n branch: %s\n worktree: %s\n tokens: in=%d out=%d turns=%d\n duration: %s\n", diff --git a/cmd/features.go b/cmd/features.go index aaf94f8b..c3170023 100644 --- a/cmd/features.go +++ b/cmd/features.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "image/color" "sort" "strings" @@ -33,12 +34,12 @@ Show a specific flag: if !ok { return fmt.Errorf("unknown feature flag: %s", args[1]) } - fmt.Printf("Name: %s\n", f.Name()) - fmt.Printf("Default: %v\n", f.DefaultValue()) - fmt.Printf("Current: %v\n", feature.EnabledByName(args[1])) - fmt.Printf("Description: %s\n", f.Description()) + fmt.Printf("%s\n", auditTint("Name: ", textMuted)+auditTint(f.Name(), textPrimary)) + fmt.Printf("%s\n", auditTint("Default: ", textMuted)+auditTint(fmt.Sprintf("%v", f.DefaultValue()), textPrimary)) + fmt.Printf("%s\n", auditTint("Current: ", textMuted)+auditTint(fmt.Sprintf("%v", feature.EnabledByName(args[1])), textPrimary)) + fmt.Printf("%s\n", auditTint("Description: ", textMuted)+auditTint(f.Description(), textPrimary)) envVar := "GRAYCODE_FEATURE_" + strings.ReplaceAll(strings.ToUpper(args[1]), "-", "_") - fmt.Printf("Env var: %s\n", envVar) + fmt.Printf("%s\n", auditTint("Env var: ", textMuted)+auditTint(envVar, textPrimary)) return nil } @@ -49,21 +50,23 @@ Show a specific flag: } sort.Strings(names) - fmt.Println("Feature Flags:") + fmt.Println(auditTint("Feature Flags:", graycodeColor)) fmt.Println() for _, name := range names { f, _ := feature.Info(name) val := flags[name] status := "DISABLED" + var statusColor color.Color = textMuted if val { status = "ENABLED" + statusColor = doneGreen } - fmt.Printf(" %s = %v [%s]\n", name, val, status) + fmt.Printf(" %s = %v [%s]\n", auditTint(name, textPrimary), val, auditTint(status, statusColor)) if f != nil { - fmt.Printf(" default: %v\n", f.DefaultValue()) - fmt.Printf(" description: %s\n", f.Description()) + fmt.Printf("%s\n", auditTint(fmt.Sprintf(" default: %v", f.DefaultValue()), textMuted)) + fmt.Printf("%s\n", auditTint(" description: "+f.Description(), textMuted)) envVar := "GRAYCODE_FEATURE_" + strings.ReplaceAll(strings.ToUpper(name), "-", "_") - fmt.Printf(" env: %s\n", envVar) + fmt.Printf("%s\n", auditTint(" env: "+envVar, textMuted)) } fmt.Println() } diff --git a/cmd/feedback.go b/cmd/feedback.go index 93769876..3b61506c 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -62,7 +62,7 @@ func init() { func runFeedback(_ *cobra.Command, args []string) error { body := strings.Join(args, " ") if body == "" { - fmt.Println("Enter your feedback (press Ctrl+D when done):") + fmt.Println(auditTint("Enter your feedback (press Ctrl+D when done):", textPrimary)) data, err := readFeedbackStdin() if err != nil { return err @@ -118,7 +118,7 @@ func saveFeedbackLocal(report FeedbackReport) error { return fmt.Errorf("write feedback: %w", err) } - fmt.Printf("Feedback saved to %s\n", path) + fmt.Println(auditTint("Feedback saved to ", doneGreen) + auditTint(path, textMuted)) return nil } @@ -149,11 +149,11 @@ func openFeedbackIssue(report FeedbackReport) error { if err := openBrowser(issueURL); err != nil { // Fallback: print the URL. - fmt.Printf("Could not open browser. Please visit:\n%s\n", issueURL) + fmt.Printf("%s\n%s\n", auditTint("Could not open browser. Please visit:", textMuted), auditTint(issueURL, infoSky)) return nil } - fmt.Println("Opened feedback issue in your browser.") + fmt.Println(auditTint("Opened feedback issue in your browser.", doneGreen)) return nil } diff --git a/cmd/governance_cmd.go b/cmd/governance_cmd.go index 810fd7b8..a51f33ab 100644 --- a/cmd/governance_cmd.go +++ b/cmd/governance_cmd.go @@ -41,22 +41,22 @@ var governanceShowCmd = &cobra.Command{ if path == "" { path = governance.ManagedPolicyPath() } - cmd.Printf("Governance layer %q (%s)\n", layer.Name, path) - cmd.Printf("Fail-closed: %t\n", layer.FailClosed) + cmd.Printf("%s %s\n", auditTint("Governance layer", textMuted), auditTint(fmt.Sprintf("%q (%s)", layer.Name, path), textPrimary)) + cmd.Printf("%s %t\n", auditTint("Fail-closed:", textMuted), layer.FailClosed) if len(layer.DeniedTools) > 0 { - cmd.Printf("Denied tools: %s\n", sortedKeys(layer.DeniedTools)) + cmd.Printf("%s %s\n", auditTint("Denied tools:", textMuted), auditTint(sortedKeys(layer.DeniedTools), textPrimary)) } if len(layer.DeniedBash) > 0 { - cmd.Printf("Denied bash patterns: %s\n", strings.Join(layer.DeniedBash, ", ")) + cmd.Printf("%s %s\n", auditTint("Denied bash patterns:", textMuted), auditTint(strings.Join(layer.DeniedBash, ", "), textPrimary)) } if len(layer.SensitivePaths) > 0 { - cmd.Printf("Sensitive paths: %s\n", strings.Join(layer.SensitivePaths, ", ")) + cmd.Printf("%s %s\n", auditTint("Sensitive paths:", textMuted), auditTint(strings.Join(layer.SensitivePaths, ", "), textPrimary)) } if len(layer.Capabilities) == 0 { - cmd.Println("No capability rows.") + cmd.Println(auditTint("No capability rows.", textMuted)) return nil } - cmd.Println("\nCapabilities:") + cmd.Println(auditTint("\nCapabilities:", infoSky)) for _, cap := range layer.Capabilities { pattern := cap.Pattern if pattern == "" { @@ -66,7 +66,15 @@ var governanceShowCmd = &cobra.Command{ if cap.Reason != "" { reason = " (" + cap.Reason + ")" } - cmd.Printf(" %-8s %-20s %-12s %s\n", cap.Action, cap.Scope, pattern, reason) + actionColor := doneGreen + if cap.Action == governance.ActionDeny { + actionColor = errorCoral + } + cmd.Printf(" %s %-20s %-12s %s\n", + auditTint(fmt.Sprintf("%-8s", cap.Action), actionColor), + auditTint(string(cap.Scope), textPrimary), + auditTint(pattern, textMuted), + auditTint(reason, textMuted)) } return nil }, @@ -81,8 +89,9 @@ var governanceValidateCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("valid: %d capability row(s), fail_closed=%t (%s)\n", - len(layer.Capabilities), layer.FailClosed, args[0]) + cmd.Printf("%s %d capability row(s), fail_closed=%t (%s)\n", + auditTint("valid:", doneGreen), + len(layer.Capabilities), layer.FailClosed, auditTint(args[0], textMuted)) return nil }, } @@ -109,24 +118,26 @@ var governanceExplainCmd = &cobra.Command{ scoped = strings.Join(scopeNames(scopes), ", ") } verdict := "DENY" + verdictColor := errorCoral if dec.Allowed { verdict = "ALLOW" + verdictColor = doneGreen } - cmd.Printf("tool: %s\n", toolName) - cmd.Printf("scopes: %s\n", scoped) + cmd.Printf("%s %s\n", auditTint("tool:", textMuted), auditTint(toolName, textPrimary)) + cmd.Printf("%s %s\n", auditTint("scopes:", textMuted), auditTint(scoped, textPrimary)) if summary != "" { - cmd.Printf("summary: %s\n", summary) + cmd.Printf("%s %s\n", auditTint("summary:", textMuted), auditTint(summary, textPrimary)) } - cmd.Printf("decision: %s\n", verdict) - cmd.Printf("source: %s\n", dec.Source) + cmd.Printf("%s %s\n", auditTint("decision:", textMuted), auditTint(verdict, verdictColor)) + cmd.Printf("%s %s\n", auditTint("source:", textMuted), auditTint(dec.Source, textPrimary)) if dec.Scope != "" { - cmd.Printf("scope hit: %s\n", dec.Scope) + cmd.Printf("%s %s\n", auditTint("scope hit:", textMuted), auditTint(string(dec.Scope), textPrimary)) } if dec.Rule != "" { - cmd.Printf("rule: %s\n", dec.Rule) + cmd.Printf("%s %s\n", auditTint("rule:", textMuted), auditTint(dec.Rule, textPrimary)) } if dec.Reason != "" { - cmd.Printf("reason: %s\n", dec.Reason) + cmd.Printf("%s %s\n", auditTint("reason:", textMuted), auditTint(dec.Reason, textPrimary)) } return nil }, @@ -143,17 +154,16 @@ func init() { func runGovernanceStatus(cmd *cobra.Command) error { path := governance.ManagedPolicyPath() - cmd.Printf("Managed policy path: %s\n", path) + cmd.Printf("%s\n", auditTint("Managed policy path: ", textMuted)+auditTint(path, textPrimary)) if _, err := os.Stat(path); err != nil { - cmd.Println("Status: not installed (governance is fail-open; no ceiling enforced)") + cmd.Println(auditTint("Status: not installed (governance is fail-open; no ceiling enforced)", warnAmber)) return nil } layer, err := governance.LoadLayer("policy", path) if err != nil { return fmt.Errorf("managed policy is invalid: %w", err) } - cmd.Printf("Status: installed — fail_closed=%t, %d capability row(s), %d denied tool(s)\n", - layer.FailClosed, len(layer.Capabilities), len(layer.DeniedTools)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Status: installed — fail_closed=%t, %d capability row(s), %d denied tool(s)", layer.FailClosed, len(layer.Capabilities), len(layer.DeniedTools)), doneGreen)) return nil } diff --git a/cmd/graycode/main.go b/cmd/graycode/main.go index 45e6e2cf..9e2883a6 100644 --- a/cmd/graycode/main.go +++ b/cmd/graycode/main.go @@ -7,6 +7,8 @@ import ( "os" "time" + "golang.org/x/term" + "github.com/GrayCodeAI/graycode-cli/cmd" "github.com/GrayCodeAI/graycode-cli/internal/crash" "github.com/GrayCodeAI/graycode-cli/internal/graycodeerr" @@ -82,7 +84,7 @@ func main() { mcp.SetClientVersion(Version) if err := cmd.RunWithPanicRecovery(cmd.Execute); err != nil { - fmt.Fprintln(os.Stderr, err) + printError(err) // An explicit ExitCodeError (e.g. a wrapped Bash exit status) wins — // it already carries the intended code. Otherwise classify the failure // into the stable exit-code taxonomy so callers can branch on the @@ -94,3 +96,30 @@ func main() { os.Exit(graycodeerr.ClassifyExitCode(err)) } } + +// errorCoralRGB is the brand error color (#FF6B6B) as an SGR truecolor +// sequence, applied only when stderr is a color-capable terminal so scripts +// piping diagnostics never see raw ANSI. +const errorCoralRGB = "\x1b[38;2;255;107;107m" + +// printError writes a top-level failure to stderr, colorized (error coral) +// when the terminal supports it and NO_COLOR is unset. +func printError(err error) { + msg := err.Error() + if shouldColorErr() { + msg = errorCoralRGB + msg + "\x1b[m" + } + fmt.Fprintln(os.Stderr, msg) +} + +// shouldColorErr mirrors cmd.ShouldColor's detection for stderr: NO_COLOR +// wins, then FORCE_COLOR, else the terminal's color support. +func shouldColorErr() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if os.Getenv("FORCE_COLOR") != "" { + return true + } + return term.IsTerminal(int(os.Stderr.Fd())) +} diff --git a/cmd/harness.go b/cmd/harness.go index 22dd43c0..8f94e7a9 100644 --- a/cmd/harness.go +++ b/cmd/harness.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "os" "path/filepath" @@ -34,28 +35,62 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories return fmt.Errorf("failed to get working directory: %w", err) } + // Live progress over the slow evaluation and report-writing stages. + // TTY-aware: animates on a terminal, prints clean static lines when + // piped. Harness writes reports to files (not stdout), so progress + // never corrupts structured output. --fix inserts a "Repairing + // harness" step between evaluation and the report writes. + fixing := harnessFix || (len(args) > 0 && args[0] == "fix") + steps := []string{"Evaluating workspace", "Writing markdown", "Writing HTML", "Writing JSON"} + reportBase := 1 + if fixing { + steps = []string{"Evaluating workspace", "Repairing harness", "Writing markdown", "Writing HTML", "Writing JSON"} + reportBase = 2 + } + prog := NewCLIProgress("Harness", steps) + defer prog.Abort() + step := func(i int) { + if prog != nil { + prog.StartStep(i) + } + } + done := func(i int) { + if prog != nil { + prog.CompleteStep(i) + } + } + finish := func() { + if prog != nil { + prog.Done() + } + } + ctx := context.Background() opts := harness.EvaluateOptions{ TargetPath: targetDir, OutputDir: harnessOutDir, } + step(0) report, err := harness.EvaluateWorkspace(ctx, targetDir, opts) if err != nil { return fmt.Errorf("harness evaluation failed: %w", err) } + done(0) - if harnessFix || (len(args) > 0 && args[0] == "fix") { + if fixing { + step(1) fixResult, fixErr := harness.FixWorkspaceHarness(ctx, targetDir, report) if fixErr != nil { return fmt.Errorf("harness auto-fix failed: %w", fixErr) } - fmt.Printf("[FIX] Graycode Harness Auto-Repair Results:\n") + fmt.Printf("%s\n", auditTint("[FIX] Graycode Harness Auto-Repair Results:", warnAmber)) for _, repair := range fixResult.RepairsPerformed { - fmt.Printf(" + %s\n", repair) + fmt.Printf("%s\n", auditTint(" + "+repair, doneGreen)) } // Re-evaluate workspace after fix report, _ = harness.EvaluateWorkspace(ctx, targetDir, opts) + done(1) } outDir := harnessOutDir @@ -68,25 +103,31 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories } // Write Markdown report + step(reportBase) mdPath := filepath.Join(outDir, "report.md") mdContent := harness.RenderMarkdown(report) if writeErr := os.WriteFile(mdPath, []byte(mdContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.md: %w", writeErr) } + done(reportBase) // Write HTML report + step(reportBase + 1) htmlPath := filepath.Join(outDir, "report.html") htmlContent := harness.RenderHTML(report) if writeErr := os.WriteFile(htmlPath, []byte(htmlContent), 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write report.html: %w", writeErr) } + done(reportBase + 1) // Write JSON report + step(reportBase + 2) jsonPath := filepath.Join(outDir, "findings.json") jsonContent, renderErr := harness.RenderJSON(report) if renderErr != nil { return fmt.Errorf("failed to serialize findings.json: %w", renderErr) } + done(reportBase + 2) if writeErr := os.WriteFile(jsonPath, jsonContent, 0o640); writeErr != nil { // #nosec G306 -- report is intentionally group-readable return fmt.Errorf("failed to write findings.json: %w", writeErr) } @@ -94,12 +135,16 @@ Use --fix to automatically repair missing AGENTS.md, skills, or spec directories // Journal quality observation to Graycode execution graph _ = harness.JournalHarnessReport(report, "") - fmt.Printf("[GRAYCODE] Graycode Harness Evaluation Complete\n") - fmt.Printf(" Overall Score : %d/100 (%s)\n", report.OverallScore, report.OverallStatus) - fmt.Printf(" Findings : %d prioritized issues\n", len(report.Findings)) - fmt.Printf(" HTML Report : %s\n", htmlPath) - fmt.Printf(" Markdown : %s\n", mdPath) - fmt.Printf(" JSON Findings : %s\n", jsonPath) + finish() + fmt.Printf("%s\n", auditTint("[GRAYCODE] Graycode Harness Evaluation Complete", graycodeColor)) + fmt.Printf(" %s : %s (%s)\n", + auditTint("Overall Score", textPrimary), + auditTint(fmt.Sprintf("%d/100", report.OverallScore), textPrimary), + auditTint(report.OverallStatus, harnessStatusColor(report.OverallStatus))) + fmt.Printf(" %s : %d prioritized issues\n", auditTint("Findings", textPrimary), len(report.Findings)) + fmt.Printf(" %s : %s\n", auditTint("HTML Report", textPrimary), htmlPath) + fmt.Printf(" %s : %s\n", auditTint("Markdown", textPrimary), mdPath) + fmt.Printf(" %s : %s\n", auditTint("JSON Findings", textPrimary), jsonPath) return nil }, @@ -110,3 +155,17 @@ func init() { harnessCmd.Flags().StringVar(&harnessFormat, "format", "all", "Report output format (html, markdown, json, all)") harnessCmd.Flags().BoolVar(&harnessFix, "fix", false, "Automatically repair missing harness assets (AGENTS.md, skills, specs)") } + +// harnessStatusColor maps the harness health status to a semantic theme color. +func harnessStatusColor(status string) color.Color { + switch status { + case "EXCELLENT", "GOOD": + return doneGreen + case "NEEDS_IMPROVEMENT": + return warnAmber + case "POOR": + return errorCoral + default: + return textPrimary + } +} diff --git a/cmd/help_template.go b/cmd/help_template.go new file mode 100644 index 00000000..f51b8169 --- /dev/null +++ b/cmd/help_template.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// Modern, theme-aware help output. Section headers render in the brand gold, +// command names in textPrimary, command descriptions in muted. Flags stay +// plain. Pad-then-colorize keeps the name column aligned; descriptions are the +// last column so coloring them (zero-width ANSI) cannot break alignment. All +// color honors ShouldColor() (NO_COLOR, --quiet, non-TTY) via auditTint. +func init() { + cobra.AddTemplateFunc("gcHeader", func(s string) string { return auditTint(s, graycodeColor) }) + cobra.AddTemplateFunc("gcCmd", func(s string) string { return auditTint(s, textPrimary) }) + cobra.AddTemplateFunc("gcDesc", func(s string) string { return auditTint(s, textMuted) }) + rootCmd.SetUsageTemplate(modernUsageTemplate) +} + +const modernUsageTemplate = `{{gcHeader "Usage:"}}{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +{{gcHeader "Aliases:"}} + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +{{gcHeader "Examples:"}} +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +{{gcHeader "Available Commands:"}}{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{gcHeader .Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +{{gcHeader "Additional Commands:"}}{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{gcCmd (rpad .Name .NamePadding)}} {{gcDesc .Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +{{gcHeader "Flags:"}} +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +{{gcHeader "Global Flags:"}} +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +{{gcHeader "Additional help topics:"}}{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` diff --git a/cmd/issue.go b/cmd/issue.go index c226377e..8e54fde2 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "github.com/spf13/cobra" ) @@ -73,13 +74,13 @@ func runIssue(cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(cmd.OutOrStdout()) return nil } - cmd.Println("Issue preview (dry run — not published)") - cmd.Println("Title: " + title) + cmd.Println(auditTint("Issue preview (dry run — not published)", warnAmber)) + cmd.Println(auditTint("Title: ", textMuted) + auditTint(title, textPrimary)) cmd.Println() cmd.Print(body) if len(issueLabels) > 0 { cmd.Println() - cmd.Println("Labels: " + strings.Join(issueLabels, ", ")) + cmd.Println(auditTint("Labels: ", textMuted) + auditTint(strings.Join(issueLabels, ", "), textPrimary)) } return nil } @@ -96,13 +97,18 @@ func runIssue(cmd *cobra.Command, args []string) error { ghArgs = append(ghArgs, "--label", l) } - cc := exec.CommandContext(context.Background(), "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable + gctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cc := exec.CommandContext(gctx, "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable cc.Stderr = os.Stderr out, err := cc.Output() if err != nil { + if gctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("gh issue create timed out after 60s") + } return fmt.Errorf("gh issue create failed: %w", err) } - cmd.Println("Issue created: " + strings.TrimSpace(string(out))) + cmd.Println(auditTint("Issue created: ", doneGreen) + auditTint(strings.TrimSpace(string(out)), textPrimary)) return nil } diff --git a/cmd/learn_cmd.go b/cmd/learn_cmd.go index cc263323..fd19eaa1 100644 --- a/cmd/learn_cmd.go +++ b/cmd/learn_cmd.go @@ -2,10 +2,12 @@ package cmd import ( "fmt" + "strconv" "strings" "time" "github.com/GrayCodeAI/graycode-cli/internal/engine" + "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" "github.com/spf13/cobra" ) @@ -46,7 +48,7 @@ var learnAddCmd = &cobra.Command{ } si := engine.NewSelfImprover() si.Learn(strings.TrimSpace(learnWhat), strings.TrimSpace(learnWhy), strings.TrimSpace(learnLesson), strings.TrimSpace(learnCategory)) - cmd.Printf("lesson added (category: %s)\n", learnCategory) + cmd.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("lesson added (category: "+learnCategory+")", textPrimary)) return nil }, } @@ -68,11 +70,11 @@ var learnClearCmd = &cobra.Command{ si := engine.NewSelfImprover() n := len(si.Lessons("")) if n == 0 { - cmd.Println("no lessons to clear") + cmd.Println(auditTint("no lessons to clear", textMuted)) return nil } si.Clear() - cmd.Printf("cleared %d lesson(s)\n", n) + cmd.Println(auditTint("cleared "+strconv.Itoa(n)+" lesson(s)", textPrimary)) return nil }, } @@ -94,7 +96,7 @@ func runLearnList(cmd *cobra.Command) error { si := engine.NewSelfImprover() lessons := si.Lessons("") if len(lessons) == 0 { - cmd.Println("No lessons yet. Add one with: graycode learn add --what ... --lesson ...") + cmd.Println(auditTint("No lessons yet. Add one with: graycode learn add --what ... --lesson ...", textMuted)) return nil } @@ -107,7 +109,7 @@ func runLearnList(cmd *cobra.Command) error { for cat, count := range cats { catSummary = append(catSummary, fmt.Sprintf("%s (%d)", cat, count)) } - cmd.Printf("Lesson store: %d lesson(s) — %s\n", len(lessons), strings.Join(catSummary, ", ")) + cmd.Println(auditTint("Lesson store: "+strconv.Itoa(len(lessons))+" lesson(s) — "+strings.Join(catSummary, ", "), textPrimary)) start := 0 if learnLimit > 0 && len(lessons) > learnLimit { @@ -115,12 +117,12 @@ func runLearnList(cmd *cobra.Command) error { } cmd.Println() for _, e := range lessons[start:] { - cmd.Printf("[%s] %s\n", e.Category, e.What) - cmd.Printf(" lesson: %s\n", e.Lesson) + cmd.Printf("%s %s\n", auditTint("["+e.Category+"]", toolGold), auditTint(e.What, textPrimary)) + cmd.Printf("%s %s\n", auditTint(" lesson:", textMuted), auditTint(e.Lesson, textPrimary)) if learnAll && e.Why != "" { - cmd.Printf(" why: %s\n", e.Why) + cmd.Printf("%s %s\n", auditTint(" why:", textMuted), auditTint(e.Why, textPrimary)) } - cmd.Printf(" learned: %s\n", e.Timestamp.Format(time.RFC3339)) + cmd.Printf("%s %s\n", auditTint(" learned:", textMuted), auditTint(e.Timestamp.Format(time.RFC3339), textMuted)) } return nil } diff --git a/cmd/mission.go b/cmd/mission.go index d3e9ec5d..f66a24c0 100644 --- a/cmd/mission.go +++ b/cmd/mission.go @@ -87,7 +87,7 @@ func runMission(_ *cobra.Command, args []string) error { var waves [][]string if missionFromTasks { - fmt.Printf("Mission %s: loading validated task graph...\n", m.ID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: loading validated task graph...", m.ID), textPrimary)) features, taskWaves, err := missionFeaturesFromTasks(tool.GetTaskStore(), m.ID) if err != nil { return fmt.Errorf("task graph: %w", err) @@ -95,7 +95,7 @@ func runMission(_ *cobra.Command, args []string) error { m.Features = features waves = taskWaves } else { - fmt.Printf("Mission %s: planning...\n", m.ID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: planning...", m.ID), textPrimary)) planFn := func(ctx context.Context, p string) ([]mission.Feature, error) { return planWithLLM(ctx, p, effectiveProvider, effectiveModel, settings) } @@ -104,14 +104,14 @@ func runMission(_ *cobra.Command, args []string) error { } } - fmt.Printf("Mission %s: %d features planned\n", m.ID, len(m.Features)) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Mission %s: %d features planned", m.ID, len(m.Features)), textPrimary)) for i, f := range m.Features { - fmt.Printf(" %d. %s\n", i+1, f.Description) + fmt.Printf("%s\n", auditTint(fmt.Sprintf(" %d. %s", i+1, f.Description), textPrimary)) } fmt.Println() if missionDryRun { - fmt.Println("(dry-run: not executing workers)") + fmt.Println(auditTint("(dry-run: not executing workers)", textMuted)) return nil } @@ -124,7 +124,12 @@ func runMission(_ *cobra.Command, args []string) error { workerFn = graphTrackingWorker(tool.GetTaskStore(), workerFn) } - fmt.Printf("Executing with %d parallel workers...\n\n", cfg.MaxWorkers) + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Mission", []string{fmt.Sprintf("Executing %d features with %d workers", len(m.Features), cfg.MaxWorkers)}) + defer prog.Abort() + prog.StartStep(0) + } var runErr error if missionFromTasks { runErr = m.RunStaged(ctx, workerFn, mission.WithExecutionWaves(waves)) @@ -132,29 +137,38 @@ func runMission(_ *cobra.Command, args []string) error { runErr = m.Run(ctx, workerFn) } if runErr != nil { + if prog != nil { + prog.FailStep(0, runErr.Error()) + } return runErr } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } // Print results fmt.Println() - fmt.Println(m.Summary()) + fmt.Println(auditTint(m.Summary(), textPrimary)) fmt.Println() for _, f := range m.Features { status := icons.CheckBold() + " " + statusColor := doneGreen if f.Status == mission.FeatureFailed { status = icons.CloseThick() + " " + statusColor = errorCoral } branch := f.Branch if f.Handoff != nil && f.Handoff.CommitID != "" { branch += " (" + f.Handoff.CommitID[:7] + ")" } - fmt.Printf(" %s %s — %s\n", status, f.Description, branch) + fmt.Printf(" %s %s\n", auditTint(status, statusColor), auditTint(f.Description, textPrimary)+auditTint(" — "+branch, textMuted)) } if missionFromTasks && len(m.WaveJoins) > 0 { fmt.Println() - fmt.Println("Wave joins:") + fmt.Println(auditTint("Wave joins:", textPrimary)) for _, join := range m.WaveJoins { - fmt.Printf(" %d. %s\n", join.Wave, join.Summary) + fmt.Printf(" %s\n", auditTint(fmt.Sprintf("%d. %s", join.Wave, join.Summary), textMuted)) } } diff --git a/cmd/models.go b/cmd/models.go index a458c3c9..57b4eeca 100644 --- a/cmd/models.go +++ b/cmd/models.go @@ -38,10 +38,16 @@ var modelsRefreshCmd = &cobra.Command{ } ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) defer cancel() + prog := NewCLIProgress("Models", []string{"Discovering catalog"}) + defer prog.Abort() + prog.StartStep(0) summary, err := graycodeconfig.RefreshModelCatalogV1WithSettings(ctx, settings) if err != nil { + prog.FailStep(0, err.Error()) return err } + prog.CompleteStep(0) + prog.Done() cmd.Println(summary) return nil }, @@ -104,6 +110,15 @@ var modelsListCmd = &cobra.Command{ } ctx := cmd.Context() var models []graycodeconfig.EngineModel + // Only the live provider fetch is slow enough to animate, and only when + // the output is a human table (JSON/raw must stay pure). + animate := modelsListLive && !modelsListJSON && !modelsListRaw + var prog *CLIProgress + if animate { + prog = NewCLIProgress("Models", []string{"Fetching live models"}) + defer prog.Abort() + prog.StartStep(0) + } if modelsListLive { if providerName == "" { return fmt.Errorf("provider required with --live (e.g. graycode models list canopywave --live --json)") @@ -113,8 +128,15 @@ var modelsListCmd = &cobra.Command{ models, err = graycodeconfig.FetchModelsForProviderWithSettings(ctx, settings, providerName) } if err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return err } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } if modelsListJSON || modelsListRaw { out, merr := marshalModelListJSON(models, modelsListRaw, modelsListLive) if merr != nil { @@ -123,9 +145,9 @@ var modelsListCmd = &cobra.Command{ cmd.Println(string(out)) return nil } - cmd.Printf("%d models", len(models)) + cmd.Printf("%s", auditTint(fmt.Sprintf("%d models", len(models)), textPrimary)) if providerName != "" { - cmd.Printf(" for provider %q", providerName) + cmd.Printf("%s", auditTint(fmt.Sprintf(" for provider %q", providerName), textMuted)) } cmd.Println() rows := make([]modelTableRow, len(models)) diff --git a/cmd/permissions.go b/cmd/permissions.go index 58f3f65f..44ab3ecc 100644 --- a/cmd/permissions.go +++ b/cmd/permissions.go @@ -55,7 +55,7 @@ var permissionsListCmd = &cobra.Command{ return err } if len(out) == 0 { - cmd.Println("No persisted permission rules.") + cmd.Println(auditTint("No persisted permission rules.", textMuted)) return nil } for _, rule := range out { @@ -93,7 +93,7 @@ var permissionsAddCmd = &cobra.Command{ if err := store.Save(); err != nil { return err } - cmd.Printf("Permission rule %d saved.\n", id) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Permission rule %d saved.", id), doneGreen)) return nil }, } @@ -117,7 +117,7 @@ var permissionsRevokeCmd = &cobra.Command{ if err := store.Save(); err != nil { return err } - cmd.Printf("Permission rule %d revoked.\n", id) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Permission rule %d revoked.", id), textPrimary)) return nil }, } @@ -134,13 +134,13 @@ var permissionsResetCmd = &cobra.Command{ return err } if !store.Reset() { - cmd.Println("No persisted permission rules.") + cmd.Println(auditTint("No persisted permission rules.", textMuted)) return nil } if err := store.Save(); err != nil { return err } - cmd.Println("Persisted permission rules reset.") + cmd.Println(auditTint("Persisted permission rules reset.", doneGreen)) return nil }, } diff --git a/cmd/plan.go b/cmd/plan.go index ec7496d7..579851cb 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -37,14 +37,14 @@ var planCreateCmd = &cobra.Command{ // Generate the plan prompt (would normally be sent to an LLM). prompt := planner.Generate(description, "") - cmd.Println("Plan prompt generated. Send this to an LLM to produce a plan:") - cmd.Println("--- System ---") + cmd.Println(auditTint("Plan prompt generated. Send this to an LLM to produce a plan:", textMuted)) + cmd.Println(auditTint("--- System ---", graycodeColor)) cmd.Println(prompt.System) cmd.Println() - cmd.Println("--- User ---") + cmd.Println(auditTint("--- User ---", graycodeColor)) cmd.Println(prompt.User) cmd.Println() - cmd.Println("Once you have the LLM response, save it with an explicit output path or import it into Graycode plans.") + cmd.Println(auditTint("Once you have the LLM response, save it with an explicit output path or import it into Graycode plans.", textMuted)) return nil }, } @@ -60,7 +60,7 @@ var planListCmd = &cobra.Command{ if planJSON { fmt.Println("[]") } else { - cmd.Println("No plans found. Create one with: graycode plan create ") + cmd.Println(auditTint("No plans found. Create one with: graycode plan create ", textMuted)) } return nil } @@ -68,6 +68,7 @@ var planListCmd = &cobra.Command{ } var plans []planner.Plan + var planNames []string for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue @@ -78,6 +79,7 @@ var planListCmd = &cobra.Command{ continue } plans = append(plans, *plan) + planNames = append(planNames, strings.TrimSuffix(e.Name(), ".json")) } if planJSON { @@ -90,19 +92,23 @@ var planListCmd = &cobra.Command{ } if len(plans) == 0 { - cmd.Println("No plans found. Create one with: graycode plan create ") + cmd.Println(auditTint("No plans found. Create one with: graycode plan create ", textMuted)) return nil } - for _, plan := range plans { + for i, plan := range plans { pending := len(planner.PendingTasks(&plan)) total := len(plan.Tasks) done := total - pending + name := "" + if i < len(planNames) { + name = planNames[i] + } cmd.Println(fmt.Sprintf( - " %s [%d/%d done] %s", - plan.Title, - done, total, - plan.Title, + " %s %s %s", + auditTint(plan.Title, textPrimary), + auditTint(fmt.Sprintf("[%d/%d done]", done, total), doneGreen), + auditTint(name, textMuted), )) } @@ -184,7 +190,7 @@ followed by the task ID: graycode plan done `, return fmt.Errorf("write plan: %w", err) } - cmd.Println(fmt.Sprintf("Task %d marked as done.", taskID)) + cmd.Println(auditTint(fmt.Sprintf("Task %d marked as done.", taskID), doneGreen)) return nil }, } diff --git a/cmd/plugin_dynamic.go b/cmd/plugin_dynamic.go index 29d2c49c..ee62c270 100644 --- a/cmd/plugin_dynamic.go +++ b/cmd/plugin_dynamic.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "path/filepath" "text/tabwriter" @@ -14,6 +15,20 @@ import ( var dynamicManager *plugin.DynamicPluginManager +// pluginStateColor maps a plugin lifecycle state to a theme color. +func pluginStateColor(state plugin.PluginState) color.Color { + switch state { + case plugin.StateActive: + return doneGreen + case plugin.StateFailed: + return errorCoral + case plugin.StateDisabled: + return textDisabled + default: // discovered, loaded + return infoSky + } +} + func getDynamicManager() *plugin.DynamicPluginManager { if dynamicManager == nil { dynamicManager = plugin.NewDynamicPluginManager(nil, nil, nil) @@ -32,7 +47,7 @@ var pluginActivateCmd = &cobra.Command{ if err := dm.Activate(name); err != nil { return fmt.Errorf("activate plugin %q: %w", name, err) } - cmd.Printf("Plugin %q activated.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" activated.", doneGreen)) return nil }, } @@ -47,7 +62,7 @@ var pluginDeactivateCmd = &cobra.Command{ if err := dm.Deactivate(name); err != nil { return fmt.Errorf("deactivate plugin %q: %w", name, err) } - cmd.Printf("Plugin %q deactivated.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" deactivated.", textPrimary)) return nil }, } @@ -62,7 +77,7 @@ var pluginReloadCmd = &cobra.Command{ if err := dm.Reload(name); err != nil { return fmt.Errorf("reload plugin %q: %w", name, err) } - cmd.Printf("Plugin %q reloaded.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" reloaded.", textPrimary)) return nil }, } @@ -75,7 +90,7 @@ var pluginStatusCmd = &cobra.Command{ statuses := dm.Status() if len(statuses) == 0 { - cmd.Println("No plugins discovered. Run 'graycode plugin install' to add plugins.") + cmd.Println(auditTint("No plugins discovered. Run 'graycode plugin install' to add plugins.", textMuted)) return nil } @@ -90,12 +105,12 @@ var pluginStatusCmd = &cobra.Command{ } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintf(w, "NAME\tVERSION\tSTATE\tTOOLS\tHOOKS\n"); err != nil { + if _, err := fmt.Fprintf(w, "%s\n", auditTint("NAME\tVERSION\tSTATE\tTOOLS\tHOOKS", textMuted)); err != nil { return err } for _, s := range statuses { if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", - s.Name, s.Version, s.State, s.ToolCount, s.HookCount); err != nil { + s.Name, s.Version, auditTint(string(s.State), pluginStateColor(s.State)), s.ToolCount, s.HookCount); err != nil { return err } } @@ -119,7 +134,7 @@ var pluginInstallDynamicCmd = &cobra.Command{ if err := plugin.Install(source); err != nil { return err } - cmd.Printf("Installed plugin from %s.\n", source) + cmd.Printf("%s\n", auditTint("Installed plugin from "+source+".", doneGreen)) return nil } @@ -128,7 +143,7 @@ var pluginInstallDynamicCmd = &cobra.Command{ if err := dm.InstallFromGitHub(source); err != nil { return err } - cmd.Printf("Installed plugin from %s.\n", source) + cmd.Printf("%s\n", auditTint("Installed plugin from "+source+".", doneGreen)) // Re-discover _ = dm.DiscoverAll() @@ -170,7 +185,7 @@ var pluginUninstallCmd = &cobra.Command{ if err := dm.Uninstall(name); err != nil { return err } - cmd.Printf("Plugin %q uninstalled.\n", name) + cmd.Printf("%s\n", auditTint("Plugin "+name+" uninstalled.", textPrimary)) return nil }, } @@ -314,19 +329,19 @@ See `+"`plugin.json`"+` for the full manifest configuration. // #nosec G306 _ = os.WriteFile(filepath.Join(dir, "mcp.json"), []byte("{\n \"servers\": []\n}\n"), 0o644) - cmd.Printf("Created multi-component plugin scaffold at ./%s/\n", name) - cmd.Printf(" %s/plugin.json - Plugin manifest\n", name) - cmd.Printf(" %s/main.go - Plugin entrypoint\n", name) - cmd.Printf(" %s/skills/ - Bundled skills\n", name) - cmd.Printf(" %s/hooks/ - Hook scripts\n", name) - cmd.Printf(" %s/tools/ - Tool binaries\n", name) - cmd.Printf(" %s/mcp.json - MCP server specs\n", name) - cmd.Printf(" %s/README.md - Documentation\n", name) + cmd.Printf("%s\n", auditTint("Created multi-component plugin scaffold at ./"+name+"/", doneGreen)) + cmd.Printf("%s\n", auditTint(" "+name+"/plugin.json - Plugin manifest", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/main.go - Plugin entrypoint", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/skills/ - Bundled skills", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/hooks/ - Hook scripts", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/tools/ - Tool binaries", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/mcp.json - MCP server specs", textMuted)) + cmd.Printf("%s\n", auditTint(" "+name+"/README.md - Documentation", textMuted)) cmd.Println() - cmd.Printf("Next steps:\n") - cmd.Printf(" cd %s && go mod init %s\n", name, name) - cmd.Printf(" graycode plugin install ./%s\n", name) - cmd.Printf(" graycode plugin activate %s\n", name) + cmd.Printf("%s\n", auditTint("Next steps:", textPrimary)) + cmd.Printf("%s\n", auditTint(" cd "+name+" && go mod init "+name, textMuted)) + cmd.Printf("%s\n", auditTint(" graycode plugin install ./"+name, textMuted)) + cmd.Printf("%s\n", auditTint(" graycode plugin activate "+name, textMuted)) return nil }, } @@ -359,20 +374,20 @@ var pluginLogsCmd = &cobra.Command{ name := args[0] for _, s := range statuses { if s.Name == name { - cmd.Printf("Plugin: %s\n", s.Name) - cmd.Printf("State: %s\n", s.State) + cmd.Printf("%s\n", auditTint("Plugin: ", textMuted)+auditTint(s.Name, textPrimary)) + cmd.Printf("%s\n", auditTint("State: ", textMuted)+auditTint(string(s.State), pluginStateColor(s.State))) if s.Error != "" { - cmd.Printf("Error: %s\n", s.Error) + cmd.Printf("%s\n", auditTint("Error: ", textMuted)+auditTint(s.Error, errorCoral)) } if !s.ActivatedAt.IsZero() { - cmd.Printf("Activated: %s\n", s.ActivatedAt.Format(time.RFC3339)) + cmd.Printf("%s\n", auditTint("Activated: ", textMuted)+auditTint(s.ActivatedAt.Format(time.RFC3339), textPrimary)) } return nil } } return fmt.Errorf("plugin %q not found", name) } - cmd.Println("No recent plugin events.") + cmd.Println(auditTint("No recent plugin events.", textMuted)) return nil } @@ -411,8 +426,8 @@ var pluginMarketplaceListCmd = &cobra.Command{ return fmt.Errorf("fetch marketplace: %w (indexes may be unpublished; add a source with graycode plugin marketplace add)", err) } if len(entries) == 0 { - cmd.Println("No marketplace plugins found.") - cmd.Println("Add a source: graycode plugin marketplace add ") + cmd.Println(auditTint("No marketplace plugins found.", textMuted)) + cmd.Println(auditTint("Add a source: graycode plugin marketplace add ", textMuted)) return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) @@ -445,7 +460,7 @@ var pluginMarketplaceInstallCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Installed %s to %s\n", entry.Name, dir) + cmd.Printf("%s\n", auditTint("Installed "+entry.Name+" to ", doneGreen)+auditTint(dir, textPrimary)) // re-discover _ = getDynamicManager().DiscoverAll() return nil @@ -460,7 +475,7 @@ var pluginMarketplaceAddCmd = &cobra.Command{ if err := plugin.AddSource(args[0], args[1]); err != nil { return err } - cmd.Printf("Added marketplace source %q → %s\n", args[0], args[1]) + cmd.Printf("%s\n", auditTint("Added marketplace source "+args[0]+" → ", doneGreen)+auditTint(args[1], textPrimary)) return nil }, } @@ -495,20 +510,20 @@ var pluginInspectCmd = &cobra.Command{ if err != nil { return err } - cmd.Printf("Root: %s\n", comp.Root) - cmd.Printf("Components: %s\n", comp.ComponentSummary()) - cmd.Printf("Tools: %v\n", comp.HasTools) - cmd.Printf("Skills (%d):\n", len(comp.Skills)) + cmd.Printf("%s\n", auditTint("Root: ", textMuted)+auditTint(comp.Root, textPrimary)) + cmd.Printf("%s\n", auditTint("Components: ", textMuted)+auditTint(comp.ComponentSummary(), textPrimary)) + cmd.Printf("%s\n", auditTint("Tools: ", textMuted)+auditTint(fmt.Sprintf("%v", comp.HasTools), textPrimary)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Skills (%d):", len(comp.Skills)), textPrimary)) for _, s := range comp.Skills { - cmd.Printf(" - %s\n", s) + cmd.Printf("%s\n", auditTint(" - "+s, textMuted)) } - cmd.Printf("Hooks (%d):\n", len(comp.HookFiles)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Hooks (%d):", len(comp.HookFiles)), textPrimary)) for _, h := range comp.HookFiles { - cmd.Printf(" - %s\n", h) + cmd.Printf("%s\n", auditTint(" - "+h, textMuted)) } - cmd.Printf("MCP servers (%d):\n", len(comp.MCPServers)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("MCP servers (%d):", len(comp.MCPServers)), textPrimary)) for _, m := range comp.MCPServers { - cmd.Printf(" - %s cmd=%s url=%s\n", m.Name, m.Command, m.URL) + cmd.Printf("%s\n", auditTint(fmt.Sprintf(" - %s cmd=%s url=%s", m.Name, m.Command, m.URL), textMuted)) } return nil }, diff --git a/cmd/pr.go b/cmd/pr.go index 106e9534..6c46b732 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -70,7 +70,7 @@ Otherwise, reviews the diff between the base branch and HEAD.`, } if strings.TrimSpace(diff) == "" { - cmd.Println("No changes found.") + cmd.Println(auditTint("No changes found.", textMuted)) return nil } @@ -81,7 +81,7 @@ Otherwise, reviews the diff between the base branch and HEAD.`, if err := ghPRComment(prNumber, review); err != nil { return fmt.Errorf("failed to post comment: %w", err) } - cmd.Println("\nReview posted as comment on PR #" + strconv.Itoa(prNumber)) + cmd.Println(auditTint("\nReview posted as comment on PR #"+strconv.Itoa(prNumber), doneGreen)) } return nil @@ -138,7 +138,7 @@ then creates a pull request via the GitHub CLI.`, } prURL := strings.TrimSpace(string(out)) - cmd.Println("Pull request created: " + prURL) + cmd.Println(auditTint("Pull request created: ", doneGreen) + auditTint(prURL, textPrimary)) return nil }, } @@ -164,7 +164,7 @@ Use --update to write the description back to the PR.`, return fmt.Errorf("failed to get PR diff: %w", err) } if strings.TrimSpace(diff) == "" { - cmd.Println("No changes found in PR #" + strconv.Itoa(prNumber)) + cmd.Println(auditTint("No changes found in PR #"+strconv.Itoa(prNumber), textMuted)) return nil } @@ -178,7 +178,7 @@ Use --update to write the description back to the PR.`, if err := ghCmd.Run(); err != nil { return fmt.Errorf("failed to update PR description: %w", err) } - cmd.Println("\nPR #" + strconv.Itoa(prNumber) + " description updated.") + cmd.Println(auditTint("\nPR #"+strconv.Itoa(prNumber)+" description updated.", doneGreen)) } return nil diff --git a/cmd/progress_cli.go b/cmd/progress_cli.go new file mode 100644 index 00000000..6911b9d9 --- /dev/null +++ b/cmd/progress_cli.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "fmt" + "image/color" + "io" + "os" + "strings" + "time" + + lipgloss "charm.land/lipgloss/v2" + "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" +) + +// CLIProgress renders a ProgressTracker as live single-line progress for +// non-TUI commands. On a terminal it animates the active step in place with +// the spinner wave; when stdout is piped or CI it emits one clean static line +// per completed step so output stays parseable. Glyphs come from +// internal/ui/icons so the cmd/ no-emoji audit holds. +type CLIProgress struct { + w io.Writer + pt *ProgressTracker + spinner *BrailleSpinner + tty bool +} + +// NewCLIProgress builds a tracker for title with the given steps, writing to +// stdout and animating only when stdout is a terminal. +func NewCLIProgress(title string, steps []string) *CLIProgress { + return newCLIProgress(title, steps, os.Stdout, stdoutIsTerminal()) +} + +// newCLIProgress is the testable core: writer and tty are injected. +func newCLIProgress(title string, steps []string, w io.Writer, tty bool) *CLIProgress { + pt := NewProgressTracker(title) + for _, s := range steps { + pt.AddStep(s) + } + return &CLIProgress{w: w, pt: pt, spinner: NewBrailleSpinner(SpinnerGraycode, ""), tty: tty} +} + +// StartStep marks step i active and, on a TTY, starts animating it in place. +// A fresh BrailleSpinner is created per step because the spinner is one-shot: +// its stop channel is closed on Stop() and cannot be restarted. +func (c *CLIProgress) StartStep(i int) { + c.pt.StartStep(i) + if IsQuiet() || !c.tty || i < 0 || i >= len(c.pt.Steps) { + return + } + c.spinner = NewBrailleSpinner(SpinnerGraycode, c.pt.Steps[i].Name) + c.spinner.Start(80*time.Millisecond, func(frame string) { + eta := "" + if remaining := c.pt.EstimateRemaining(); remaining > 0 { + eta = fmt.Sprintf(" · ETA %s", formatDurationShort(remaining)) + } + name := c.tint(c.pt.Steps[i].Name, textPrimary) + _, _ = fmt.Fprintf(c.w, "\r%s %s %s %d/%d%s\033[K", frame, c.bar(), name, i+1, len(c.pt.Steps), eta) + }) +} + +// CompleteStep finalizes step i with its duration and prints a clean line. +func (c *CLIProgress) CompleteStep(i int) { + c.spinner.Stop() + c.pt.CompleteStep(i) + if IsQuiet() || i < 0 || i >= len(c.pt.Steps) { + return + } + s := c.pt.Steps[i] + c.writeLine(fmt.Sprintf("%s %s (%s)", + c.tint(icons.CheckBold(), doneGreen), + c.tint(s.Name, textPrimary), + c.tint(formatDurationShort(s.Duration), textMuted))) +} + +// FailStep finalizes step i as failed with a reason. +func (c *CLIProgress) FailStep(i int, reason string) { + c.spinner.Stop() + c.pt.FailStep(i, reason) + if IsQuiet() || i < 0 || i >= len(c.pt.Steps) { + return + } + s := c.pt.Steps[i] + c.writeLine(fmt.Sprintf("%s %s (%s) : %s", + c.tint(icons.CloseThick(), errorCoral), + c.tint(s.Name, textPrimary), + c.tint(formatDurationShort(s.Duration), textMuted), + c.tint(reason, errorCoral))) +} + +// tint applies a theme foreground color when color output is appropriate +// (honors --quiet, NO_COLOR, FORCE_COLOR, and TTY state via ShouldColor). +// Piped/CI output stays plain so scripts never see stray ANSI escapes. +func (c *CLIProgress) tint(s string, color color.Color) string { + if !ShouldColor() || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(color).Render(s) +} + +// bar renders the overall progress as a compact theme-colored bar (filled = +// successTeal, empty = borderDim). Block glyphs (U+2588/U+2591) are in the +// range the cmd/ no-emoji audit permits, matching ProgressTracker.Render. +func (c *CLIProgress) bar() string { + pct := c.pt.overallProgress() + const width = 12 + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + filledStr := strings.Repeat("█", filled) + emptyStr := strings.Repeat("░", width-filled) + return c.tint(filledStr, successTeal) + c.tint(emptyStr, borderDim) +} + +// Done stops any animation and prints a themed completion summary with the +// final progress bar. Uses errorCoral when any step failed. +func (c *CLIProgress) Done() { + c.spinner.Stop() + if IsQuiet() { + return + } + elapsed := c.pt.GetElapsed() + failures := 0 + for _, s := range c.pt.Steps { + if s.Status == "failed" { + failures++ + } + } + mark := icons.CheckBold() + markColor := doneGreen + verb := "complete" + if failures > 0 { + mark = icons.CloseThick() + markColor = errorCoral + verb = "finished" + } + line := fmt.Sprintf("%s %s %s in %s", + c.tint(mark, markColor), + c.tint(c.pt.Title, textPrimary), + verb, + c.tint(formatDurationShort(elapsed), textMuted)) + if failures > 0 { + line += c.tint(fmt.Sprintf(" (%d failed)", failures), errorCoral) + } + c.writeLine(fmt.Sprintf("%s %s", c.bar(), line)) +} + +// Abort stops any running animation without printing a completion line. Safe +// to call from deferred error paths so a spinner never leaks past a return. +func (c *CLIProgress) Abort() { + c.spinner.Stop() +} + +func (c *CLIProgress) writeLine(line string) { + if c.tty { + _, _ = fmt.Fprintf(c.w, "\r%s\033[K\n", line) + return + } + _, _ = fmt.Fprintln(c.w, line) +} diff --git a/cmd/progress_cli_test.go b/cmd/progress_cli_test.go new file mode 100644 index 00000000..2ed2b1b4 --- /dev/null +++ b/cmd/progress_cli_test.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +// TestCLIProgressNonTTY asserts that piped/CI output is clean: one static line +// per completed step, no ANSI escapes, no carriage-return animation. +func TestCLIProgressNonTTY(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review", "Save"}, &buf, false) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.StartStep(2) + p.CompleteStep(2) + p.Done() + + out := buf.String() + if strings.Contains(out, "\x1b") { + t.Errorf("non-TTY output must not contain ANSI escapes, got: %q", out) + } + if strings.Contains(out, "\r") { + t.Errorf("non-TTY output must not use carriage returns, got: %q", out) + } + for _, name := range []string{"Build", "Review", "Save"} { + if !strings.Contains(out, name) { + t.Errorf("expected step %q in output, got: %q", name, out) + } + } + // Each completed step should appear as its own line. + if got := strings.Count(out, "\n"); got < 3 { + t.Errorf("expected at least 3 completed-step lines, got %d in %q", got, out) + } +} + +// TestCLIProgressTTY asserts the terminal path redraws the current line with a +// leading carriage return and clears trailing glyphs before finalizing. +func TestCLIProgressTTY(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\r") { + t.Errorf("TTY output should redraw with carriage returns, got: %q", out) + } + if !strings.Contains(out, "Build") { + t.Errorf("expected step name in TTY output, got: %q", out) + } +} + +// TestCLIProgressFailStep marks a step failed and keeps other steps pending. +func TestCLIProgressFailStep(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, false) + + p.StartStep(0) + p.FailStep(0, "boom") + + if p.pt.Steps[0].Status != "failed" { + t.Errorf("expected step 0 failed, got %q", p.pt.Steps[0].Status) + } + if p.pt.Steps[1].Status != "pending" { + t.Errorf("expected step 1 still pending, got %q", p.pt.Steps[1].Status) + } + if !strings.Contains(buf.String(), "boom") { + t.Errorf("expected fail reason in output, got: %q", buf.String()) + } +} + +// TestCLIProgressTTYMultiStep runs multiple Start/Complete cycles on a TTY. +// Regression: the spinner is one-shot, so each step must get a fresh spinner +// rather than restarting a stopped one (which would panic on a double close). +func TestCLIProgressTTYMultiStep(t *testing.T) { + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review", "Save"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.StartStep(2) + p.CompleteStep(2) + p.Done() + + out := buf.String() + for _, name := range []string{"Build", "Review", "Save"} { + if !strings.Contains(out, name) { + t.Errorf("expected step %q in TTY output, got: %q", name, out) + } + } +} + +// TestCLIProgressQuiet asserts --quiet fully suppresses progress: no +// animation, no completed-step lines, no completion summary. The flag is +// documented to drop "spinners, progress, decoration" for machine parsing. +func TestCLIProgressQuiet(t *testing.T) { + prev := quietFlag + quietFlag = true + defer func() { quietFlag = prev }() + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build", "Review"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.StartStep(1) + p.CompleteStep(1) + p.Done() + + if buf.Len() != 0 { + t.Errorf("quiet mode should emit no progress output, got: %q", buf.String()) + } +} + +// TestCLIProgressNoColor asserts NO_COLOR keeps the TTY animation (carriage +// returns and the \033[K clear-line control) but strips color SGR sequences +// (the \x1b[38 truecolor foregrounds applied by tint). +func TestCLIProgressNoColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build"}, &buf, true) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\r") { + t.Errorf("NO_COLOR should keep TTY animation, got: %q", out) + } + if strings.Contains(out, "\x1b[38") { + t.Errorf("NO_COLOR must strip color SGR sequences, got: %q", out) + } +} + +// TestCLIProgressForceColor asserts FORCE_COLOR colors output even when stdout +// is piped (no carriage-return animation, but truecolor SGR present). +func TestCLIProgressForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "") // clear ambient NO_COLOR; it wins over FORCE_COLOR + t.Setenv("FORCE_COLOR", "1") + + var buf bytes.Buffer + p := newCLIProgress("Review", []string{"Build"}, &buf, false) + + p.StartStep(0) + p.CompleteStep(0) + p.Done() + + out := buf.String() + if !strings.Contains(out, "\x1b[38") { + t.Errorf("FORCE_COLOR should color piped output, got: %q", out) + } + if strings.Contains(out, "\r") { + t.Errorf("piped output must not animate with carriage returns, got: %q", out) + } +} diff --git a/cmd/review.go b/cmd/review.go index 2a9e031e..2182895a 100644 --- a/cmd/review.go +++ b/cmd/review.go @@ -61,7 +61,7 @@ func runReviewInit(_ *cobra.Command, _ []string) error { if _, err := os.Stat(hookPath); err == nil && !reviewInitForce { existing, _ := os.ReadFile(hookPath) // #nosec G304 -- hookPath built from internal hooksDir constant, not external input if strings.Contains(string(existing), "graycode review") { - fmt.Println(icons.CheckBold() + " graycode review hook already installed") + fmt.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("graycode review hook already installed", textPrimary)) return nil } return fmt.Errorf("post-commit hook already exists at %s\nUse --force to overwrite, or manually add:\n %s", hookPath, strings.TrimSpace(hookScript)) @@ -72,10 +72,10 @@ func runReviewInit(_ *cobra.Command, _ []string) error { return fmt.Errorf("write hook: %w", err) } - fmt.Printf("%s Installed post-commit hook at %s\n", icons.CheckBold(), hookPath) - fmt.Println(" Every commit will now be reviewed automatically.") - fmt.Println(" View reviews: graycode review status") - fmt.Println(" Interactive: graycode review tui") + fmt.Println(auditTint(icons.CheckBold()+" ", doneGreen) + auditTint("Installed post-commit hook at ", textPrimary) + auditTint(hookPath, textMuted)) + fmt.Println(auditTint(" Every commit will now be reviewed automatically.", textMuted)) + fmt.Println(auditTint(" View reviews: graycode review status", textMuted)) + fmt.Println(auditTint(" Interactive: graycode review tui", textMuted)) return nil } diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index 513f02d2..6f284371 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -120,7 +120,7 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { return fmt.Errorf("gather files: %w", err) } if content == "" { - fmt.Println("No files matched.") + fmt.Println(auditTint("No files matched.", textMuted)) return nil } @@ -155,11 +155,23 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Use the analysis prompt as a "diff" — kestrel will review it. analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) - fmt.Printf("Analyzing (%s)...\n", analysisType) + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Analyze", []string{fmt.Sprintf("Analyzing %s", analysisType)}) + defer prog.Abort() + prog.StartStep(0) + } result, err := bridge.ReviewContracts(ctx, analysisInput) if err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return fmt.Errorf("analysis failed: %w", err) } + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } // Store as a review record. projectDir, _ := os.Getwd() @@ -177,24 +189,24 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Print results. if len(result.Findings) == 0 { - fmt.Printf("%s No %s issues found.\n", icons.CheckBold(), analysisType) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint("No "+analysisType+" issues found.", doneGreen)) return nil } - fmt.Printf("%s %d %s finding(s):\n\n", icons.Alert(), len(result.Findings), analysisType) + fmt.Printf("%s %s\n\n", auditTint(icons.Alert(), warnAmber), auditTint(fmt.Sprintf("%d %s finding(s):", len(result.Findings), analysisType), textPrimary)) for i, f := range result.Findings { sev := severityStyle(f.Severity.String()) - fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) - fmt.Printf(" %s\n", f.Message) + fmt.Printf(" %d. %s %s:%d\n", i+1, sev, auditTint(f.File, textPrimary), f.Line) + fmt.Printf(" %s\n", auditTint(f.Message, textMuted)) if f.Fix != "" { - fmt.Printf(" Fix: %s\n", f.Fix) + fmt.Printf(" %s\n", auditTint("Fix: "+f.Fix, textMuted)) } fmt.Println() } // Auto-fix if requested. if analyzeFix && len(result.Findings) > 0 { - fmt.Println("Applying fixes...") + fmt.Println(auditTint("Applying fixes...", textPrimary)) return autoFixAnalysis(result) } diff --git a/cmd/review_fix.go b/cmd/review_fix.go index 67a7a2cc..1417847b 100644 --- a/cmd/review_fix.go +++ b/cmd/review_fix.go @@ -60,16 +60,17 @@ func runReviewFix(_ *cobra.Command, args []string) error { } if len(reviews) == 0 { - fmt.Println("No open reviews to fix.") + fmt.Println(auditTint("No open reviews to fix.", textMuted)) return nil } for _, r := range reviews { + fmt.Printf("%s %s\n", auditTint(icons.Bolt(), toolGold), auditTint(fmt.Sprintf("Fixing review #%d (%s)...", r.ID, r.SHA[:8]), textPrimary)) if err := fixReview(store, r); err != nil { - fmt.Printf("%s Review #%d (%s): %v\n", icons.CloseThick(), r.ID, r.SHA[:8], err) + fmt.Printf("%s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint(fmt.Sprintf("Review #%d (%s): %v", r.ID, r.SHA[:8], err), errorCoral)) continue } - fmt.Printf("%s Review #%d (%s) fixed\n", icons.CheckBold(), r.ID, r.SHA[:8]) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("Review #%d (%s) fixed", r.ID, r.SHA[:8]), doneGreen)) } return nil } diff --git a/cmd/review_read.go b/cmd/review_read.go index f9e996db..9b8c7931 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -3,13 +3,14 @@ package cmd import ( "encoding/json" "fmt" + "image/color" "os" "strconv" "strings" - lipgloss "charm.land/lipgloss/v2" "github.com/spf13/cobra" + contracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/types" "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" ) @@ -71,7 +72,7 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { total += v } if total == 0 { - fmt.Println("No reviews yet. Run 'graycode review init' to start.") + fmt.Println(auditTint("No reviews yet. Run 'graycode review init' to start.", textMuted)) return nil } @@ -80,18 +81,18 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { fixed := summary[ReviewStatusFixed] failed := summary[ReviewStatusFailed] - fmt.Printf("Reviews: %d total", total) + fmt.Printf("%s %s", auditTint("Reviews:", textMuted), auditTint(fmt.Sprintf("%d total", total), textPrimary)) if open > 0 { - fmt.Printf(" · %d open", open) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d open", open), infoSky)) } if passed > 0 { - fmt.Printf(" · %d passed", passed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d passed", passed), doneGreen)) } if fixed > 0 { - fmt.Printf(" · %d fixed", fixed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d fixed", fixed), successTeal)) } if failed > 0 { - fmt.Printf(" · %d failed", failed) + fmt.Printf(" · %s", auditTint(fmt.Sprintf("%d failed", failed), errorCoral)) } fmt.Println() @@ -100,7 +101,15 @@ func runReviewStatus(_ *cobra.Command, _ []string) error { reviews, _ := store.ListOpen() fmt.Println() for _, r := range reviews { - fmt.Printf(" #%d %s [%s] %d findings\n", r.ID, r.SHA[:8], r.MaxSeverity, len(r.Findings)) + var sev color.Color = textPrimary + if parsed, err := contracts.ParseSeverityStrict(r.MaxSeverity); err == nil { + sev = reviewSeverityColor(parsed) + } + fmt.Printf(" %s %s %s %s\n", + auditTint(fmt.Sprintf("#%d", r.ID), textPrimary), + auditTint(r.SHA[:8], textMuted), + auditTint(fmt.Sprintf("[%s]", r.MaxSeverity), sev), + auditTint(fmt.Sprintf("%d findings", len(r.Findings)), textMuted)) } } return nil @@ -119,7 +128,7 @@ func runReviewShow(_ *cobra.Command, args []string) error { // Show latest open review. reviews, _ := store.ListOpen() if len(reviews) == 0 { - fmt.Println("No open reviews.") + fmt.Println(auditTint("No open reviews.", textMuted)) return nil } review = reviews[0] @@ -156,7 +165,7 @@ func runReviewClose(_ *cobra.Command, args []string) error { if err := store.SetStatus(review.ID, ReviewStatusClosed); err != nil { return err } - fmt.Printf("%s Closed review #%d (%s)\n", icons.CheckBold(), review.ID, review.SHA[:8]) + fmt.Printf("%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("Closed review #%d (%s)", review.ID, review.SHA[:8]), textPrimary)) return nil } @@ -173,7 +182,7 @@ func runReviewList(_ *cobra.Command, _ []string) error { return err } if len(reviews) == 0 { - fmt.Println("No reviews yet.") + fmt.Println(auditTint("No reviews yet.", textMuted)) return nil } @@ -181,9 +190,13 @@ func runReviewList(_ *cobra.Command, _ []string) error { icon := statusIcon(r.Status) findings := "" if len(r.Findings) > 0 { - findings = fmt.Sprintf(" %d findings [%s]", len(r.Findings), r.MaxSeverity) + findings = auditTint(fmt.Sprintf(" %d findings", len(r.Findings)), textMuted) + " " + severityStyle(r.MaxSeverity) } - fmt.Printf("%s #%-3d %s %s%s %s\n", icon, r.ID, r.SHA[:8], r.Status, findings, r.CreatedAt.Format("Jan 02 15:04")) + fmt.Printf("%s #%-3d %s %s%s %s\n", + icon, r.ID, r.SHA[:8], + auditTint(string(r.Status), reviewStatusColor(r.Status)), + findings, + r.CreatedAt.Format("Jan 02 15:04")) } return nil } @@ -205,27 +218,27 @@ func resolveReview(store *ReviewStore, ref string) (*ReviewRecord, error) { } func printReviewDetail(r *ReviewRecord) { - header := lipgloss.NewStyle().Bold(true) - dim := lipgloss.NewStyle().Faint(true) - - fmt.Printf("%s Review #%d — %s\n", statusIcon(r.Status), r.ID, r.SHA[:8]) - fmt.Printf("%s\n", dim.Render(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed))) + fmt.Printf("%s %s\n", auditTint(statusIcon(r.Status), reviewStatusColor(r.Status)), auditTint(fmt.Sprintf("Review #%d — %s", r.ID, r.SHA[:8]), textPrimary)) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Status: %s · Created: %s · Tokens: %d", r.Status, r.CreatedAt.Format("2006-01-02 15:04"), r.TokensUsed), textMuted)) fmt.Println() if len(r.Findings) == 0 { - fmt.Println(header.Render("No findings — clean commit " + icons.CheckBold())) + fmt.Println(auditTint("No findings — clean commit "+icons.CheckBold(), doneGreen)) return } - fmt.Println(header.Render(fmt.Sprintf("%d Findings:", len(r.Findings)))) + fmt.Println(auditTint(fmt.Sprintf("%d Findings:", len(r.Findings)), textPrimary)) fmt.Println() for i, f := range r.Findings { sev := severityStyle(f.Severity.String()) - fmt.Printf(" %d. %s %s:%d\n", i+1, sev, f.File, f.Line) - fmt.Printf(" %s\n", f.Message) + fmt.Printf(" %s %s %s:%d\n", + auditTint(fmt.Sprintf("%d.", i+1), textMuted), + sev, + auditTint(f.File, textPrimary), f.Line) + fmt.Printf(" %s\n", auditTint(f.Message, textMuted)) if f.Fix != "" { - fmt.Printf(" %s %s\n", dim.Render("Fix:"), f.Fix) + fmt.Printf(" %s %s\n", auditTint("Fix:", textMuted), f.Fix) } fmt.Println() } @@ -250,17 +263,22 @@ func statusIcon(s ReviewStatus) string { } } -func severityStyle(sev string) string { - switch strings.ToLower(sev) { - case "critical": - return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true).Render("[CRITICAL]") - case "high": - return lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true).Render("[HIGH]") - case "medium": - return lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render("[MEDIUM]") - case "low": - return lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("[LOW]") +func reviewStatusColor(s ReviewStatus) color.Color { + switch s { + case ReviewStatusPassed, ReviewStatusFixed: + return doneGreen + case ReviewStatusOpen, ReviewStatusRunning: + return infoSky + case ReviewStatusFailed: + return errorCoral + case ReviewStatusClosed: + return textMuted default: - return lipgloss.NewStyle().Faint(true).Render("[INFO]") + return textPrimary } } + +func severityStyle(sev string) string { + s, _ := contracts.ParseSeverityStrict(sev) + return auditTint("["+strings.ToUpper(sev)+"]", reviewSeverityColor(s)) +} diff --git a/cmd/review_refine.go b/cmd/review_refine.go index 9bd6431a..11607d74 100644 --- a/cmd/review_refine.go +++ b/cmd/review_refine.go @@ -62,14 +62,14 @@ func runReviewRefine(_ *cobra.Command, args []string) error { } if len(reviews) == 0 { - fmt.Println("No open reviews to refine.") + fmt.Println(auditTint("No open reviews to refine.", textMuted)) return nil } - fmt.Printf("Refining %d review(s), max %d iterations...\n\n", len(reviews), refineMaxIter) + fmt.Printf("%s\n\n", auditTint(fmt.Sprintf("Refining %d review(s), max %d iterations...", len(reviews), refineMaxIter), textPrimary)) for iter := 1; iter <= refineMaxIter; iter++ { - fmt.Printf("── Iteration %d/%d ──\n", iter, refineMaxIter) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("── Iteration %d/%d ──", iter, refineMaxIter), graycodeColor)) // Fix all open reviews. for _, r := range reviews { @@ -77,22 +77,22 @@ func runReviewRefine(_ *cobra.Command, args []string) error { continue } if err := fixReviewRefine(store, r); err != nil { - fmt.Printf(" %s #%d fix failed: %v\n", icons.CloseThick(), r.ID, err) + fmt.Printf(" %s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint(fmt.Sprintf("#%d fix failed: %v", r.ID, err), errorCoral)) } else { - fmt.Printf(" %s #%d fix applied\n", icons.CheckBold(), r.ID) + fmt.Printf(" %s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("#%d fix applied", r.ID), doneGreen)) } } // Wait briefly for hook to fire, then re-review the latest commit. latestSHA := getLatestCommitSHA() if latestSHA == "" { - fmt.Println(" Could not determine latest commit.") + fmt.Println(auditTint(" Could not determine latest commit.", textMuted)) break } - fmt.Printf(" Reviewing %s...\n", latestSHA[:8]) + fmt.Printf("%s\n", auditTint(" Reviewing "+latestSHA[:8]+"...", textPrimary)) if err := runReviewOnSHA(store, latestSHA); err != nil { - fmt.Printf(" %s Review failed: %v\n", icons.CloseThick(), err) + fmt.Printf(" %s %s\n", auditTint(icons.CloseThick(), errorCoral), auditTint("Review failed: "+err.Error(), errorCoral)) break } @@ -102,7 +102,7 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("load review for %s: %w", latestSHA[:8], getErr) } if newReview != nil && newReview.Status == ReviewStatusPassed { - fmt.Printf("\n%s All clean after %d iteration(s)!\n", icons.CheckBold(), iter) + fmt.Printf("\n%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("All clean after %d iteration(s)!", iter), textPrimary)) return nil } @@ -116,7 +116,7 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("list open reviews: %w", listErr) } if len(reviews) == 0 { - fmt.Printf("\n%s All reviews resolved after %d iteration(s)!\n", icons.CheckBold(), iter) + fmt.Printf("\n%s %s\n", auditTint(icons.CheckBold(), doneGreen), auditTint(fmt.Sprintf("All reviews resolved after %d iteration(s)!", iter), textPrimary)) return nil } } @@ -128,8 +128,8 @@ func runReviewRefine(_ *cobra.Command, args []string) error { return fmt.Errorf("list open reviews: %w", listErr) } if len(remaining) > 0 { - fmt.Printf("\n%s %d review(s) still open after %d iterations.\n", icons.Alert(), len(remaining), refineMaxIter) - fmt.Println(" Run 'graycode review show' to inspect, or increase --max-iterations.") + fmt.Printf("\n%s %s\n", auditTint(icons.Alert(), warnAmber), auditTint(fmt.Sprintf("%d review(s) still open after %d iterations.", len(remaining), refineMaxIter), textPrimary)) + fmt.Println(auditTint(" Run 'graycode review show' to inspect, or increase --max-iterations.", textMuted)) } return nil } diff --git a/cmd/review_run.go b/cmd/review_run.go index 8bfd30b4..ecb23fe0 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "image/color" "os" "os/exec" "strings" @@ -11,6 +12,7 @@ import ( graycodeKestrel "github.com/GrayCodeAI/graycode-cli/internal/bridge/kestrel" graycodeconfig "github.com/GrayCodeAI/graycode-cli/internal/config" reviewcontracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/review" + contracts "github.com/GrayCodeAI/graycode-cli/internal/contracts/types" "github.com/GrayCodeAI/graycode-cli/internal/engine" "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" kestrelLib "github.com/GrayCodeAI/kestrel" @@ -64,7 +66,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { } if existing != nil && existing.Status != ReviewStatusFailed { if !reviewRunBackground { - fmt.Printf("Commit %s already reviewed (status: %s)\n", sha[:8], existing.Status) + fmt.Printf("%s\n", auditTint("Commit "+sha[:8]+" already reviewed (status: ", textMuted)+auditTint(string(existing.Status), reviewStatusColor(existing.Status))+auditTint(")", textMuted)) } return nil } @@ -91,17 +93,43 @@ func runReviewRun(_ *cobra.Command, args []string) error { return silentErr(statusErr, "mark review passed") } if !reviewRunBackground { - fmt.Println("Empty diff — nothing to review.") + fmt.Println(auditTint("Empty diff — nothing to review.", textMuted)) } return nil } + // Live progress for the slow review stages. TTY-aware: animates the active + // step on a terminal, prints clean static lines when piped, and stays + // silent in background/hook mode. The deferred Abort guarantees the + // spinner goroutine never leaks past an error return. + var prog *CLIProgress + if !reviewRunBackground { + prog = NewCLIProgress("Review", []string{"Building model", "Reviewing code", "Saving results"}) + defer prog.Abort() + } + step := func(i int) { + if prog != nil { + prog.StartStep(i) + } + } + done := func(i int) { + if prog != nil { + prog.CompleteStep(i) + } + } + finish := func() { + if prog != nil { + prog.Done() + } + } + // Build the Kestrel bridge through Graycode's GraycodeRouter engine boundary. ctx := context.Background() selection := graycodeconfig.EffectiveSelection(ctx, graycodeconfig.SelectionOptions{ ProviderOverride: strings.TrimSpace(provider), ModelOverride: strings.TrimSpace(reviewRunModel), }) + step(0) chatProvider, providerID, err := engine.BuildChatProvider(ctx, selection, strings.TrimSpace(provider)) if err != nil { if statusErr := store.SetStatus(id, ReviewStatusFailed); statusErr != nil { @@ -136,6 +164,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { defer cancel() } + done(0) + step(1) + // Run review. result, err := bridge.ReviewContracts(ctx, diff) if err != nil { @@ -151,9 +182,14 @@ func runReviewRun(_ *cobra.Command, args []string) error { status = ReviewStatusOpen } + done(1) + step(2) + if err := store.Update(id, status, result); err != nil { return silentErr(err, "store result") } + done(2) + finish() if !reviewRunBackground { printReviewSummary(sha, result) @@ -174,14 +210,37 @@ func getCommitDiff(sha string) (string, error) { return string(out), nil } +// reviewSeverityColor maps a review finding's severity to its semantic theme +// color, mirroring the audit report's severity palette. +func reviewSeverityColor(sev contracts.Severity) color.Color { + switch sev { + case contracts.SeverityCritical, contracts.SeverityHigh: + return errorCoral + case contracts.SeverityMedium: + return warnAmber + default: + return infoSky + } +} + func printReviewSummary(sha string, result *reviewcontracts.Result) { if len(result.Findings) == 0 { - fmt.Printf("%s %s — no issues found (%d files reviewed)\n", icons.CheckBold(), sha[:8], result.Stats.FilesReviewed) + fmt.Printf("%s %s — no issues found (%d files reviewed)\n", + auditTint(icons.CheckBold(), doneGreen), + auditTint(sha[:8], textPrimary), + result.Stats.FilesReviewed) return } - fmt.Printf("%s %s — %d findings (max severity: %s)\n", icons.Alert(), sha[:8], len(result.Findings), result.MaxSeverity()) + maxSev := result.MaxSeverity() + fmt.Printf("%s %s — %d findings (max severity: %s)\n", + auditTint(icons.Alert(), errorCoral), + auditTint(sha[:8], textPrimary), + len(result.Findings), + auditTint(maxSev.String(), reviewSeverityColor(maxSev))) for _, f := range result.Findings { - fmt.Printf(" [%s] %s:%d — %s\n", f.Severity, f.File, f.Line, f.Message) + fmt.Printf(" %s %s\n", + auditTint(fmt.Sprintf("[%s]", f.Severity.String()), reviewSeverityColor(f.Severity)), + auditTint(fmt.Sprintf("%s:%d", f.File, f.Line), textPrimary)+auditTint(" — "+f.Message, textMuted)) } } diff --git a/cmd/root.go b/cmd/root.go index 2a6aaebb..992312df 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -195,7 +195,7 @@ Run graycode and use /config to set up your first provider.`, registeredProvider if len(candidates) > 0 { // Auto-resume the most recent interrupted session c := candidates[0] - fmt.Printf("Found interrupted session %s (%s, %d msgs)\n", c.SessionID, c.Interruption, c.MessageCount) + fmt.Printf("%s\n", auditTint("Found interrupted session ", warnAmber)+auditTint(c.SessionID, textPrimary)+auditTint(fmt.Sprintf(" (%s, %d msgs)", c.Interruption, c.MessageCount), textMuted)) resumeID = c.SessionID } } @@ -263,7 +263,7 @@ func init() { rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale") rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume") rootCmd.Flags().BoolVar(&startupProfileFlag, "startup-profile", false, "print startup performance profile") - rootCmd.Flags().BoolVarP(&quietFlag, "quiet", "q", false, "suppress non-essential output (spinners, progress, decoration); machine-parseable output only") + rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "suppress non-essential output (spinners, progress, decoration); machine-parseable output only") preflightCmd.Flags().BoolVar(&preflightLiveFlag, "live", false, "verify selected provider connectivity and authentication") preflightCmd.Flags().BoolVar(&preflightJSON, "json", false, "output preflight report as JSON") doctorCmd.Flags().BoolVar(&doctorJSONFlag, "json", false, "output diagnostics as JSON") @@ -449,7 +449,8 @@ Fish: return fmt.Errorf("cannot write completion script: %w", err) } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Installed %s completion to %s\n", shell, path); err != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s completion to %s\n", + auditTint("Installed", doneGreen), shell, auditTint(path, textPrimary)); err != nil { return fmt.Errorf("cannot write completion message: %w", err) } return nil @@ -466,7 +467,20 @@ var updateCmd = &cobra.Command{ if ver == "" { ver = "dev" } - cmd.Println(update.Summary(ver)) + release, err := update.Check(ver) + if err != nil { + cmd.Println(auditTint("Update check failed: "+err.Error(), errorCoral)) + return nil + } + if release == nil { + cmd.Println(auditTint("graycode is up to date ("+ver+")", doneGreen)) + return nil + } + cmd.Println(auditTint("Update available: ", warnAmber) + auditTint(ver+" -> "+release.TagName, textPrimary)) + cmd.Println(auditTint(release.URL, textMuted)) + cmd.Println() + cmd.Println(auditTint("Release notes:", textPrimary)) + cmd.Println(release.Body) return nil }, } @@ -560,9 +574,15 @@ var doctorCmd = &cobra.Command{ return err } if doctorJSONFlag { - cmd.Println(doctorOutput(settings)) + cmd.Println(doctorJSON(settings)) } else { - cmd.Println(doctorReport(settings)) + prog := NewCLIProgress("Doctor", []string{"Running diagnostics"}) + defer prog.Abort() + prog.StartStep(0) + report := doctorReport(settings) + prog.CompleteStep(0) + prog.Done() + cmd.Println(report) } return nil }, @@ -591,7 +611,20 @@ var preflightCmd = &cobra.Command{ ctx, cancel = context.WithTimeout(ctx, limit) defer cancel() } + // Only the live provider verification is slow enough to animate, and + // only when the output is a human report (JSON must stay pure). + animate := preflightLiveFlag && !preflightJSON + var prog *CLIProgress + if animate { + prog = NewCLIProgress("Preflight", []string{"Verifying provider"}) + defer prog.Abort() + prog.StartStep(0) + } r := graycodeconfig.EnginePreflightReportWithSettings(ctx, settings, graycodeconfig.EnginePreflightOptions{VerifyLive: preflightLiveFlag}) + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } if preflightJSON { out, err := json.MarshalIndent(r, "", " ") if err != nil { @@ -611,6 +644,19 @@ var preflightCmd = &cobra.Command{ }, } +// printConfigSetResult renders the result of a successful config write, +// showing a modern old → new transition when the value actually changed. +// Settable keys are non-secret (API keys error out before reaching here), +// so displaying the prior value cannot leak a secret. +func printConfigSetResult(cmd *cobra.Command, key, newVal string, settings graycodeconfig.Settings) { + oldVal, hadOld := graycodeconfig.SettingValue(settings, key) + if hadOld && oldVal != "" && oldVal != newVal { + cmd.Println(auditTint(key, textPrimary) + auditTint(": ", textMuted) + auditTint(oldVal, textMuted) + auditTint(" → ", graycodeColor) + auditTint(newVal, textPrimary) + auditTint(" (updated)", doneGreen)) + return + } + cmd.Println(auditTint("updated ", doneGreen) + auditTint(key, textPrimary)) +} + var configCmd = &cobra.Command{ Use: "config [get|set|provider|model|keys|routing-preview|migrate-deployments]", Short: "Show or update settings", @@ -629,34 +675,54 @@ var configCmd = &cobra.Command{ if !ok { return fmt.Errorf("unsupported setting key %q", args[1]) } - cmd.Println(value) + if value == "" { + cmd.Println(auditTint("(unset)", textMuted)) + } else { + cmd.Println(value) + } return nil case "set": if len(args) < 3 { return fmt.Errorf("usage: graycode config set ") } - if err := graycodeconfig.SetGlobalSetting(args[1], strings.Join(args[2:], " ")); err != nil { + key := args[1] + newVal := strings.Join(args[2:], " ") + settings, err := loadEffectiveSettings() + if err != nil { return err } - cmd.Println("updated", args[1]) + if err := graycodeconfig.SetGlobalSetting(key, newVal); err != nil { + return err + } + printConfigSetResult(cmd, key, newVal, settings) return nil case "provider": if len(args) < 2 { return fmt.Errorf("usage: graycode config provider ") } - if err := graycodeconfig.SetGlobalSetting("provider", strings.Join(args[1:], " ")); err != nil { + newVal := strings.Join(args[1:], " ") + settings, err := loadEffectiveSettings() + if err != nil { return err } - cmd.Println("updated provider") + if err := graycodeconfig.SetGlobalSetting("provider", newVal); err != nil { + return err + } + printConfigSetResult(cmd, "provider", newVal, settings) return nil case "model": if len(args) < 2 { return fmt.Errorf("usage: graycode config model ") } - if err := graycodeconfig.SetGlobalSetting("model", strings.Join(args[1:], " ")); err != nil { + newVal := strings.Join(args[1:], " ") + settings, err := loadEffectiveSettings() + if err != nil { + return err + } + if err := graycodeconfig.SetGlobalSetting("model", newVal); err != nil { return err } - cmd.Println("updated model") + printConfigSetResult(cmd, "model", newVal, settings) return nil case "keys": cmd.Println(apiKeyConfigSummary()) @@ -810,10 +876,23 @@ var contextCmd = &cobra.Command{ Short: "Export project context as a single document for use in any LLM", RunE: func(cmd *cobra.Command, args []string) error { if contextOutput != "" { + var prog *CLIProgress + if !IsQuiet() { + prog = NewCLIProgress("Context", []string{"Building project context"}) + defer prog.Abort() + prog.StartStep(0) + } if err := ExportContextToFile("", contextFocus, contextOutput); err != nil { + if prog != nil { + prog.FailStep(0, err.Error()) + } return err } - cmd.Println("Context exported to", contextOutput) + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } + cmd.Println(auditTint("Context exported to", doneGreen) + " " + auditTint(contextOutput, textPrimary)) return nil } result, err := ExportContext("", contextFocus) @@ -865,8 +944,7 @@ Examples: return err } cmd.Println(note) - cmd.Printf("Resuming session %s (%d messages, %s/%s)\n", - s.ID, len(s.Messages), s.Provider, s.Model) + cmd.Println(auditTint("Resuming session ", textPrimary) + auditTint(s.ID, toolGold) + auditTint(fmt.Sprintf(" (%d messages, %s/%s)", len(s.Messages), s.Provider, s.Model), textMuted)) return resumeRecoveredSession(context.Background(), s.ID) } @@ -875,8 +953,8 @@ Examples: cmd.Println(session.FormatRecoveryCandidates(candidates)) if len(candidates) > 0 { - cmd.Println("Resume with: graycode recover ") - cmd.Println("Or launch TUI with: graycode --recover") + cmd.Println(auditTint("Resume with: graycode recover ", textMuted)) + cmd.Println(auditTint("Or launch TUI with: graycode --recover", textMuted)) } return nil }, diff --git a/cmd/rules.go b/cmd/rules.go index f63b60ce..927f4c84 100644 --- a/cmd/rules.go +++ b/cmd/rules.go @@ -42,13 +42,13 @@ var rulesDetectCmd = &cobra.Command{ } if len(found) == 0 { - cmd.Println("No AI tool rule files detected.") + cmd.Println(auditTint("No AI tool rule files detected.", textMuted)) return nil } - cmd.Println("Detected AI tool rule files:") + cmd.Println(auditTint("Detected AI tool rule files:", textPrimary)) for format, path := range found { - cmd.Println(fmt.Sprintf(" %-12s %s", format, path)) + cmd.Println(fmt.Sprintf(" %s %s", auditTint(fmt.Sprintf("%-12s", format), textMuted), auditTint(path, textPrimary))) } return nil }, @@ -69,7 +69,7 @@ var rulesImportCmd = &cobra.Command{ } if len(imported) == 0 { - cmd.Println(fmt.Sprintf("No rules found in %s format.", rulesImportFrom)) + cmd.Println(auditTint(fmt.Sprintf("No rules found in %s format.", rulesImportFrom), textMuted)) return nil } @@ -78,9 +78,9 @@ var rulesImportCmd = &cobra.Command{ return fmt.Errorf("export to graycode format failed: %w", err) } - cmd.Println(fmt.Sprintf("Imported %d rule(s) from %s to .agents/rules/.", len(imported), rulesImportFrom)) + cmd.Println(auditTint(fmt.Sprintf("Imported %d rule(s) from %s to .agents/rules/.", len(imported), rulesImportFrom), doneGreen)) for _, r := range imported { - cmd.Println(fmt.Sprintf(" - %s", r.Name)) + cmd.Println(auditTint(" - "+r.Name, textPrimary)) } return nil }, @@ -101,7 +101,7 @@ var rulesExportCmd = &cobra.Command{ } if len(graycodeRules) == 0 { - cmd.Println("No graycode rules found in .agents/rules/. Nothing to export.") + cmd.Println(auditTint("No graycode rules found in .agents/rules/. Nothing to export.", textMuted)) return nil } @@ -110,9 +110,9 @@ var rulesExportCmd = &cobra.Command{ return fmt.Errorf("export to %s format failed: %w", rulesExportTo, err) } - cmd.Println(fmt.Sprintf("Exported %d rule(s) to %s format.", len(graycodeRules), rulesExportTo)) + cmd.Println(auditTint(fmt.Sprintf("Exported %d rule(s) to %s format.", len(graycodeRules), rulesExportTo), doneGreen)) for _, r := range graycodeRules { - cmd.Println(fmt.Sprintf(" - %s", r.Name)) + cmd.Println(auditTint(" - "+r.Name, textPrimary)) } return nil }, diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 75943497..6a3777c2 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -52,7 +52,7 @@ var sandboxDiffCmd = &cobra.Command{ sb := getSandbox() d := sb.Diff() if d == "" { - cmd.Println("No pending changes.") + cmd.Println(auditTint("No pending changes.", textMuted)) return } fmt.Print(d) @@ -65,21 +65,21 @@ var sandboxApplyCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { sb := getSandbox() if !sb.HasChanges() { - cmd.Println("No pending changes to apply.") + cmd.Println(auditTint("No pending changes to apply.", textMuted)) return nil } stats := sb.Stats() - cmd.Println(fmt.Sprintf("Applying %d change(s): +%d -%d lines, %d created, %d modified, %d deleted", + cmd.Println(auditTint(fmt.Sprintf("Applying %d change(s): +%d -%d lines, %d created, %d modified, %d deleted", stats.FilesCreated+stats.FilesModified+stats.FilesDeleted, stats.LinesAdded, stats.LinesRemoved, - stats.FilesCreated, stats.FilesModified, stats.FilesDeleted)) + stats.FilesCreated, stats.FilesModified, stats.FilesDeleted), textPrimary)) if err := sb.Apply(); err != nil { return fmt.Errorf("apply failed: %w", err) } - cmd.Println("All changes applied.") + cmd.Println(auditTint("All changes applied.", doneGreen)) return nil }, } @@ -90,11 +90,11 @@ var sandboxDiscardCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { sb := getSandbox() if !sb.HasChanges() { - cmd.Println("No pending changes to discard.") + cmd.Println(auditTint("No pending changes to discard.", textMuted)) return } sb.Discard() - cmd.Println("All pending changes discarded.") + cmd.Println(auditTint("All pending changes discarded.", doneGreen)) }, } diff --git a/cmd/search.go b/cmd/search.go index 489a28d0..2b8fee5e 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -44,7 +44,7 @@ func runSearch(_ *cobra.Command, args []string) error { } if len(results) == 0 { - fmt.Printf("No results for %q\n", query) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("No results for %q", query), textMuted)) return nil } diff --git a/cmd/securitylog_cmd.go b/cmd/securitylog_cmd.go index 3bd32ea9..aa2feda3 100644 --- a/cmd/securitylog_cmd.go +++ b/cmd/securitylog_cmd.go @@ -47,7 +47,10 @@ var securitylogVerifyCmd = &cobra.Command{ if err != nil { return fmt.Errorf("security log verification FAILED: %w", err) } - cmd.Printf("security event log OK: %d entries verified (%s)\n", count, dir) + cmd.Printf("%s %s (%s)\n", + auditTint("security event log OK:", doneGreen), + auditTint(fmt.Sprintf("%d entries verified", count), textPrimary), + auditTint(dir, textMuted)) return nil }, } @@ -83,8 +86,8 @@ func runSecuritylogShow(cmd *cobra.Command, limit int, asJSON bool) error { } if len(events) == 0 { - cmd.Println("No security events recorded yet.") - cmd.Printf("Log location: %s\n", dir) + cmd.Println(auditTint("No security events recorded yet.", textMuted)) + cmd.Printf("%s\n", auditTint("Log location: "+dir, textMuted)) return nil } @@ -92,17 +95,24 @@ func runSecuritylogShow(cmd *cobra.Command, limit int, asJSON bool) error { if limit > 0 && len(events) > limit { start = len(events) - limit } - cmd.Printf("Security event log: %d event(s) at %s\n", len(events), dir) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Security event log: %d event(s) at %s", len(events), dir), textPrimary)) if start > 0 { - cmd.Printf("Showing the most recent %d:\n", len(events)-start) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Showing the most recent %d:", len(events)-start), textMuted)) } for _, ev := range events[start:] { + sevColor := infoSky + switch ev.Severity { + case securitylog.SeverityCritical: + sevColor = errorCoral + case securitylog.SeverityWarning: + sevColor = warnAmber + } cmd.Printf( - "%s %-8s %-20s %s\n", - ev.Timestamp.Format(time.RFC3339), - ev.Severity, - ev.Type, - truncateWithEllipsis(ev.Detail, 60), + "%s %s %s %s\n", + auditTint(ev.Timestamp.Format(time.RFC3339), textMuted), + auditTint(fmt.Sprintf("%-8s", ev.Severity), sevColor), + auditTint(fmt.Sprintf("%-20s", ev.Type), textPrimary), + auditTint(truncateWithEllipsis(ev.Detail, 60), textMuted), ) } return nil diff --git a/cmd/session_export.go b/cmd/session_export.go index b822dc28..ab43a18f 100644 --- a/cmd/session_export.go +++ b/cmd/session_export.go @@ -44,7 +44,7 @@ var sessionExportCmd = &cobra.Command{ if err := os.WriteFile(exportOutput, data, 0o600); err != nil { return fmt.Errorf("write output file: %w", err) } - cmd.Printf("Session exported to %s\n", exportOutput) + cmd.Printf("%s\n", auditTint("Session exported to ", doneGreen)+auditTint(exportOutput, textPrimary)) return nil }, } diff --git a/cmd/session_migrate.go b/cmd/session_migrate.go index 7beebf09..452949ee 100644 --- a/cmd/session_migrate.go +++ b/cmd/session_migrate.go @@ -62,9 +62,9 @@ func runSessionMigrate(cmd *cobra.Command, args []string) error { } if res.FromVersion >= res.ToVersion { - cmd.Println(fmt.Sprintf("Session %s is already at the current format (v%d).", res.ID, res.ToVersion)) + cmd.Println(auditTint(fmt.Sprintf("Session %s is already at the current format (v%d).", res.ID, res.ToVersion), textMuted)) } else { - cmd.Println(fmt.Sprintf("Migrated session %s from v%d to v%d (%d bytes).", res.ID, res.FromVersion, res.ToVersion, res.SizeBytes)) + cmd.Println(auditTint("Migrated session ", doneGreen) + auditTint(res.ID, textPrimary) + auditTint(fmt.Sprintf(" from v%d to v%d (%d bytes).", res.FromVersion, res.ToVersion, res.SizeBytes), textMuted)) } return nil } diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 49646cea..f2bff432 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -60,7 +60,7 @@ var skillsSearchCmd = &cobra.Command{ return nil } if len(results) == 0 { - fmt.Println("No skills found.") + fmt.Println(auditTint("No skills found.", textMuted)) return nil } for _, e := range results { @@ -99,7 +99,7 @@ var skillsRemoveCmd = &cobra.Command{ if err := plugin.Remove(args[0]); err != nil { return err } - fmt.Printf("Removed skill %q.\n", args[0]) + fmt.Printf("%s\n", auditTint("Removed skill "+args[0]+".", textPrimary)) return nil }, } @@ -118,11 +118,11 @@ var skillsInfoCmd = &cobra.Command{ if err != nil { return err } - fmt.Printf("Skill: %s (not installed)\n", entry.Name) + fmt.Printf("%s %s\n", auditTint("Skill:", textMuted), auditTint(entry.Name, textPrimary)+auditTint(" (not installed)", textMuted)) if entry.Description != "" { - fmt.Printf("Description: %s\n", entry.Description) + fmt.Printf("%s %s\n", auditTint("Description:", textMuted), auditTint(entry.Description, textPrimary)) } - fmt.Printf("Repo: %s\nInstalls: %d\n", entry.Repo, entry.Installs) + fmt.Printf("%s %s\n%s %d\n", auditTint("Repo:", textMuted), auditTint(entry.Repo, textPrimary), auditTint("Installs:", textMuted), entry.Installs) return nil }, } @@ -143,7 +143,7 @@ var skillsTrendingCmd = &cobra.Command{ return err } for i, e := range results { - fmt.Printf("%d. %s", i+1, strings.TrimLeft(plugin.FormatSkillEntry(e), " ")) + fmt.Printf("%s. %s", auditTint(fmt.Sprintf("%d", i+1), textMuted), strings.TrimLeft(plugin.FormatSkillEntry(e), " ")) } return nil }, diff --git a/cmd/skills_curator_cmd.go b/cmd/skills_curator_cmd.go index 858f2c36..af0e24e6 100644 --- a/cmd/skills_curator_cmd.go +++ b/cmd/skills_curator_cmd.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "image/color" "path/filepath" "github.com/GrayCodeAI/graycode-cli/internal/intelligence/skillcurator" @@ -41,7 +42,7 @@ never-used skills, and pinned skills are left alone.`, return err } if len(skills) == 0 { - fmt.Println("No curated skills found.") + fmt.Println(auditTint("No curated skills found.", textMuted)) return nil } for _, s := range skills { @@ -49,7 +50,15 @@ never-used skills, and pinned skills are left alone.`, if !s.LastUsed.IsZero() { last = s.LastUsed.Format("2006-01-02") } - fmt.Printf("%-24s %-9s uses=%-4d last=%s\n", s.Name, s.Status, s.UseCount, last) + var statusColor color.Color = textMuted + if s.Status == "pinned" { + statusColor = doneGreen + } + fmt.Printf("%s %s %s %s\n", + auditTint(fmt.Sprintf("%-24s", s.Name), textPrimary), + auditTint(fmt.Sprintf("%-9s", s.Status), statusColor), + auditTint(fmt.Sprintf("uses=%-4d", s.UseCount), textMuted), + auditTint("last="+last, textMuted)) } return nil case "run": @@ -58,12 +67,12 @@ never-used skills, and pinned skills are left alone.`, return err } if len(archived) == 0 { - fmt.Println("Review complete: nothing to archive.") + fmt.Println(auditTint("Review complete: nothing to archive.", textMuted)) return nil } - fmt.Printf("Archived %d cold skill(s):\n", len(archived)) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Archived %d cold skill(s):", len(archived)), doneGreen)) for _, n := range archived { - fmt.Printf(" - %s (recoverable from .archive/)\n", n) + fmt.Printf("%s\n", auditTint(" - "+n+" (recoverable from .archive/)", textMuted)) } return nil case "pin": diff --git a/cmd/snapshot_cmd.go b/cmd/snapshot_cmd.go index 904197bd..86c63314 100644 --- a/cmd/snapshot_cmd.go +++ b/cmd/snapshot_cmd.go @@ -143,7 +143,7 @@ var snapshotListCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No snapshots yet.") + fmt.Println(auditTint("No snapshots yet.", textMuted)) } return nil } @@ -156,7 +156,7 @@ var snapshotListCmd = &cobra.Command{ return nil } for _, p := range history { - fmt.Printf("%s %s %s\n", p.Hash, p.Timestamp.Format("2006-01-02 15:04:05"), p.Message) + fmt.Printf("%s %s %s\n", auditTint(p.Hash, textPrimary), auditTint(p.Timestamp.Format("2006-01-02 15:04:05"), textMuted), auditTint(p.Message, textPrimary)) } return nil }, @@ -175,7 +175,7 @@ var snapshotRestoreCmd = &cobra.Command{ if err := t.Restore(args[0]); err != nil { return err } - fmt.Printf("Restored to snapshot %s\n", args[0]) + fmt.Printf("%s\n", auditTint("Restored to snapshot ", doneGreen)+auditTint(args[0], textPrimary)) return nil }, } @@ -195,7 +195,7 @@ var snapshotDiffCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No snapshots to diff against.") + fmt.Println(auditTint("No snapshots to diff against.", textMuted)) } return nil } @@ -207,7 +207,7 @@ var snapshotDiffCmd = &cobra.Command{ if snapshotJSON { fmt.Println("[]") } else { - fmt.Println("No changes.") + fmt.Println(auditTint("No changes.", textMuted)) } return nil } @@ -220,7 +220,14 @@ var snapshotDiffCmd = &cobra.Command{ return nil } for _, d := range diffs { - fmt.Printf("%s +%d -%d %s\n", d.Status, d.Additions, d.Deletions, d.File) + statusColor := warnAmber + switch d.Status { + case "added": + statusColor = doneGreen + case "deleted": + statusColor = errorCoral + } + fmt.Printf("%s %s %s\n", auditTint(d.Status, statusColor), auditTint(fmt.Sprintf("+%d -%d", d.Additions, d.Deletions), textMuted), auditTint(d.File, textPrimary)) } return nil }, diff --git a/cmd/stats.go b/cmd/stats.go index a1df3310..329890e7 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -84,8 +84,8 @@ func runStats(cmd *cobra.Command, args []string) error { } if len(filtered) == 0 { - cmd.Println("No session data found for the specified time period.") - cmd.Println("Sessions are recorded automatically when you use graycode.") + cmd.Println(auditTint("No session data found for the specified time period.", textMuted)) + cmd.Println(auditTint("Sessions are recorded automatically when you use graycode.", textMuted)) return nil } @@ -180,30 +180,30 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { _, _ = fmt.Fprintf(w, "\n") _, _ = fmt.Fprintf(w, "══════════════════════════════════════════════════\n") - _, _ = fmt.Fprintf(w, " Graycode Usage Statistics (%s)\n", out.Period) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("Graycode Usage Statistics (%s)", out.Period), graycodeColor)) _, _ = fmt.Fprintf(w, "══════════════════════════════════════════════════\n") // Overview section _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Overview ───\n") - _, _ = fmt.Fprintf(w, " Sessions: %d\n", out.TotalSessions) - _, _ = fmt.Fprintf(w, " Messages: %d\n", out.TotalMessages) - _, _ = fmt.Fprintf(w, " Tool calls: %d\n", out.TotalToolCalls) - _, _ = fmt.Fprintf(w, " Active days: %d\n", out.ActiveDays) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Overview", infoSky)) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Sessions:", textMuted), out.TotalSessions) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Messages:", textMuted), out.TotalMessages) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Tool calls:", textMuted), out.TotalToolCalls) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint("Active days:", textMuted), out.ActiveDays) // Cost section _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Cost ───\n") - _, _ = fmt.Fprintf(w, " Total cost: $%.4f\n", out.TotalCostUSD) - _, _ = fmt.Fprintf(w, " Avg cost/session: $%.4f\n", out.AvgCostPerSession) - _, _ = fmt.Fprintf(w, " Avg cost/day: $%.4f\n", out.AvgCostPerDay) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Cost", infoSky)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Total cost:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.TotalCostUSD), costViolet)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Avg cost/session:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.AvgCostPerSession), costViolet)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("Avg cost/day:", textMuted), auditTint(fmt.Sprintf("$%.4f", out.AvgCostPerDay), costViolet)) // Models section if statsModels && len(out.Models) > 0 { _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Models ───\n") - _, _ = fmt.Fprintf(w, " %-30s %8s %10s\n", "MODEL", "REQUESTS", "COST") - _, _ = fmt.Fprintf(w, " %-30s %8s %10s\n", strings.Repeat("─", 30), strings.Repeat("─", 8), strings.Repeat("─", 10)) + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Models", infoSky)) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("%-30s %8s %10s", "MODEL", "REQUESTS", "COST"), textMuted)) + _, _ = fmt.Fprintf(w, " %s\n", auditTint(fmt.Sprintf("%-30s %8s %10s", strings.Repeat("─", 30), strings.Repeat("─", 8), strings.Repeat("─", 10)), textMuted)) // Sort models by cost descending type modelEntry struct { @@ -219,14 +219,14 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { }) for _, m := range models { - _, _ = fmt.Fprintf(w, " %-30s %8d %10s\n", m.name, m.stat.Requests, fmt.Sprintf("$%.4f", m.stat.CostUSD)) + _, _ = fmt.Fprintf(w, " %-30s %8d %10s\n", m.name, m.stat.Requests, auditTint(fmt.Sprintf("$%.4f", m.stat.CostUSD), costViolet)) } } // Top Tools section if len(out.TopTools) > 0 { _, _ = fmt.Fprintf(w, "\n") - _, _ = fmt.Fprintf(w, "─── Top Tools ───\n") + _, _ = fmt.Fprintf(w, "─── %s ───\n", auditTint("Top Tools", infoSky)) limit := statsTop if limit > len(out.TopTools) { @@ -249,7 +249,7 @@ func printStatsText(cmd *cobra.Command, out *statsOutput) { barLen = 1 } bar := strings.Repeat("█", barLen) - _, _ = fmt.Fprintf(w, " %-20s %s %d\n", t.Name, bar, t.Count) + _, _ = fmt.Fprintf(w, " %-20s %s %d\n", t.Name, auditTint(bar, successTeal), t.Count) } } diff --git a/cmd/status_snapshot.go b/cmd/status_snapshot.go index 882d9f78..b3a3bcae 100644 --- a/cmd/status_snapshot.go +++ b/cmd/status_snapshot.go @@ -85,11 +85,23 @@ func formatStatusSnapshot(s status.Snapshot) string { if s.Permission.SandboxBackend != "" { backend = " (" + s.Permission.SandboxBackend + ")" } - return fmt.Sprintf("Graycode status\nSchema: %s\nWorkspace: %s\nGit branch: %s\nProvider: %s\nModel: %s\nAutonomy tier: %s\nSandbox: %s%s\nPermission rules: %d\nMCP: %d configured (%s)\nSkills: %d (%s)\nSecrets redacted: %t\n", - s.SchemaVersion, s.Workspace, s.GitBranch, s.Provider, s.Model, - s.Permission.AutonomyTier, s.Permission.SandboxMode, backend, - s.Permission.EffectiveRules, s.MCP.Configured, s.MCP.State, - s.Skills.Configured, s.Skills.State, s.Permission.SecretRedacted) + line := func(label, val string) string { + return fmt.Sprintf("%s: %s\n", auditTint(label, textMuted), auditTint(val, textPrimary)) + } + var b strings.Builder + b.WriteString(auditTint("Graycode status", graycodeColor) + "\n") + b.WriteString(line("Schema", s.SchemaVersion)) + b.WriteString(line("Workspace", s.Workspace)) + b.WriteString(line("Git branch", s.GitBranch)) + b.WriteString(line("Provider", s.Provider)) + b.WriteString(line("Model", s.Model)) + b.WriteString(line("Autonomy tier", s.Permission.AutonomyTier)) + b.WriteString(line("Sandbox", s.Permission.SandboxMode+backend)) + b.WriteString(line("Permission rules", fmt.Sprintf("%d", s.Permission.EffectiveRules))) + b.WriteString(line("MCP", fmt.Sprintf("%d configured (%s)", s.MCP.Configured, s.MCP.State))) + b.WriteString(line("Skills", fmt.Sprintf("%d (%s)", s.Skills.Configured, s.Skills.State))) + b.WriteString(line("Secrets redacted", fmt.Sprintf("%t", s.Permission.SecretRedacted))) + return strings.TrimRight(b.String(), "\n") } func init() { diff --git a/cmd/swift_report.go b/cmd/swift_report.go index bd24e687..ea184073 100644 --- a/cmd/swift_report.go +++ b/cmd/swift_report.go @@ -87,9 +87,9 @@ func runSwiftReport(cmd *cobra.Command, _ []string) error { // Mirror fx: attempt clipboard copy; on failure print a review-and-redact // notice pointing at the saved path. if !swiftReportNoCopy && swift.TryClipboard(swift.Build(&s)) { - cmd.Println("Swift report copied to clipboard. Saved at " + path + " (review and redact before sharing).") + cmd.Println(auditTint("Swift report copied to clipboard. ", doneGreen) + auditTint("Saved at "+path, textPrimary) + auditTint(" (review and redact before sharing).", textMuted)) } else { - cmd.Println("Swift saved at " + path + ". Review and redact it before sharing.") + cmd.Println(auditTint("Swift saved at ", doneGreen) + auditTint(path, textPrimary) + auditTint(". Review and redact it before sharing.", textMuted)) } return nil } diff --git a/cmd/tape.go b/cmd/tape.go index f42a74d9..d2234335 100644 --- a/cmd/tape.go +++ b/cmd/tape.go @@ -68,17 +68,17 @@ func runTapeStatus(cmd *cobra.Command, args []string) error { } w := cmd.OutOrStdout() - _, _ = fmt.Fprintf(w, "path: %s\n", st.Path) - _, _ = fmt.Fprintf(w, "size: %d bytes\n", st.Size) - _, _ = fmt.Fprintf(w, "terminal: %dx%d\n", st.Cols, st.Rows) - _, _ = fmt.Fprintf(w, "captured: %s\n", time.UnixMilli(st.EpochMS).UTC().Format(time.RFC3339)) - _, _ = fmt.Fprintf(w, "version: %s\n", st.Version) - _, _ = fmt.Fprintf(w, "frames: %d\n", st.FrameCount) - _, _ = fmt.Fprintf(w, "stdout: %d bytes\n", st.StdoutBytes) - _, _ = fmt.Fprintf(w, "duration: %s\n", tapeDuration(st.DurationMS)) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("path:", textMuted), auditTint(st.Path, textPrimary)) + _, _ = fmt.Fprintf(w, "%s %d bytes\n", auditTint("size:", textMuted), st.Size) + _, _ = fmt.Fprintf(w, "%s %dx%d\n", auditTint("terminal:", textMuted), st.Cols, st.Rows) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("captured:", textMuted), auditTint(time.UnixMilli(st.EpochMS).UTC().Format(time.RFC3339), textPrimary)) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("version:", textMuted), auditTint(st.Version, textPrimary)) + _, _ = fmt.Fprintf(w, "%s %d\n", auditTint("frames:", textMuted), st.FrameCount) + _, _ = fmt.Fprintf(w, "%s %d bytes\n", auditTint("stdout:", textMuted), st.StdoutBytes) + _, _ = fmt.Fprintf(w, "%s %s\n", auditTint("duration:", textMuted), auditTint(tapeDuration(st.DurationMS), textPrimary)) for _, k := range []string{"stdout", "stdin", "resize", "sigint", "marker"} { if n := st.Kinds[k]; n > 0 { - _, _ = fmt.Fprintf(w, " %-7s %d\n", k+":", n) + _, _ = fmt.Fprintf(w, " %s %d\n", auditTint(k+":", textMuted), n) } } return nil @@ -99,10 +99,10 @@ func runTapeCommit(cmd *cobra.Command, args []string) error { return err } w := cmd.OutOrStdout() - _, _ = fmt.Fprintf(w, "committed %s\n", c.Name) - _, _ = fmt.Fprintf(w, " id: %s\n", c.CommitID) - _, _ = fmt.Fprintf(w, " tape: %s\n", c.Path) - _, _ = fmt.Fprintf(w, " meta: %s\n", c.MetaPath) + _, _ = fmt.Fprintf(w, "%s\n", auditTint("committed "+c.Name, doneGreen)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("id:", textMuted), auditTint(c.CommitID, textPrimary)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("tape:", textMuted), auditTint(c.Path, textPrimary)) + _, _ = fmt.Fprintf(w, " %s %s\n", auditTint("meta:", textMuted), auditTint(c.MetaPath, textPrimary)) return nil } diff --git a/cmd/taste.go b/cmd/taste.go index 775c4d0e..29df55f7 100644 --- a/cmd/taste.go +++ b/cmd/taste.go @@ -104,8 +104,9 @@ func runTasteShow(_ *cobra.Command, _ []string) error { // Also show prompt context if anything is learned. ctx := profile.ToPromptContext() if ctx != "" { - fmt.Println("\nSystem prompt fragment that would be injected:") - fmt.Println(strings.Repeat("-", 50)) + fmt.Println() + fmt.Println(auditTint("System prompt fragment that would be injected:", textPrimary)) + fmt.Println(auditTint(strings.Repeat("-", 50), textMuted)) fmt.Println(ctx) } @@ -128,7 +129,7 @@ func runTastePush(_ *cobra.Command, _ []string) error { if err := os.WriteFile(tasteFile, data, 0o600); err != nil { return fmt.Errorf("write file: %w", err) } - fmt.Printf("Taste profile exported to %s\n", tasteFile) + fmt.Printf("%s\n", auditTint("Taste profile exported to ", doneGreen)+auditTint(tasteFile, textPrimary)) } else { fmt.Println(string(data)) } @@ -159,7 +160,7 @@ func runTastePull(_ *cobra.Command, args []string) error { return fmt.Errorf("import profile: %w", err) } - fmt.Println("Taste profile imported successfully.") + fmt.Println(auditTint("Taste profile imported successfully.", doneGreen)) return nil } @@ -174,7 +175,7 @@ func runTasteReset(_ *cobra.Command, _ []string) error { return fmt.Errorf("reset profile: %w", err) } - fmt.Printf("Taste profile for %q has been reset.\n", projectID) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("Taste profile for %q has been reset.", projectID), textPrimary)) return nil } diff --git a/cmd/toolset_cmd.go b/cmd/toolset_cmd.go index 2b263b10..dd98a3d6 100644 --- a/cmd/toolset_cmd.go +++ b/cmd/toolset_cmd.go @@ -28,7 +28,7 @@ transitively (cycle-safe) and de-duplicates.`, return err } if len(args) == 0 { - fmt.Println("Available toolsets: " + strings.Join(reg.Names(), ", ")) + fmt.Println(auditTint("Available toolsets: ", textPrimary) + auditTint(strings.Join(reg.Names(), ", "), textMuted)) return nil } name := args[0] diff --git a/cmd/trust.go b/cmd/trust.go index ac29c935..9b09fe9b 100644 --- a/cmd/trust.go +++ b/cmd/trust.go @@ -46,7 +46,7 @@ var trustAddCmd = &cobra.Command{ if err := s.Trust(path, reason); err != nil { return err } - cmd.Printf("Trusted %s\n", path) + cmd.Printf("%s\n", auditTint("Trusted ", doneGreen)+auditTint(path, textPrimary)) return nil }, } @@ -73,7 +73,7 @@ var trustRemoveCmd = &cobra.Command{ if err := s.Untrust(path); err != nil { return err } - cmd.Printf("Removed trust for %s\n", path) + cmd.Printf("%s\n", auditTint("Removed trust for ", textPrimary)+auditTint(path, textMuted)) return nil }, } @@ -93,8 +93,8 @@ var trustListCmd = &cobra.Command{ if trustListJSON { fmt.Println("[]") } else { - cmd.Println("No trusted directories.") - cmd.Printf("Folder trust enforcement: %v (GRAYCODE_Y0_FOLDER_TRUST)\n", flags.FolderTrust()) + cmd.Println(auditTint("No trusted directories.", textMuted)) + cmd.Printf("%s\n", auditTint(fmt.Sprintf("Folder trust enforcement: %v (GRAYCODE_Y0_FOLDER_TRUST)", flags.FolderTrust()), textMuted)) } return nil } @@ -143,9 +143,13 @@ var trustCheckCmd = &cobra.Command{ } enforced := flags.FolderTrust() trusted := s.IsTrusted(path) - cmd.Printf("path: %s\n", path) - cmd.Printf("trusted: %v\n", trusted) - cmd.Printf("enforcement: %v\n", enforced) + cmd.Printf("%s %s\n", auditTint("path:", textMuted), auditTint(path, textPrimary)) + trustedColor := doneGreen + if !trusted { + trustedColor = errorCoral + } + cmd.Printf("%s %s\n", auditTint("trusted:", textMuted), auditTint(fmt.Sprintf("%v", trusted), trustedColor)) + cmd.Printf("%s %s\n", auditTint("enforcement:", textMuted), auditTint(fmt.Sprintf("%v", enforced), textPrimary)) if enforced && !trusted { return fmt.Errorf("not trusted") } diff --git a/cmd/usage.go b/cmd/usage.go index ff0fbbd0..9c218ea9 100644 --- a/cmd/usage.go +++ b/cmd/usage.go @@ -60,20 +60,24 @@ func runUsage(cmd *cobra.Command, _ []string) error { } if sum.Generations == 0 { - cmd.Println("No usage recorded in the last " + usagePeriod + ".") - cmd.Println("The ledger lives at " + usage.LedgerPath()) + cmd.Println(auditTint("No usage recorded in the last "+usagePeriod+".", textMuted)) + cmd.Println(auditTint("The ledger lives at "+usage.LedgerPath(), textMuted)) return nil } - cmd.Println(fmt.Sprintf("Usage (last %s)", usagePeriod)) - cmd.Println(fmt.Sprintf("%-28s %10s %10s %8s %12s", "model", "in", "out", "gen", "cost")) + cmd.Println(auditTint(fmt.Sprintf("Usage (last %s)", usagePeriod), graycodeColor)) + cmd.Println(auditTint(fmt.Sprintf("%-28s %10s %10s %8s %12s", "model", "in", "out", "gen", "cost"), textMuted)) for _, m := range sum.ByModel { - cmd.Println(fmt.Sprintf("%-28s %10d %10d %8d %10.4f$", - truncateModel(m.Model), m.InputTokens, m.OutputTokens, m.Generations, m.TotalCostUSD)) + // Pad the cost to its column width first, then colorize, so the + // zero-width ANSI escapes don't break the fixed-width alignment. + cost := auditTint(fmt.Sprintf("%10.4f$", m.TotalCostUSD), costViolet) + cmd.Println(fmt.Sprintf("%-28s %10d %10d %8d %s", + truncateModel(m.Model), m.InputTokens, m.OutputTokens, m.Generations, cost)) } - cmd.Println("------------------------------------------------------------") - cmd.Println(fmt.Sprintf("%-28s %10d %10s %8d %10.4f$", - "total", sum.TotalTokens, "", sum.Generations, sum.TotalCostUSD)) + cmd.Println(auditTint("------------------------------------------------------------", textMuted)) + cmd.Println(fmt.Sprintf("%-28s %10d %10s %8d %s", + auditTint("total", textPrimary), sum.TotalTokens, "", sum.Generations, + auditTint(fmt.Sprintf("%10.4f$", sum.TotalCostUSD), costViolet))) return nil } diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index 1d4b7a89..694bd468 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -1,10 +1,12 @@ package cmd import ( + "context" "fmt" "os" "os/exec" "strings" + "time" "github.com/GrayCodeAI/graycode-cli/internal/governance" "github.com/GrayCodeAI/graycode-cli/internal/securitylog" @@ -24,42 +26,63 @@ var verifyCmd = &cobra.Command{ Exits non-zero on the first failed check.`, RunE: func(cmd *cobra.Command, args []string) error { ok := true + start := time.Now() + // Themed markers (padded to a fixed width so colorized output keeps + // its column alignment; plain when piped via ShouldColor). + okMark := auditTint("[OK] ", doneGreen) + failMark := auditTint("[FAIL] ", errorCoral) + skipMark := auditTint("[SKIP] ", textMuted) // 1. Security event log chain integrity. dir := securitylog.DefaultDir() count, err := securitylog.Verify(dir) if err != nil { ok = false - cmd.Printf("[FAIL] security event log: %v\n", err) + cmd.Printf("%ssecurity event log: %v\n", failMark, err) } else { - cmd.Printf("[OK] security event log: %d entries verified (%s)\n", count, dir) + cmd.Printf("%ssecurity event log: %d entries verified (%s)\n", okMark, count, dir) } // 2. Managed governance policy validity (only when installed). policyPath := governance.ManagedPolicyPath() if _, statErr := os.Stat(policyPath); statErr != nil { - cmd.Printf("[SKIP] governance policy: not installed (%s)\n", policyPath) + cmd.Printf("%sgovernance policy: not installed (%s)\n", skipMark, policyPath) } else if _, err := governance.LoadLayer("policy", policyPath); err != nil { ok = false - cmd.Printf("[FAIL] governance policy: %v\n", err) + cmd.Printf("%sgovernance policy: %v\n", failMark, err) } else { - cmd.Printf("[OK] governance policy: valid (%s)\n", policyPath) + cmd.Printf("%sgovernance policy: valid (%s)\n", okMark, policyPath) } // 3. Project test/verify checks discovered from the workspace. - for _, c := range runWorkspaceChecks() { + // Running them can be slow (actual test/verify commands), so show a + // TTY-only animated indicator while they execute; the structured + // [OK]/[FAIL] results are printed only after the animation clears. + checks, detectErr := testrunner.Detect(".") + var prog *CLIProgress + if detectErr == nil && len(checks) > 0 && stdoutIsTerminal() { + prog = NewCLIProgress("Verify", []string{fmt.Sprintf("Running %d project checks", len(checks))}) + defer prog.Abort() + prog.StartStep(0) + } + results := runWorkspaceChecks() + if prog != nil { + prog.CompleteStep(0) + prog.Done() + } + for _, c := range results { if c.Err != nil { ok = false - cmd.Printf("[FAIL] %s: %v\n", c.Name, c.Err) + cmd.Printf("%s%s: %v\n", failMark, c.Name, c.Err) continue } - cmd.Printf("[OK] %s: %s\n", c.Name, c.Detail) + cmd.Printf("%s%s: %s\n", okMark, c.Name, c.Detail) } if !ok { return fmt.Errorf("verification failed — see messages above") } - cmd.Println("verification passed") + cmd.Println(auditTint("verification passed", doneGreen) + auditTint(" in "+time.Since(start).Round(time.Millisecond).String(), textMuted)) return nil }, } @@ -81,11 +104,20 @@ func runWorkspaceChecks() []workspaceCheckResult { } var results []workspaceCheckResult for _, c := range checks { - run := exec.Command(c.Command[0], c.Command[1:]...) // #nosec G204 -- discovered from project manifests + // Guard against a hanging project test/verify command: cap each check + // at 10 minutes and report a timed-out check as a failure with a clear + // message instead of blocking verify forever. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + run := exec.CommandContext(ctx, c.Command[0], c.Command[1:]...) // #nosec G204 -- discovered from project manifests var stdout, stderr strings.Builder run.Stdout = &stdout run.Stderr = &stderr runErr := run.Run() + cancel() + if ctx.Err() == context.DeadlineExceeded { + results = append(results, workspaceCheckResult{Name: c.Name, Err: fmt.Errorf("timed out after 10m: %s", strings.TrimSpace(stderr.String()))}) + continue + } summary := testrunner.ParseSummary(c, stdout.String(), stderr.String()) if runErr != nil && summary == nil { results = append(results, workspaceCheckResult{Name: c.Name, Err: fmt.Errorf("%w: %s", runErr, strings.TrimSpace(stderr.String()))}) diff --git a/cmd/version_display.go b/cmd/version_display.go index af49145e..75405ed9 100644 --- a/cmd/version_display.go +++ b/cmd/version_display.go @@ -13,9 +13,9 @@ func versionLine() string { if ver != "" && !strings.HasPrefix(ver, "v") && !strings.HasPrefix(ver, "V") { ver = "v" + ver } - line := "graycode " + ver + line := auditTint("graycode", textPrimary) + " " + auditTint(ver, graycodeColor) if d := strings.TrimSpace(buildDate); d != "" && d != "unknown" { - line += " (built " + d + ")" + line += auditTint(" (built "+d+")", textMuted) } return line } diff --git a/cmd/vibe.go b/cmd/vibe.go index 48c20f5b..600f2c03 100644 --- a/cmd/vibe.go +++ b/cmd/vibe.go @@ -106,7 +106,7 @@ func VibeLoop(ctx context.Context, sess *engine.Session, prompt string, config V // Step 3: Run the test/build command if configured if !config.AutoRun || config.RunCommand == "" { - fmt.Printf("[vibe] iteration %d complete (no run command configured)\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d complete (no run command configured)", i+1), textPrimary)) return nil } @@ -114,12 +114,12 @@ func VibeLoop(ctx context.Context, sess *engine.Session, prompt string, config V // Step 4: If passes, we're done if runErr == nil { - fmt.Printf("[vibe] iteration %d: all good\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d: all good", i+1), doneGreen)) return nil } // Step 5: If fails, send error back to LLM for fixing - fmt.Printf("[vibe] iteration %d: command failed, asking LLM to fix...\n", i+1) + fmt.Printf("%s\n", auditTint(fmt.Sprintf("[vibe] iteration %d: command failed, asking LLM to fix...", i+1), warnAmber)) currentPrompt = fmt.Sprintf( "The command `%s` failed with the following output:\n\n```\n%s\n```\n\nPlease fix the issues and try again.", config.RunCommand, output, diff --git a/internal/config/catalog_health.go b/internal/config/catalog_health.go index 92735cde..3c13c5db 100644 --- a/internal/config/catalog_health.go +++ b/internal/config/catalog_health.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) var ( @@ -79,21 +81,21 @@ func catalogHealthReportUncached(ctx context.Context) CatalogHealth { // FormatCatalogHealth returns human-readable catalog status for graycode doctor. func FormatCatalogHealth(h CatalogHealth) string { var b strings.Builder - b.WriteString("Model catalog (graycode-router):\n") - b.WriteString(fmt.Sprintf(" path: %s\n", h.CachePath)) + b.WriteString(theme.Tint("Model catalog (graycode-router):", theme.ReportInfo) + "\n") + b.WriteString(" " + theme.Tint("path:", theme.ReportMuted) + " " + theme.Tint(h.CachePath, theme.ReportInfo) + "\n") if h.Error != "" { - b.WriteString(fmt.Sprintf(" status: %s\n", h.Error)) + b.WriteString(" " + theme.Tint("status:", theme.ReportMuted) + " " + theme.Tint(h.Error, theme.ReportError) + "\n") return strings.TrimRight(b.String(), "\n") } - b.WriteString(fmt.Sprintf(" modified: %s (%d bytes)\n", h.Modified.UTC().Format(time.RFC3339), h.SizeBytes)) + b.WriteString(" " + theme.Tint("modified:", theme.ReportMuted) + " " + theme.Tint(h.Modified.UTC().Format(time.RFC3339), theme.ReportInfo) + fmt.Sprintf(" (%d bytes)", h.SizeBytes) + "\n") if h.Source != "" { - b.WriteString(fmt.Sprintf(" source: %s\n", h.Source)) + b.WriteString(" " + theme.Tint("source:", theme.ReportMuted) + " " + theme.Tint(h.Source, theme.ReportInfo) + "\n") } - b.WriteString(fmt.Sprintf(" models: %d deployments: %d offerings: %d\n", h.Models, h.Deployments, h.Offerings)) + b.WriteString(" " + theme.Tint("models:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Models), theme.ReportInfo) + " " + theme.Tint("deployments:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Deployments), theme.ReportInfo) + " " + theme.Tint("offerings:", theme.ReportMuted) + " " + theme.Tint(fmt.Sprintf("%d", h.Offerings), theme.ReportInfo) + "\n") if h.Stale { - b.WriteString(fmt.Sprintf(" stale: yes (after %s) — graycode refreshes automatically on start\n", h.StaleAfter.UTC().Format(time.RFC3339))) + b.WriteString(" " + theme.Tint("stale:", theme.ReportMuted) + " " + theme.Tint("yes", theme.ReportWarn) + fmt.Sprintf(" (after %s) — graycode refreshes automatically on start\n", h.StaleAfter.UTC().Format(time.RFC3339))) } else if !h.StaleAfter.IsZero() { - b.WriteString(fmt.Sprintf(" stale: no (until %s)\n", h.StaleAfter.UTC().Format(time.RFC3339))) + b.WriteString(" " + theme.Tint("stale:", theme.ReportMuted) + " " + theme.Tint("no", theme.ReportSuccess) + fmt.Sprintf(" (until %s)\n", h.StaleAfter.UTC().Format(time.RFC3339))) } return strings.TrimRight(b.String(), "\n") } diff --git a/internal/config/credentials_store.go b/internal/config/credentials_store.go index 6df75968..f17d3f1d 100644 --- a/internal/config/credentials_store.go +++ b/internal/config/credentials_store.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/provider/gateway" + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) // PersistAPIKey saves a provider API key via graycode-router (OS secret store). @@ -143,17 +144,17 @@ func FormatCredentialCLIStatus(ctx context.Context) string { } report := CredentialStorageStatus(ctx) var b strings.Builder - fmt.Fprintf(&b, "Credential storage: %s only\n", report.PlatformStore) + fmt.Fprintf(&b, "%s %s only\n", theme.Tint("Credential storage:", theme.ReportMuted), theme.Tint(report.PlatformStore, theme.ReportInfo)) if report.Writable { - b.WriteString(" Keychain: writable\n") + b.WriteString(" " + theme.Tint("Keychain:", theme.ReportMuted) + " " + theme.Tint("writable", theme.ReportSuccess) + "\n") } else { - fmt.Fprintf(&b, " Keychain: %s\n", report.Detail) + fmt.Fprintf(&b, " %s %s\n", theme.Tint("Keychain:", theme.ReportMuted), theme.Tint(report.Detail, theme.ReportWarn)) } providers := ConfiguredCredentialProviders() if len(providers) == 0 { - b.WriteString(" Configured: (none)\n") + b.WriteString(" " + theme.Tint("Configured:", theme.ReportMuted) + " " + theme.Tint("(none)", theme.ReportWarn) + "\n") } else { - fmt.Fprintf(&b, " Configured: %s\n", strings.Join(providers, ", ")) + fmt.Fprintf(&b, " %s %s\n", theme.Tint("Configured:", theme.ReportMuted), theme.Tint(strings.Join(providers, ", "), theme.ReportInfo)) } return strings.TrimRight(b.String(), "\n") } diff --git a/internal/config/developer_path.go b/internal/config/developer_path.go index 5617a20e..2c82d4bd 100644 --- a/internal/config/developer_path.go +++ b/internal/config/developer_path.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "image/color" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/intelligence/memory" "github.com/GrayCodeAI/graycode-cli/internal/provider/gateway" "github.com/GrayCodeAI/graycode-cli/internal/sandbox" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/GrayCodeAI/graycode-cli/internal/token" "github.com/GrayCodeAI/graycode-cli/internal/tool" @@ -258,40 +260,61 @@ func developerPathNextStep(r DeveloperPathReport, setup SetupState) string { return "Run graycode preflight for details, then /config if needed" } +// pathStatusColor maps a readiness status to a semantic report color. +func pathStatusColor(s PathCheckStatus) color.Color { + switch s { + case PathPass: + return theme.ReportSuccess + case PathWarn: + return theme.ReportWarn + case PathFail: + return theme.ReportError + default: + return theme.ReportMuted + } +} + // FormatDeveloperPathReport renders the developer path readiness report for CLI/TUI. func FormatDeveloperPathReport(ctx context.Context) string { r := EvaluateDeveloperPath(ctx) var b strings.Builder - b.WriteString("Developer path (graycode · graycode-router · shrike · harrier)\n\n") + b.WriteString(theme.Tint("Developer path (graycode · graycode-router · shrike · harrier)", theme.ReportInfo) + "\n\n") status := "NEEDS SETUP" + statusColor := theme.ReportWarn switch { case r.Ready: status = "READY" + statusColor = theme.ReportSuccess case r.ChatReady && !r.SecureReady: status = "SECURITY FIX NEEDED" + statusColor = theme.ReportError case r.SecureReady && !r.ChatReady: status = "ALMOST READY" + statusColor = theme.ReportWarn } - b.WriteString("Status: " + status + "\n\n") + b.WriteString(theme.Tint("Status:", theme.ReportMuted) + " " + theme.Tint(status, statusColor) + "\n\n") sections := []string{"Setup", "Security", "Sandbox", "Ecosystem"} for _, sec := range sections { - b.WriteString(sec + "\n") + b.WriteString(theme.Tint(sec, theme.ReportInfo) + "\n") for _, c := range r.Checks { if c.Section != sec { continue } - b.WriteString(fmt.Sprintf(" %s %s — %s\n", pathStatusGlyph(c.Status), c.Name, c.Detail)) + b.WriteString(fmt.Sprintf(" %s %s — %s\n", + theme.Tint(pathStatusGlyph(c.Status), pathStatusColor(c.Status)), + theme.Tint(c.Name, theme.ReportMuted), + theme.Tint(c.Detail, theme.ReportInfo))) if c.FixHint != "" && c.Status != PathPass { - b.WriteString(" → " + c.FixHint + "\n") + b.WriteString(" " + theme.Tint("→ "+c.FixHint, theme.ReportWarn) + "\n") } } b.WriteByte('\n') } - b.WriteString("Next: " + r.NextStep + "\n") - b.WriteString("\nDocs: docs/DEVELOPER-PATH.md · docs/SECURITY-DEVELOPER.md · graycode doctor · graycode preflight\n") + b.WriteString(theme.Tint("Next:", theme.ReportMuted) + " " + r.NextStep + "\n") + b.WriteString("\n" + theme.Tint("Docs: docs/DEVELOPER-PATH.md · docs/SECURITY-DEVELOPER.md · graycode doctor · graycode preflight", theme.ReportMuted) + "\n") return strings.TrimRight(b.String(), "\n") } diff --git a/internal/config/ecosystem_report.go b/internal/config/ecosystem_report.go index e841d603..2c772861 100644 --- a/internal/config/ecosystem_report.go +++ b/internal/config/ecosystem_report.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/intelligence/memory" + "github.com/GrayCodeAI/graycode-cli/internal/theme" "github.com/GrayCodeAI/graycode-cli/internal/token" ) @@ -71,30 +72,30 @@ func BuildEcosystemReport(ctx context.Context, provider, model string) Ecosystem // FormatEcosystemPanel summarizes graycode-router, harrier, and shrike integration for doctor and status output. func FormatEcosystemPanel(ctx context.Context, provider, model string) string { var b strings.Builder - b.WriteString("Ecosystem (graycode-router · harrier · shrike):\n") + b.WriteString(theme.Tint("Ecosystem (graycode-router · harrier · shrike):", theme.ReportInfo) + "\n") // graycode-router — LLM provider layer cat := CatalogHealthReport(ctx) - graycodeRouterLine := " graycode-router: " + graycodeRouterLine := " " + theme.Tint("graycode-router:", theme.ReportMuted) + " " if cat.Exists { - graycodeRouterLine += fmt.Sprintf("catalog %d models", cat.Models) + graycodeRouterLine += theme.Tint(fmt.Sprintf("catalog %d models", cat.Models), theme.ReportInfo) } else { - graycodeRouterLine += "catalog missing (run graycode models refresh)" + graycodeRouterLine += theme.Tint("catalog missing (run graycode models refresh)", theme.ReportWarn) } pre := EnginePreflightReport(ctx) if pre.Ready { - graycodeRouterLine += " · locally ready" + graycodeRouterLine += " · " + theme.Tint("locally ready", theme.ReportSuccess) } else { - graycodeRouterLine += " · setup incomplete" + graycodeRouterLine += " · " + theme.Tint("setup incomplete", theme.ReportWarn) } if strings.TrimSpace(provider) != "" && provider != "auto" { - graycodeRouterLine += fmt.Sprintf(" · provider %s", provider) + graycodeRouterLine += " · " + theme.Tint("provider "+provider, theme.ReportInfo) } if dep, err := EngineDeploymentSummary(ctx, model); err == nil { if dep.RoutingStages > 0 { - graycodeRouterLine += fmt.Sprintf(" · routing %s (%d stages)", dep.RoutingSource, dep.RoutingStages) + graycodeRouterLine += " · " + theme.Tint(fmt.Sprintf("routing %s (%d stages)", dep.RoutingSource, dep.RoutingStages), theme.ReportInfo) } else { - graycodeRouterLine += fmt.Sprintf(" · routing %s", dep.RoutingSource) + graycodeRouterLine += " · " + theme.Tint("routing "+dep.RoutingSource, theme.ReportInfo) } } b.WriteString(graycodeRouterLine + "\n") @@ -103,14 +104,13 @@ func FormatEcosystemPanel(ctx context.Context, provider, model string) string { bridge := memory.NewHarrierBridge() if bridge.Ready() { first := strings.Split(memory.HarrierStatus(), "\n")[0] - b.WriteString(" harrier: " + first + " · bridge ready\n") + b.WriteString(" " + theme.Tint("harrier:", theme.ReportMuted) + " " + theme.Tint(first, theme.ReportInfo) + " · " + theme.Tint("bridge ready", theme.ReportSuccess) + "\n") } else { - b.WriteString(" harrier: not initialized · memory ops skipped (~/.harrier/data/)\n") + b.WriteString(" " + theme.Tint("harrier:", theme.ReportMuted) + " " + theme.Tint("not initialized", theme.ReportWarn) + " · memory ops skipped (~/.harrier/data/)\n") } // shrike — token counting and context compression (always embedded) sample := token.CountTokensFast("graycode context compression pipeline") - b.WriteString(fmt.Sprintf(" shrike: embedded · token/compress pipeline OK (sample=%d tokens)\n", sample)) - + b.WriteString(" " + theme.Tint("shrike:", theme.ReportMuted) + " " + theme.Tint("embedded", theme.ReportInfo) + " · " + theme.Tint("token/compress pipeline OK", theme.ReportSuccess) + fmt.Sprintf(" (sample=%d tokens)", sample) + "\n") return strings.TrimRight(b.String(), "\n") } diff --git a/internal/contracts/types/severity.go b/internal/contracts/types/severity.go index 5eef2182..32654bd0 100644 --- a/internal/contracts/types/severity.go +++ b/internal/contracts/types/severity.go @@ -27,22 +27,11 @@ func (s Severity) String() string { return "unknown" } -// ParseSeverity converts a string to a Severity. -// -// Deprecated: ParseSeverity fails open — unknown input (typos such as -// "critcal", empty strings, arbitrary text) silently maps to SeverityInfo, -// so a malformed value is indistinguishable from a legitimate "info". -// Callers handling untrusted input should use ParseSeverityStrict, which -// reports unknown values as errors instead. -func ParseSeverity(s string) Severity { - sev, _ := ParseSeverityStrict(s) - return sev -} - // ParseSeverityStrict converts a string to a Severity, reporting unknown // values as errors instead of failing open to SeverityInfo. Matching is -// case-insensitive and ignores surrounding whitespace, exactly like -// ParseSeverity; the two accept the same set of valid names. +// case-insensitive and ignores surrounding whitespace; it accepts the same +// set of valid names as the removed fail-open ParseSeverity (which silently +// mapped unknown input to SeverityInfo and was deleted as a footgun). func ParseSeverityStrict(s string) (Severity, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "critical": diff --git a/internal/crash/crash_unix.go b/internal/crash/crash_unix.go index d38b5a1b..d7f96285 100644 --- a/internal/crash/crash_unix.go +++ b/internal/crash/crash_unix.go @@ -4,7 +4,6 @@ package crash import ( - "errors" "fmt" "os" "os/signal" @@ -41,9 +40,7 @@ func installDumpHandler(sig syscall.Signal) { writeSignalReport(sig) signal.Reset(sig) // Restore default disposition and re-raise. - if err := raiseSignal(sig); err != nil { - fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) - } + raiseSignal(sig) }() } @@ -66,12 +63,15 @@ func writeSignalReport(sig syscall.Signal) { _, _ = WriteReport(nil, []byte(fmt.Sprintf("signal dump %s — see crash-signal-*.txt", sig))) } -func raiseSignal(sig syscall.Signal) error { +// raiseSignal re-raises sig with the default disposition so the OS produces +// normal termination. It either terminates the process or, if a handler +// swallows the signal, logs the failure and returns (never returns nil — this +// is a diagnostic safety net, so the log is unconditional on reaching here). +func raiseSignal(sig syscall.Signal) { if err := syscall.Kill(os.Getpid(), sig); err != nil { - return fmt.Errorf("kill self: %w", err) + fmt.Fprintf(os.Stderr, "crash: failed to re-raise %s: %v\n", sig, err) + return } - // If kill returns, momentarily restore the default and re-raise. We reach - // here only if a handler caught it above; the re-raise above should have - // terminated. This is a safety net. - return errors.New("re-raise returned without terminating") + // If kill returns, a handler caught it; log that termination did not occur. + fmt.Fprintf(os.Stderr, "crash: re-raise of %s returned without terminating\n", sig) } diff --git a/internal/daemon/routes_metrics.go b/internal/daemon/routes_metrics.go index 7e0d45df..fb10775f 100644 --- a/internal/daemon/routes_metrics.go +++ b/internal/daemon/routes_metrics.go @@ -76,15 +76,15 @@ func (s *Server) emitRuntimeMetrics(sb *strings.Builder) { return true }) - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_active_sessions gauge\n")) + sb.WriteString("# TYPE graycode_daemon_active_sessions gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_active_sessions %d\n", activeSessions)) // Concurrency slots used - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_chat_concurrency_used gauge\n")) + sb.WriteString("# TYPE graycode_daemon_chat_concurrency_used gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_chat_concurrency_used %d\n", len(s.concurrencySem))) // Uptime - sb.WriteString(fmt.Sprintf("# TYPE graycode_daemon_uptime_seconds gauge\n")) + sb.WriteString("# TYPE graycode_daemon_uptime_seconds gauge\n") sb.WriteString(fmt.Sprintf("graycode_daemon_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())) } diff --git a/internal/diffsandbox/sandbox.go b/internal/diffsandbox/sandbox.go index c0b19d87..b2e09dd3 100644 --- a/internal/diffsandbox/sandbox.go +++ b/internal/diffsandbox/sandbox.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/graycode-cli/internal/theme" ) // ChangeType identifies the kind of file modification. @@ -340,24 +342,26 @@ func (s *Sandbox) Summary() string { defer s.mu.RUnlock() if len(s.changes) == 0 { - return "No pending changes." + return theme.Tint("No pending changes.", theme.ReportMuted) } var b strings.Builder stats := s.statsLocked() - b.WriteString(fmt.Sprintf("Pending changes (%d file(s)):\n", len(s.changes))) + b.WriteString(theme.Tint(fmt.Sprintf("Pending changes (%d file(s)):", len(s.changes)), theme.ReportInfo) + "\n") for _, path := range s.order { c, ok := s.changes[path] if !ok { continue } - b.WriteString(fmt.Sprintf(" [%s] %s\n", c.Type.String(), c.Path)) + b.WriteString(fmt.Sprintf(" %s %s\n", + theme.Tint("["+c.Type.String()+"]", theme.ReportMuted), + theme.Tint(c.Path, theme.ReportInfo))) } - b.WriteString(fmt.Sprintf("Stats: +%d -%d lines | %d created, %d modified, %d deleted\n", + b.WriteString(theme.Tint(fmt.Sprintf("Stats: +%d -%d lines | %d created, %d modified, %d deleted", stats.LinesAdded, stats.LinesRemoved, - stats.FilesCreated, stats.FilesModified, stats.FilesDeleted)) + stats.FilesCreated, stats.FilesModified, stats.FilesDeleted), theme.ReportMuted) + "\n") return b.String() } diff --git a/internal/engine/lifecycle/sleeptime_ops.go b/internal/engine/lifecycle/sleeptime_ops.go index aed60906..02ef80c9 100644 --- a/internal/engine/lifecycle/sleeptime_ops.go +++ b/internal/engine/lifecycle/sleeptime_ops.go @@ -1,6 +1,7 @@ package lifecycle import ( + "context" "encoding/json" "errors" "fmt" @@ -41,7 +42,7 @@ func ParseAndApplyMemoryOps(bridge *memory.HarrierBridge, response string) error } switch op.Op { case "add": - if err := bridge.Remember(op.Content, op.Type); err != nil { + if err := bridge.Remember(context.Background(), op.Content, op.Type); err != nil { errs = append(errs, fmt.Errorf("memory ops: remember: %w", err)) } } diff --git a/internal/engine/memory_service.go b/internal/engine/memory_service.go index bf311b51..632769fe 100644 --- a/internal/engine/memory_service.go +++ b/internal/engine/memory_service.go @@ -112,13 +112,12 @@ func (s *MemoryService) RecallContext(_ context.Context, lastUserMsg string, bud // shouldn't fail a turn just because harrier is unavailable). func (s *MemoryService) Remember(ctx context.Context, content, category string) { if s.enhanced != nil { - _ = s.enhanced.Remember(content, category) + _ = s.enhanced.Remember(ctx, content, category) return } if s.memory != nil { - _ = s.memory.Remember(content, category) + _ = s.memory.Remember(ctx, content, category) } - _ = ctx // reserved for future context-aware memory ops } // OnSessionEnd runs the post-session memory bookkeeping. @@ -152,7 +151,7 @@ func (s *MemoryService) Finalize(messages []types.GraycodeRouterMessage, success if !success { summary += " (interrupted)" } - _ = s.memory.Remember(summary, "session") + _ = s.memory.Remember(context.Background(), summary, "session") } } diff --git a/internal/engine/memory_service_test_helpers_test.go b/internal/engine/memory_service_test_helpers_test.go index 8b8be53f..bcd23d21 100644 --- a/internal/engine/memory_service_test_helpers_test.go +++ b/internal/engine/memory_service_test_helpers_test.go @@ -1,5 +1,7 @@ package engine +import "context" + // mockMemoryRecaller is the minimal in-memory backend used by memory-service // tests. It intentionally lives beside those tests rather than in the removed // SessionServices compatibility test. @@ -9,7 +11,7 @@ func (m *mockMemoryRecaller) Recall(query string, tokenBudget int) (string, erro return "recalled: " + query, nil } -func (m *mockMemoryRecaller) Remember(content, category string) error { +func (m *mockMemoryRecaller) Remember(ctx context.Context, content, category string) error { return nil } diff --git a/internal/engine/session.go b/internal/engine/session.go index a2b81049..39e71449 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -32,7 +32,10 @@ import ( // MemoryRecaller abstracts memory recall/remember so engine avoids importing memory directly. type MemoryRecaller interface { Recall(query string, tokenBudget int) (string, error) - Remember(content, category string) error + // Remember persists a content+category pair. The ctx lets background + // callers bound the call so a slow/hung memory backend cannot leak a + // goroutine (the HarrierBridge path honors it for network cancellation). + Remember(ctx context.Context, content, category string) error } // SnapshotTracker abstracts the snapshot system so engine doesn't import snapshot directly. @@ -623,11 +626,11 @@ func (s *Session) AddUser(content string) { if memSvc := s.MemorySvc(); memSvc != nil { if mem := memSvc.Memory(); mem != nil && strings.Contains(strings.ToLower(content), "remember") { go func(c string) { - // Use timeout context so goroutine doesn't hang if backend is slow. + // Bound the call so a slow/hung memory backend cannot leak + // this goroutine; the ctx now propagates to the backend. rCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _ = rCtx // timeout context available if Remember is extended to accept it - if err := mem.Remember(c, "user_explicit"); err != nil { + if err := mem.Remember(rCtx, c, "user_explicit"); err != nil { slog.Warn("background memory remember failed", "error", err) } }(content) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 51bcf1be..43546d93 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -792,13 +792,12 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if textContent.Len() > 0 { s.Persistence().AppendAssistantJournaled(types.GraycodeRouterMessage{Role: "assistant", Content: textContent.String()}) // Auto-remember corrections and learnings. Best-effort - // fire-and-forget: the memory backend's Remember does not yet - // accept a context, so this goroutine cannot be cancelled mid-call. - // MemoryService.Remember(ctx, ...) reserves ctx for exactly this - // extension when the backend becomes context-aware. + // fire-and-forget, bounded so a hung backend cannot leak. if s.MemorySvc().Memory() != nil && shouldRemember(textContent.String()) { go func(content string) { - if err := s.MemorySvc().Memory().Remember(content, "assistant_learning"); err != nil { + rCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.MemorySvc().Memory().Remember(rCtx, content, "assistant_learning"); err != nil { slog.Warn("background assistant_learning remember failed", "error", err) } }(textContent.String()) @@ -806,9 +805,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Sleeptime: background memory consolidation if s.MemorySvc().Sleeptime() != nil && s.MemorySvc().Sleeptime().ShouldRun() && s.MemorySvc().Harrier() != nil && s.MemorySvc().Harrier().Ready() { - // Snapshot messages to avoid data race with main loop appending - msgs := make([]types.GraycodeRouterMessage, len(s.Persistence().RawMessages())) - copy(msgs, s.Persistence().RawMessages()) + // Snapshot messages to avoid data race with main loop appending. + // RawMessages already returns a deep clone, so a single call + // yields a stable snapshot (the prior len()+copy double-call + // raced on reallocation between the two reads). + msgs := s.Persistence().RawMessages() go func() { var transcript []string for _, m := range msgs { @@ -835,9 +836,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Skill distillation: extract reusable skill from multi-turn tasks if s.MemorySvc().SkillDistiller() != nil && toolTurns >= 5 && s.MemorySvc().Harrier() != nil && s.MemorySvc().Harrier().Ready() { - // Snapshot messages to avoid data race with main loop appending - msgs := make([]types.GraycodeRouterMessage, len(s.Persistence().RawMessages())) - copy(msgs, s.Persistence().RawMessages()) + // Snapshot messages to avoid data race with main loop appending. + // RawMessages already returns a deep clone, so a single call + // yields a stable snapshot (the prior len()+copy double-call + // raced on reallocation between the two reads). + msgs := s.Persistence().RawMessages() // Snapshot the tool/file sets too, so the goroutine never // reads the live maps while the main loop writes them on a // later tool turn. @@ -872,7 +875,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { return } content, _ := json.Marshal(skill) - if err := s.MemorySvc().Harrier().Remember(string(content), "skill"); err != nil { + rCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.MemorySvc().Harrier().Remember(rCtx, string(content), "skill"); err != nil { slog.Warn("background skill remember failed", "error", err) } }() @@ -1111,13 +1116,13 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } if userMsg != "" && assistantMsg != "" { condensed := fmt.Sprintf("Q: %s\nA: %s", truncate(userMsg, 200), truncate(assistantMsg, 300)) - if err := s.MemorySvc().Memory().Remember(condensed, "conversation"); err != nil { + if err := s.MemorySvc().Memory().Remember(ctx, condensed, "conversation"); err != nil { slog.Warn("conversation remember failed", "error", err) } } // Also save insights if the response has learning signals if assistantMsg != "" && shouldRemember(assistantMsg) { - if err := s.MemorySvc().Memory().Remember(truncate(assistantMsg, 500), "insight"); err != nil { + if err := s.MemorySvc().Memory().Remember(ctx, truncate(assistantMsg, 500), "insight"); err != nil { slog.Warn("insight remember failed", "error", err) } } diff --git a/internal/feature/eval/eval.go b/internal/feature/eval/eval.go index d761d31a..12c3d272 100644 --- a/internal/feature/eval/eval.go +++ b/internal/feature/eval/eval.go @@ -64,6 +64,10 @@ type Runner struct { Cache *Cache NoCache bool Filters []Filter + // Progress, when non-nil, is invoked before each task runs with the + // zero-based task index, the total task count, and the task ID. It lets + // callers surface live per-task progress for long benchmark suites. + Progress func(i, total int, taskID string) } // LLMClient is the interface for invoking an LLM during evaluation. @@ -102,6 +106,10 @@ func (r *Runner) Run(ctx context.Context, suite *BenchmarkSuite) (*SuiteResult, default: } + if r.Progress != nil { + r.Progress(i, len(suite.Tasks), suite.Tasks[i].ID) + } + taskResult, err := r.RunSingle(ctx, &suite.Tasks[i]) if err != nil { taskResult = &TaskResult{ diff --git a/internal/feature/eval/eval_test.go b/internal/feature/eval/eval_test.go index 55b87cfb..6f822b81 100644 --- a/internal/feature/eval/eval_test.go +++ b/internal/feature/eval/eval_test.go @@ -544,3 +544,47 @@ func TestBenchmarkSuiteStructure(t *testing.T) { } } } + +// TestRunProgressCallback asserts the Progress callback fires once per task +// with the zero-based index, total count, and task ID, in order. +func TestRunProgressCallback(t *testing.T) { + mk := func(id string) BenchmarkTask { + return BenchmarkTask{ + ID: id, + Description: "passes immediately", + SetupFn: func(workDir string) error { return nil }, + ValidateFn: func(workDir string) (bool, string) { return true, "ok" }, + Prompt: "Do nothing", + TimeLimit: 10 * time.Second, + } + } + suite := &BenchmarkSuite{ + Name: "progress", + Tasks: []BenchmarkTask{mk("a"), mk("b"), mk("c")}, + } + + var calls []string + r := NewRunner("test", "test") + r.Progress = func(i, total int, taskID string) { + if total != 3 { + t.Errorf("total = %d, want 3", total) + } + calls = append(calls, taskID) + if i != len(calls)-1 { + t.Errorf("i = %d, want %d", i, len(calls)-1) + } + } + + if _, err := r.Run(context.Background(), suite); err != nil { + t.Fatalf("Run: %v", err) + } + want := []string{"a", "b", "c"} + if len(calls) != len(want) { + t.Fatalf("callback called %d times, want %d (%v)", len(calls), len(want), calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d = %q, want %q", i, calls[i], want[i]) + } + } +} diff --git a/internal/fuzzyfind/fuzzyfind_test.go b/internal/fuzzyfind/fuzzyfind_test.go index 7617a746..f951ba7f 100644 --- a/internal/fuzzyfind/fuzzyfind_test.go +++ b/internal/fuzzyfind/fuzzyfind_test.go @@ -67,7 +67,7 @@ func TestSkipDirsExcluded(t *testing.T) { f, _ := New(root) matches := f.Search("lib", 20) for _, m := range matches { - if filepath.HasPrefix(m.Path, "vendor") { + if strings.HasPrefix(m.Path, "vendor") { t.Fatal("vendor leaked into results") } } diff --git a/internal/intelligence/memory/auto_capture.go b/internal/intelligence/memory/auto_capture.go index 1dc18eec..8271e5db 100644 --- a/internal/intelligence/memory/auto_capture.go +++ b/internal/intelligence/memory/auto_capture.go @@ -139,6 +139,7 @@ func (ac *AutoCapture) processFileWrite(job captureJob) { return } _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("File modified: %s", path), "file", ) @@ -156,6 +157,7 @@ func (ac *AutoCapture) processBash(job captureJob) { if job.isErr || containsTestFailure(job.output) { snippet := truncate(job.output, 300) _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Test failure: `%s` → %s", truncate(cmd, 100), snippet), "bug", ) @@ -169,6 +171,7 @@ func (ac *AutoCapture) processBash(job captureJob) { msg := extractCommitMessage(cmd) if msg != "" { _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Commit: %s", msg), "decision", ) @@ -182,6 +185,7 @@ func (ac *AutoCapture) processBash(job captureJob) { pkg := extractPackageName(cmd) if pkg != "" { _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Dependency added: %s", pkg), "decision", ) @@ -193,6 +197,7 @@ func (ac *AutoCapture) processBash(job captureJob) { // Detect build/deploy commands as conventions if isBuildCommand(cmd) && !job.isErr { _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Build command: `%s`", truncate(cmd, 200)), "convention", ) @@ -209,6 +214,7 @@ func (ac *AutoCapture) processRead(job captureJob) { // Only track significant reads (file structure discovery) if len(job.output) > 500 && isStructuralFile(path) { _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Project file: %s", path), "file", ) @@ -224,6 +230,7 @@ func (ac *AutoCapture) processError(job captureJob) { if containsErrorPattern(job.output) { snippet := truncate(job.output, 300) _ = ac.bridge.Remember( + context.Background(), fmt.Sprintf("Error in %s: %s", job.toolName, snippet), "bug", ) @@ -348,7 +355,7 @@ func (ac *AutoCapture) ExtractFromAssistantResponse(ctx context.Context, text st } conventions := ExtractConventions(text) for _, c := range conventions { - _ = ac.bridge.Remember(c, "convention") + _ = ac.bridge.Remember(ctx, c, "convention") ac.metrics.inc("convention") } } diff --git a/internal/intelligence/memory/enhanced_manager.go b/internal/intelligence/memory/enhanced_manager.go index 82aaf024..5f6a3908 100644 --- a/internal/intelligence/memory/enhanced_manager.go +++ b/internal/intelligence/memory/enhanced_manager.go @@ -161,9 +161,9 @@ func (em *EnhancedMemoryManager) Recall(query string, tokenBudget int) (string, } // Remember stores memory and routes through auto-capture pipeline. -// Implements engine.MemoryRecaller interface. -func (em *EnhancedMemoryManager) Remember(content, category string) error { - err := em.MemoryManager.Remember(content, category) +// Implements engine.MemoryRecaller interface. The ctx bounds the harrier path. +func (em *EnhancedMemoryManager) Remember(ctx context.Context, content, category string) error { + err := em.MemoryManager.Remember(ctx, content, category) if err != nil { return err } diff --git a/internal/intelligence/memory/harrier_bridge.go b/internal/intelligence/memory/harrier_bridge.go index 964992f9..b50a4edc 100644 --- a/internal/intelligence/memory/harrier_bridge.go +++ b/internal/intelligence/memory/harrier_bridge.go @@ -193,9 +193,11 @@ func (b *HarrierBridge) notReadyError(op string) error { // Remember stores content into harrier's memory graph under the given category. // Category maps to harrier's node type (e.g., "convention", "decision", "bug", "preference"). -// Returns a BridgeError if harrier is not initialized. -func (b *HarrierBridge) Remember(content, category string) error { - return b.RememberWithContext(context.Background(), content, category) +// Returns a BridgeError if harrier is not initialized. Implements +// engine.MemoryRecaller; the ctx bounds the harrier network call so a hung +// backend cannot leak a caller's goroutine. +func (b *HarrierBridge) Remember(ctx context.Context, content, category string) error { + return b.RememberWithContext(ctx, content, category) } // RememberWithContext is the context-aware version of Remember. diff --git a/internal/intelligence/memory/harrier_bridge_integration_test.go b/internal/intelligence/memory/harrier_bridge_integration_test.go index 1e18d933..44b90bf5 100644 --- a/internal/intelligence/memory/harrier_bridge_integration_test.go +++ b/internal/intelligence/memory/harrier_bridge_integration_test.go @@ -1,6 +1,7 @@ package memory import ( + "context" "encoding/json" "os" "strings" @@ -40,7 +41,7 @@ func TestHarrierBridge_Remember(t *testing.T) { // FIXME: harrier dependency must be available to test remember functionality t.Skip("harrier not available") } - err := b.Remember("test content to remember", "explicit") + err := b.Remember(context.Background(), "test content to remember", "explicit") if err != nil { t.Fatalf("Remember: %v", err) } @@ -54,7 +55,7 @@ func TestHarrierBridge_Recall(t *testing.T) { // FIXME: harrier dependency must be available to test recall functionality t.Skip("harrier not available") } - _ = b.Remember("golang error handling patterns", "convention") + _ = b.Remember(context.Background(), "golang error handling patterns", "convention") result, err := b.Recall("error handling", 500) if err != nil { @@ -73,7 +74,7 @@ func TestHarrierBridgeRecallRecordsPortableContextGraph(t *testing.T) { } defer b.Close() - if err := b.Remember("private graph context about error handling", "decision"); err != nil { + if err := b.Remember(context.Background(), "private graph context about error handling", "decision"); err != nil { t.Fatalf("Remember() error = %v", err) } b.ConfigureGraphObservation( diff --git a/internal/intelligence/memory/manager.go b/internal/intelligence/memory/manager.go index 7cf8112a..b0f72218 100644 --- a/internal/intelligence/memory/manager.go +++ b/internal/intelligence/memory/manager.go @@ -1,6 +1,7 @@ package memory import ( + "context" "strings" ) @@ -97,8 +98,8 @@ func (mm *MemoryManager) Recall(query string, tokenBudget int) (string, error) { } // Remember routes content to the appropriate subsystem based on category. -// Implements engine.MemoryRecaller. -func (mm *MemoryManager) Remember(content, category string) error { +// Implements engine.MemoryRecaller. The ctx bounds the harrier network path. +func (mm *MemoryManager) Remember(ctx context.Context, content, category string) error { switch category { case "guideline", "lesson": mm.Evolving.Learn(content, content, "manager") @@ -118,7 +119,7 @@ func (mm *MemoryManager) Remember(content, category string) error { default: // Default: store in harrier if ready, otherwise fall back to core Memory. if mm.Harrier.Ready() { - return mm.Harrier.Remember(content, category) + return mm.Harrier.RememberWithContext(ctx, content, category) } return Save(&Memory{Content: content, Tags: []string{category}}) } diff --git a/internal/intelligence/memory/manager_test.go b/internal/intelligence/memory/manager_test.go index 69e459f6..e3f2bd20 100644 --- a/internal/intelligence/memory/manager_test.go +++ b/internal/intelligence/memory/manager_test.go @@ -1,6 +1,7 @@ package memory import ( + "context" "testing" ) @@ -29,7 +30,7 @@ func TestMemoryManager_Remember(t *testing.T) { mm := NewMemoryManager(t.TempDir()) categories := []string{"guideline", "core", "procedural", "fact", "session", "other"} for _, cat := range categories { - if err := mm.Remember("test content for "+cat, cat); err != nil { + if err := mm.Remember(context.Background(), "test content for "+cat, cat); err != nil { t.Fatalf("Remember(%q) error: %v", cat, err) } } diff --git a/internal/intelligence/memory/session_diff.go b/internal/intelligence/memory/session_diff.go index 7d7c43b8..6be117a7 100644 --- a/internal/intelligence/memory/session_diff.go +++ b/internal/intelligence/memory/session_diff.go @@ -134,12 +134,13 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { } else if isConfigFile(f) { content = fmt.Sprintf("Config file: %s (%s)", basename, ext) } - _ = sd.bridge.Remember(content, "file") + _ = sd.bridge.Remember(context.Background(), content, "file") } // New dependencies → remember as decisions for _, dep := range diff.NewDeps { _ = sd.bridge.Remember( + context.Background(), fmt.Sprintf("Dependency added: %s", dep), "decision", ) @@ -155,6 +156,7 @@ func (sd *SessionDiffAnalyzer) StoreMemoriesFromDiff(diff *DiffResult) { parts := strings.SplitN(commit, " ", 2) if len(parts) > 1 { _ = sd.bridge.Remember( + context.Background(), fmt.Sprintf("Decision: %s", parts[1]), "decision", ) diff --git a/internal/onboarding/onboarding.go b/internal/onboarding/onboarding.go index 507c5c9a..f2c5f293 100644 --- a/internal/onboarding/onboarding.go +++ b/internal/onboarding/onboarding.go @@ -15,17 +15,42 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/ui/icons" ) -const ( - teal = "\033[38;2;78;205;196m" - dim = "\033[2m" - bold = "\033[1m" - red = "\033[38;2;224;85;85m" - reset = "\033[0m" +var ( + teal string + dim string + bold string + red string + reset string + brand string ) +func init() { + initColorCodes() +} + +// initColorCodes sets the ANSI escape codes, honoring NO_COLOR/FORCE_COLOR/TTY +// so the onboarding wizard stays plain in scripted or NO_COLOR environments. +func initColorCodes() { + teal = "" + dim = "" + bold = "" + red = "" + reset = "" + brand = "" + if !internaltheme.ColorEnabled() { + return + } + teal = "\033[38;2;78;205;196m" + dim = "\033[2m" + bold = "\033[1m" + red = "\033[38;2;224;85;85m" + reset = "\033[0m" + brand = internaltheme.BrandANSI +} + // Welcome prints the graycode welcome banner. func Welcome(version string) { - graycodeC := internaltheme.BrandANSI + graycodeC := brand totalW := 80 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 40 { diff --git a/internal/onboarding/onboarding_test.go b/internal/onboarding/onboarding_test.go index fdbb693d..a37f55b2 100644 --- a/internal/onboarding/onboarding_test.go +++ b/internal/onboarding/onboarding_test.go @@ -41,13 +41,27 @@ func TestWelcome(t *testing.T) { } func TestColorConstants(t *testing.T) { + // Color codes are gated on the environment: present when color is forced, + // empty when NO_COLOR is set (the wizard stays plain in scripted output). + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + initColorCodes() if teal == "" { - t.Error("teal color should not be empty") + t.Error("teal color should not be empty when color is enabled") } if reset == "" { - t.Error("reset should not be empty") + t.Error("reset should not be empty when color is enabled") } if bold == "" { - t.Error("bold should not be empty") + t.Error("bold should not be empty when color is enabled") + } + + t.Setenv("NO_COLOR", "1") + initColorCodes() + if teal != "" { + t.Error("teal should be empty when NO_COLOR is set") + } + if reset != "" { + t.Error("reset should be empty when NO_COLOR is set") } } diff --git a/internal/plugin/marketplace.go b/internal/plugin/marketplace.go index f7409aeb..122e3ee9 100644 --- a/internal/plugin/marketplace.go +++ b/internal/plugin/marketplace.go @@ -40,15 +40,14 @@ type MarketplaceSource struct { URL string `json:"url"` } -// DefaultMarketplaceSources returns built-in sources. -// Official GrayCodeAI plugin index (may 404 until published — callers handle). +// DefaultMarketplaceSources returns the built-in plugin index sources. +// +// There are none. No repository in the GrayCode ecosystem generates a +// plugins-registry.json, so shipping a built-in source only produced a 404 +// on every `graycode plugin marketplace list`. Users register real sources +// with `graycode plugin marketplace add `. func DefaultMarketplaceSources() []MarketplaceSource { - return []MarketplaceSource{ - { - Name: "official", - URL: "https://raw.githubusercontent.com/GrayCodeAI/starling/main/plugins-registry.json", - }, - } + return nil } // MarketplaceClient fetches plugin marketplace indexes and installs packages. diff --git a/internal/plugin/marketplace_test.go b/internal/plugin/marketplace_test.go index 96781449..3b67d2e2 100644 --- a/internal/plugin/marketplace_test.go +++ b/internal/plugin/marketplace_test.go @@ -68,3 +68,22 @@ func TestMarketplaceInstallRejectsSCPStyleURL(t *testing.T) { t.Errorf("error should mention scp-style, got: %v", err) } } + +func TestNoPhantomDefaultMarketplaceSource(t *testing.T) { + for _, src := range DefaultMarketplaceSources() { + if strings.Contains(src.URL, "plugins-registry.json") { + t.Fatalf("default source %q points at plugins-registry.json, which nothing generates", src.Name) + } + } +} + +func TestFetchAllWithNoSourcesReturnsEmptyNotError(t *testing.T) { + mc := &MarketplaceClient{Sources: nil, CacheDir: t.TempDir()} + entries, err := mc.FetchAll() + if err != nil { + t.Fatalf("FetchAll with no sources returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0", len(entries)) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index ed414504..ebff1fb0 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "os" "os/exec" @@ -17,7 +18,72 @@ import ( "github.com/GrayCodeAI/graycode-cli/internal/storage" ) -const defaultIndexURL = "https://raw.githubusercontent.com/GrayCodeAI/starling/main/registry.json" +// defaultIndexURL is the rolling release asset published by +// graycode-skills/.github/workflows/publish-registry.yml. The registry is a +// generated 4.3 MB artifact and is deliberately not committed to that repo, +// so a raw.githubusercontent.com URL cannot work. +const defaultIndexURL = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" + +// maxSkillSearchDepth bounds how deep discoverSkillDirs walks below the repo +// root. graycode-skills nests skills at categories///, which +// is depth 3; anything deeper is almost certainly test data or a vendored +// copy. +const maxSkillSearchDepth = 4 + +// discoverSkillDirs finds every directory under root containing a SKILL.md, +// keyed by the directory name. It replaces the previous two hard-coded +// layouts (// and /skills//) so repositories that +// group skills under a category directory are installable too. +// +// On a duplicate skill name the shallowest path wins, so the result does not +// depend on walk order. +func discoverSkillDirs(root string) (map[string]string, error) { + found := map[string]string{} + depthOf := map[string]int{} + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil //nolint:nilerr // an unrelatable path is simply skipped + } + if d.IsDir() { + if path == root { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { + return filepath.SkipDir + } + if len(strings.Split(filepath.ToSlash(rel), "/")) > maxSkillSearchDepth { + return filepath.SkipDir + } + return nil + } + if d.Name() != "SKILL.md" { + return nil + } + dir := filepath.Dir(path) + if dir == root { + // A top-level SKILL.md documents the repository, not a skill. + return nil + } + name := filepath.Base(dir) + depth := len(strings.Split(filepath.ToSlash(rel), "/")) + if _, ok := found[name]; ok && depthOf[name] <= depth { + return nil + } + found[name] = dir + depthOf[name] = depth + return nil + }) + if err != nil { + return nil, fmt.Errorf("scan skills: %w", err) + } + return found, nil +} // SkillInvocationPolicy controls which callers may invoke a skill. type SkillInvocationPolicy struct { @@ -244,6 +310,10 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) } defer func() { _ = os.RemoveAll(tmpDir) }() + // ponytail: whole-repo shallow clone. Installing one skill from + // graycode-skills clones ~127 MB of categories. Switch to git + // sparse-checkout of the skill's indexed path if install latency + // becomes a complaint. url := "https://github.com/" + repo + ".git" cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", url, tmpDir) // #nosec G204 -- url is built from a caller-supplied repo slug prefixed with a fixed GitHub URL, consistent with other install paths in this package if out, cloneErr := cmd.CombinedOutput(); cloneErr != nil { @@ -257,12 +327,16 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) commitSha = strings.TrimSpace(string(headOut)) } - // Discover skills in the cloned repo. - skillsRoot := tmpDir - // Check for skills/ subdirectory (agentskills.io convention). - if info, statErr := os.Stat(filepath.Join(tmpDir, "skills")); statErr == nil && info.IsDir() { - skillsRoot = filepath.Join(tmpDir, "skills") + // Discover skills in the cloned repo, whatever layout it uses. + discovered, err := discoverSkillDirs(tmpDir) + if err != nil { + return "", err + } + names := make([]string, 0, len(discovered)) + for name := range discovered { + names = append(names, name) } + sort.Strings(names) installed := []string{} blocked := []string{} @@ -272,20 +346,11 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error) if lockErr != nil { return "", fmt.Errorf("load skills lock: %w", lockErr) } - entries, err := os.ReadDir(skillsRoot) - if err != nil { - return "", fmt.Errorf("read skills: %w", err) - } - - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() + for _, name := range names { if skillName != "" && !strings.EqualFold(name, skillName) { continue } - srcSkill := filepath.Join(skillsRoot, name, "SKILL.md") + srcSkill := filepath.Join(discovered[name], "SKILL.md") if _, err := os.Stat(srcSkill); err != nil { continue } diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go index 471d9944..df2bdd4b 100644 --- a/internal/plugin/registry_test.go +++ b/internal/plugin/registry_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "strings" "testing" @@ -17,8 +18,8 @@ func testIndex() SkillIndex { Version: 1, UpdatedAt: "2026-05-01T00:00:00Z", Skills: []SkillEntry{ - {Name: "api-review", Description: "Reviews API endpoints", Author: "graycode", Repo: "GrayCodeAI/starling", Category: "engineering", Tags: []string{"api", "review"}, Version: "1.0.0", Installs: 342}, - {Name: "security-scan", Description: "Scans for security vulnerabilities", Author: "graycode", Repo: "GrayCodeAI/starling", Category: "security", Tags: []string{"security", "scan"}, Version: "2.1.0", Installs: 891}, + {Name: "api-review", Description: "Reviews API endpoints", Author: "graycode", Repo: "GrayCodeAI/graycode-skills", Category: "engineering", Tags: []string{"api", "review"}, Version: "1.0.0", Installs: 342}, + {Name: "security-scan", Description: "Scans for security vulnerabilities", Author: "graycode", Repo: "GrayCodeAI/graycode-skills", Category: "security", Tags: []string{"security", "scan"}, Version: "2.1.0", Installs: 891}, {Name: "changelog", Description: "Generates changelogs from git commits", Author: "community", Repo: "community/skills", Category: "workflow", Tags: []string{"changelog", "git"}, Version: "1.2.0", Installs: 156}, }, } @@ -164,7 +165,7 @@ license: MIT category: engineering tags: ["api", "review", "rest"] agents: ["graycode", "claude-code"] -source-repo: GrayCodeAI/starling +source-repo: GrayCodeAI/graycode-skills source-ref: v1.2.0 source-installed-at: 2026-05-01T00:00:00Z --- @@ -189,7 +190,7 @@ Review all API endpoints. if len(skill.Agents) != 2 { t.Errorf("expected 2 agents, got %d", len(skill.Agents)) } - if skill.Source.Repo != "GrayCodeAI/starling" { + if skill.Source.Repo != "GrayCodeAI/graycode-skills" { t.Errorf("source repo: got %q", skill.Source.Repo) } if skill.Source.Ref != "v1.2.0" { @@ -247,7 +248,7 @@ func TestFormatSkillEntry(t *testing.T) { Version: "1.0.0", Author: "graycode", Description: "Reviews API endpoints", - Repo: "GrayCodeAI/starling", + Repo: "GrayCodeAI/graycode-skills", Installs: 342, } out := FormatSkillEntry(e) @@ -273,7 +274,7 @@ func TestFormatSkillInfo(t *testing.T) { License: "MIT", Category: "engineering", Tags: []string{"api", "review"}, - Source: SkillSource{Repo: "GrayCodeAI/starling", Ref: "v1.0.0"}, + Source: SkillSource{Repo: "GrayCodeAI/graycode-skills", Ref: "v1.0.0"}, } out := FormatSkillInfo(s, "/path/to/skill") if !strings.Contains(out, "Skill: api-review") { @@ -282,7 +283,121 @@ func TestFormatSkillInfo(t *testing.T) { if !strings.Contains(out, "MIT") { t.Error("expected license") } - if !strings.Contains(out, "GrayCodeAI/starling") { + if !strings.Contains(out, "GrayCodeAI/graycode-skills") { t.Error("expected source repo") } } + +func TestDefaultIndexURLIsPublished(t *testing.T) { + const want = "https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json" + if defaultIndexURL != want { + t.Fatalf("defaultIndexURL = %q, want %q", defaultIndexURL, want) + } + if strings.Contains(defaultIndexURL, "starling") { + t.Errorf("defaultIndexURL still references the renamed starling repo") + } +} + +func TestFetchIndexParsesGeneratedShape(t *testing.T) { + // Byte-for-byte the shape graycode-skills/tools/update_registry.py emits. + const generated = `{ + "version": 1, + "skills": [ + { + "name": "ab-test-setup", + "description": "Plan and design an A/B test", + "category": "testing", + "tags": ["testing"], + "path": "categories/testing/ab-test-setup", + "repo": "GrayCodeAI/graycode-skills", + "file_count": 1, + "has_scripts": false + } + ] +} +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(generated)) + })) + defer srv.Close() + + rc := &RegistryClient{IndexURL: srv.URL, CacheDir: t.TempDir(), client: srv.Client()} + idx, err := rc.FetchIndex() + if err != nil { + t.Fatalf("FetchIndex: %v", err) + } + if len(idx.Skills) != 1 { + t.Fatalf("skills = %d, want 1", len(idx.Skills)) + } + if idx.Skills[0].Repo != "GrayCodeAI/graycode-skills" { + t.Errorf("Repo = %q, want the slug the installer clones from", idx.Skills[0].Repo) + } +} + +func TestDiscoverSkillDirs(t *testing.T) { + tests := []struct { + name string + layout []string + want []string + }{ + {name: "flat layout", layout: []string{"go-review/SKILL.md"}, want: []string{"go-review"}}, + {name: "agentskills.io skills/ layout", layout: []string{"skills/go-review/SKILL.md"}, want: []string{"go-review"}}, + { + name: "graycode-skills categories layout", + layout: []string{"categories/go/go-review/SKILL.md", "categories/python/pandas/SKILL.md"}, + want: []string{"go-review", "pandas"}, + }, + { + name: "ignores vendored and dot directories", + layout: []string{"go-review/SKILL.md", ".git/hooks/SKILL.md", "node_modules/pkg/SKILL.md"}, + want: []string{"go-review"}, + }, + { + name: "ignores a top-level SKILL.md documenting the repo", + layout: []string{"SKILL.md", "categories/go/go-review/SKILL.md"}, + want: []string{"go-review"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, rel := range tc.layout { + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("---\nname: x\n---\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + got, err := discoverSkillDirs(root) + if err != nil { + t.Fatalf("discoverSkillDirs: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("found %d skills %v, want %d %v", len(got), keysOf(got), len(tc.want), tc.want) + } + for _, name := range tc.want { + dir, ok := got[name] + if !ok { + t.Errorf("missing skill %q; got %v", name, keysOf(got)) + continue + } + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Errorf("skill %q maps to %q which has no SKILL.md", name, dir) + } + } + }) + } +} + +func keysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/plugin/skillslock_test.go b/internal/plugin/skillslock_test.go index 390ba148..588725c7 100644 --- a/internal/plugin/skillslock_test.go +++ b/internal/plugin/skillslock_test.go @@ -17,7 +17,7 @@ func TestSkillsLockRoundTrip(t *testing.T) { } lock.Set("go-review", SkillsLockEntry{ - Source: "GrayCodeAI/starling", + Source: "GrayCodeAI/graycode-skills", SourceType: "github", SkillPath: "skills/go-review/SKILL.md", Commit: "abc123", diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index 09e8f4b3..b8af22b7 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -216,14 +216,6 @@ func TestNoDirectLowerGraycodeRouterImports(t *testing.T) { "github.com/GrayCodeAI/graycode-router/tools": continue } - // The gateway package is Graycode's single GraycodeRouter boundary; it may - // import graycode-router/credentials to declare Graycode's OS keychain service - // name (the host-neutral default would otherwise orphan existing - // secrets). All other production code must use graycode-router/engine only. - if strings.HasPrefix(rel, "internal/provider/gateway/") && - path == "github.com/GrayCodeAI/graycode-router/credentials" { - continue - } pos := pf.FSet.Position(imp.Pos()) t.Fatalf("forbidden lower-level GraycodeRouter import %q at %s:%d; use github.com/GrayCodeAI/graycode-router/engine", path, rel, pos.Line) } diff --git a/internal/testaudit/package_boundaries_test.go b/internal/testaudit/package_boundaries_test.go index 6ff57391..2ed9026d 100644 --- a/internal/testaudit/package_boundaries_test.go +++ b/internal/testaudit/package_boundaries_test.go @@ -55,14 +55,6 @@ func checkGraycodeGraycodeRouterFacade(t *testing.T, root string) { case graycodeRouterModule + "/llm", graycodeRouterModule + "/graph", graycodeRouterModule + "/tools": continue } - // Graycode's gateway declares the credential service name so existing - // keychain entries remain compatible. It is the only non-engine - // production exception. - relFile, relErr := filepath.Rel(root, imp.file) - if relErr == nil && filepath.ToSlash(filepath.Dir(relFile)) == "internal/provider/gateway" && - imp.path == graycodeRouterModule+"/credentials" { - continue - } violations = append(violations, formatImportViolation(root, imp, "use the graycode-router/engine facade")) } } diff --git a/internal/theme/tint.go b/internal/theme/tint.go new file mode 100644 index 00000000..8712432d --- /dev/null +++ b/internal/theme/tint.go @@ -0,0 +1,47 @@ +// tint.go — Plain-text colorization for non-TUI output (CLI reports, status). +// +// Unlike the TUI, CLI reports are rendered as plain strings and later printed +// by the command layer. This helper lets internal report formatters colorize +// labels and statuses without depending on the cmd package, honoring the same +// NO_COLOR / FORCE_COLOR / terminal-detection contract as cmd's ShouldColor. + +package theme + +import ( + "image/color" + "os" + + lipgloss "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ColorEnabled reports whether ANSI color should be emitted. NO_COLOR wins, +// then FORCE_COLOR, then terminal detection on stdout. +func ColorEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if os.Getenv("FORCE_COLOR") != "" { + return true + } + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// Tint colors s for terminal display, honoring ColorEnabled. Returns s +// unchanged when color is disabled or s is empty. +func Tint(s string, c color.Color) string { + if !ColorEnabled() || s == "" { + return s + } + return lipgloss.NewStyle().Foreground(c).Render(s) +} + +// Semantic report colors — fixed brand values, legible on both light and dark +// terminals. Used by internal report formatters that colorize statuses. +var ( + ReportSuccess = lipgloss.Color("#4CAF50") // green + ReportWarn = lipgloss.Color("#FFB347") // amber + ReportError = lipgloss.Color("#FF6B6B") // coral + ReportInfo = lipgloss.Color("#75B1E2") // sky + ReportMuted = lipgloss.Color("#9E9E9E") // gray +) diff --git a/internal/theme/tint_test.go b/internal/theme/tint_test.go new file mode 100644 index 00000000..47df9ba6 --- /dev/null +++ b/internal/theme/tint_test.go @@ -0,0 +1,53 @@ +package theme + +import ( + "strings" + "testing" +) + +func TestColorEnabled_RespectsEnv(t *testing.T) { + t.Run("NO_COLOR disables", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "") + if ColorEnabled() { + t.Fatal("ColorEnabled() = true with NO_COLOR set") + } + }) + t.Run("FORCE_COLOR enables despite non-TTY", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + if !ColorEnabled() { + t.Fatal("ColorEnabled() = false with FORCE_COLOR set") + } + }) + t.Run("NO_COLOR wins over FORCE_COLOR", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + if ColorEnabled() { + t.Fatal("ColorEnabled() = true when both NO_COLOR and FORCE_COLOR set") + } + }) +} + +func TestTint(t *testing.T) { + t.Run("plain when disabled", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := Tint("hello", ReportSuccess); got != "hello" { + t.Fatalf("Tint = %q, want %q", got, "hello") + } + }) + t.Run("empty stays empty", func(t *testing.T) { + t.Setenv("FORCE_COLOR", "1") + if got := Tint("", ReportSuccess); got != "" { + t.Fatalf("Tint(\"\") = %q, want empty", got) + } + }) + t.Run("wraps in ANSI when enabled", func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("FORCE_COLOR", "1") + got := Tint("ready", ReportSuccess) + if !strings.Contains(got, "\x1b[") || !strings.Contains(got, "ready") { + t.Fatalf("Tint = %q, want ANSI-wrapped text", got) + } + }) +} diff --git a/internal/tool/core_memory.go b/internal/tool/core_memory.go index 40de729b..c85369e5 100644 --- a/internal/tool/core_memory.go +++ b/internal/tool/core_memory.go @@ -38,7 +38,7 @@ func (CoreMemoryAppendTool) Execute(ctx context.Context, input json.RawMessage) if tc == nil || tc.HarrierBridge == nil { return "", fmt.Errorf("memory not available") } - if err := tc.HarrierBridge.Remember(p.Content, p.Label); err != nil { + if err := tc.HarrierBridge.Remember(ctx, p.Content, p.Label); err != nil { return "", err } return fmt.Sprintf("Appended to [%s] memory block.", p.Label), nil @@ -138,7 +138,7 @@ func (CoreMemoryRethinkTool) Execute(ctx context.Context, input json.RawMessage) } return fmt.Sprintf("Rewrote [%s] memory block.", p.Label), nil } - if err := tc.HarrierBridge.Remember(p.NewValue, p.Label); err != nil { + if err := tc.HarrierBridge.Remember(ctx, p.NewValue, p.Label); err != nil { return "", err } return fmt.Sprintf("Created new [%s] memory block.", p.Label), nil diff --git a/internal/tool/spec_clarify.go b/internal/tool/spec_clarify.go index 90a74e8a..57ecfa07 100644 --- a/internal/tool/spec_clarify.go +++ b/internal/tool/spec_clarify.go @@ -10,8 +10,15 @@ import ( "strings" "github.com/GrayCodeAI/graycode-cli/internal/spec" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) +// titleCaser replaces the deprecated strings.Title, which mishandles Unicode +// word boundaries. Title case here is applied to ASCII phase/stage/identifier +// names, so language.Und is the correct, dependency-free-of-locale choice. +var titleCaser = cases.Title(language.Und) + type ClarifyTool struct{} func (ClarifyTool) Name() string { return "Clarify" } @@ -164,7 +171,7 @@ func (SpecClarifyTool) Execute(ctx context.Context, input json.RawMessage) (stri } var b strings.Builder - fmt.Fprintf(&b, "## Clarify Phase: %s\n\n", strings.Title(p.Phase)) + fmt.Fprintf(&b, "## Clarify Phase: %s\n\n", titleCaser.String(p.Phase)) fmt.Fprintf(&b, "**%d questions found, %d unresolved**\n\n", len(questions), unresolved) if unresolved > 0 { diff --git a/internal/tool/spec_ground.go b/internal/tool/spec_ground.go index 2d4ee475..7968d80d 100644 --- a/internal/tool/spec_ground.go +++ b/internal/tool/spec_ground.go @@ -62,7 +62,7 @@ func (SpecGroundTool) Execute(ctx context.Context, input json.RawMessage) (strin } var b strings.Builder - fmt.Fprintf(&b, "## Context Grounding: %s Stage\n\n", strings.Title(p.Stage)) + fmt.Fprintf(&b, "## Context Grounding: %s Stage\n\n", titleCaser.String(p.Stage)) switch p.Stage { case "specify": diff --git a/internal/tool/spec_testgen.go b/internal/tool/spec_testgen.go index 5ac0effb..6514d4bc 100644 --- a/internal/tool/spec_testgen.go +++ b/internal/tool/spec_testgen.go @@ -112,7 +112,7 @@ func (SpecTestGenTool) Execute(ctx context.Context, input json.RawMessage) (stri func goTestName(reqID string) string { name := strings.ReplaceAll(reqID, "-", "_") name = strings.ReplaceAll(name, ".", "_") - return strings.Title(name) + return titleCaser.String(name) } func detectLanguageForTests(dir string) string { diff --git a/internal/types/severity.go b/internal/types/severity.go index 1ee3a0cd..3e2e85f2 100644 --- a/internal/types/severity.go +++ b/internal/types/severity.go @@ -18,8 +18,8 @@ const ( SeverityCritical = contracts.SeverityCritical ) -// ParseSeverity converts a string to a Severity. -var ParseSeverity = contracts.ParseSeverity +// ParseSeverityStrict is available directly from internal/contracts/types; +// the deprecated fail-open ParseSeverity alias was removed as a footgun. // TokenSeverity defines rule severity for compression error patterns. type TokenSeverity = contracts.TokenSeverity diff --git a/lefthook.yml b/lefthook.yml index e95fd7fc..d08eebaf 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -118,6 +118,9 @@ pre-push: boundary-graycode-router-client: run: bash scripts/check-graycode-router-client-imports.sh + boundary-graycode-router-engine: + run: bash scripts/check-graycode-router-engine-boundary.sh + boundary-support-repo: run: bash scripts/check-support-repo-coupling.sh diff --git a/scripts/check-graycode-router-engine-boundary.sh b/scripts/check-graycode-router-engine-boundary.sh index 9ff97843..402dbeb1 100755 --- a/scripts/check-graycode-router-engine-boundary.sh +++ b/scripts/check-graycode-router-engine-boundary.sh @@ -15,8 +15,9 @@ else '"github\.com/GrayCodeAI/graycode-router/[^\"]+"' . || true )" fi -# Graycode uses the full vendored GraycodeRouter API surface for provider, graph, and -# tooling contracts that the engine facade does not re-export. +# Host contract surface is exactly four packages: engine (facade), llm (DTOs +# and the Provider port), graph (portable graph vocabulary), tools (tool-call +# contracts). See graycode-router/README.md "Ecosystem Boundaries". violations="$(printf '%s\n' "$graycoderouter_imports" | grep -vE '"github\.com/GrayCodeAI/graycode-router/(engine|llm|graph|tools)(/|\")' || true)" if [[ -n "$violations" ]]; then diff --git a/scripts/check-support-repo-coupling.sh b/scripts/check-support-repo-coupling.sh index 55be27e7..8a802eed 100644 --- a/scripts/check-support-repo-coupling.sh +++ b/scripts/check-support-repo-coupling.sh @@ -27,6 +27,14 @@ scan_dir() { fi done + if [[ ${#peers[@]} -eq 0 ]]; then + # No sibling engines to check against. Building a regex from an empty + # peer list produced 'github\.com/GrayCodeAI/()(/|")', which matches + # nothing meaningful and made this guard silently unfailable. + echo "peer guard: ${owner} has no sibling engines to check" + return + fi + pattern="$(IFS='|'; echo "${peers[*]}")" hits="$( grep -RInE --include='*.go' "github\\.com/GrayCodeAI/(${pattern})(/|\")" "${dir}" || true