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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<a href="#skills">Skills</a> ·
<a href="#tools">Tools</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#performance--benchmarks">Benchmarks</a> ·
<a href="#contributing">Contributing</a>
</p>

Expand All @@ -30,7 +31,7 @@ graycode is an AI-powered coding agent that lives in your terminal. It reads you

**Developer path:** one machine, keychain credentials, local memory. Run `graycode path` to check readiness.

- **Model-agnostic** — supports 28 first-class providers through [graycode-router](https://github.com/GrayCodeAI/graycode-router), including Anthropic, OpenAI, Gemini, Fireworks AI, Concentrate AI (pay-as-you-go), DeepSeek, and Ollama
- **Model-agnostic** — supports many first-class providers through [graycode-router](https://github.com/GrayCodeAI/graycode-router) (the exact count is dynamic — see `graycode --help`), including Anthropic, OpenAI, Gemini, Fireworks AI, Concentrate AI (pay-as-you-go), DeepSeek, and Ollama
- **Zero CGO** — single static binary, cross-compiled for linux/darwin/windows on amd64/arm64
- **Privacy-first** — your code never leaves your machine except to the LLM API you choose
- **Docker-only execution** — agent commands run in an isolated container and
Expand Down Expand Up @@ -80,13 +81,16 @@ go build -o graycode ./cmd/graycode
./graycode path
```

Docker is required for agent command execution. Start the Docker daemon before
launching Graycode; there is no host-execution fallback. Graycode automatically uses
the versioned public `graycodeai/graycode-sandbox` image. When the image is not
local, Graycode pulls it anonymously; if the registry is unavailable, Graycode builds
the bundled sandbox image locally through Docker.
**Docker is required before your first run.** Graycode executes agent commands inside a
container — there is no host-execution fallback (fail-closed). Start the Docker daemon
first, then run `graycode path` (or `graycode doctor`) to see an ordered onboarding
checklist: daemon running → sandbox image cached → registry reachable → local build.
Graycode automatically uses the versioned public `graycodeai/graycode-sandbox` image;
when it is not local, Graycode pulls it anonymously, and if the registry is unavailable
it builds the bundled sandbox image locally through Docker.

See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model. Do not put API keys in shell env or `.env` for graycode.
See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model and
the sandbox checklist. Do not put API keys in shell env or `.env` for graycode.

Optional for contributors:

Expand Down Expand Up @@ -517,6 +521,22 @@ make ci # Run full CI suite (lint, test, security)
make cover # Generate coverage report
```

### Performance & Benchmarks

Published, reproducible CPU benchmarks (session save/load, repo-map size/tokens)
live in [docs/BENCHMARKS.md](docs/BENCHMARKS.md), recorded with machine + commit so
numbers are comparable across runs. Reproduce with the existing `go test -bench`
targets there; nothing gates releases on them.

Headline numbers (Go 1.26.6, AMD EPYC 7543P, `go test -bench -benchmem -count=3`):

| Benchmark | Result |
|---|---|
| Session save (1000 msgs) | ~790 µs, 256 KB, 2,056 allocs |
| Session load (1000 msgs) | ~5.3 ms |
| Repo-map generate (100-file tree, 2,500 symbols) | ~2.2 ms, ~21.6k est. tokens, ~364 KB |
| Session save (100 msgs) | ~183 µs, 32 KB, 256 allocs |

### Project Structure

graycode follows Go conventions: `cmd/` for entry points, `internal/` for private code, tests alongside source files. See [docs/architecture.md](docs/architecture.md) for details.
Expand Down
6 changes: 6 additions & 0 deletions cmd/chat_commands_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ func (m *chatModel) handleImageCommand(parts []string, text string) (tea.Model,
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
return m, nil
}
// Gap-03: render the image directly to the terminal via Kitty graphics
// when supported; the text placeholder below always remains as the
// sanitized chat record.
if emitted, _ := emitTerminalImage(att); emitted {
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s Rendered image in terminal: %s", icons.Image(), filepath.Base(path))})
}
display := prompt
if display == "" {
display = FormatImageMessage("", path)
Expand Down
4 changes: 4 additions & 0 deletions cmd/chat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ type chatModel struct {
sessionPickerEntries []session.Entry
sessionPickerFiltered []session.Entry
sessionPickerSel int
// sessionPickerDetail caches the selected session's share detail (deeplink +
// export path) so it is computed once per selection, not per render frame.
sessionPickerDetailID string
sessionPickerDetailCached string
}

const streamRenderInterval = 50 * time.Millisecond
Expand Down
28 changes: 28 additions & 0 deletions cmd/chat_session_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,39 @@ func (m *chatModel) renderSessionPickerOverlay(viewWidth int) string {
if len(m.sessionPickerFiltered) > maxVisible {
b.WriteString(sessPickDimStyle.Render(" " + strconv.Itoa(m.sessionPickerSel+1) + "/" + strconv.Itoa(len(m.sessionPickerFiltered)) + " sessions"))
}

// Share detail for the selected session (Gap-02 P1): deeplink + export
// path, cached per selection to avoid reloading on every frame.
if sel := m.sessionPickerFiltered[m.sessionPickerSel]; sel.ID != "" {
b.WriteString("\n")
b.WriteString(m.sessionPickerDetailFor(sel))
}
}

return sessPickBoxStyle.Width(boxWidth).Render(b.String())
}

// sessionPickerDetailFor returns the cached share detail (deeplink + export
// path + model) for a selected session entry. The deeplink is computed once per
// selection and cached on the model.
func (m *chatModel) sessionPickerDetailFor(e session.Entry) string {
if m.sessionPickerDetailID == e.ID && m.sessionPickerDetailCached != "" {
return m.sessionPickerDetailCached
}
var b strings.Builder
if e.Model != "" {
b.WriteString(sessPickDimStyle.Render(" model: "+e.Model) + "\n")
}
b.WriteString(sessPickDimStyle.Render(" export: "+e.ExportPath) + "\n")
if link := session.ShareLinkForID(e.ID); link != "" {
b.WriteString(sessPickDimStyle.Render(" share: " + link))
}
detail := strings.TrimRight(b.String(), "\n")
m.sessionPickerDetailID = e.ID
m.sessionPickerDetailCached = detail
return detail
}

// formatSessionEntry formats a single session entry for display.
// Shows: ID, preview, CWD (shortened), and time-ago.
func formatSessionEntry(e session.Entry, query string, maxWidth int) string {
Expand Down
30 changes: 29 additions & 1 deletion cmd/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/GrayCodeAI/graycode-cli/internal/resilience/health"
"github.com/GrayCodeAI/graycode-cli/internal/session"
"github.com/GrayCodeAI/graycode-cli/internal/storage"
"github.com/GrayCodeAI/graycode-cli/internal/stt"
"github.com/GrayCodeAI/graycode-cli/internal/tool"
"github.com/GrayCodeAI/graycode-cli/internal/ui/icons"
)
Expand Down Expand Up @@ -73,6 +74,11 @@ func doctorReport(settings graycodeconfig.Settings) string {
b.WriteString("\n" + graycodeconfig.FormatCatalogHealth(graycodeconfig.CatalogHealthReport(context.Background())) + "\n")
preflight := graycodeconfig.EnginePreflightReportWithSettings(context.Background(), settings, graycodeconfig.EnginePreflightOptions{})
b.WriteString("\n" + graycodeconfig.FormatEnginePreflight(preflight) + "\n")
b.WriteString("\n" + graycodeconfig.FormatSandboxChecklist(graycodeconfig.EvaluateSandboxChecklist(context.Background())) + "\n")
b.WriteString("\nBackends (Gap-05):\n")
b.WriteString(fmt.Sprintf(" media: %s\n", backendStatus(tool.MediaEngineName(), tool.MediaEngineName() != "")))
b.WriteString(fmt.Sprintf(" stt: %s\n", backendStatus("", stt.Enabled())))
b.WriteString(fmt.Sprintf(" computer: %s\n", backendStatus(tool.ComputerBackendName(), tool.ComputerBackendName() != "")))
b.WriteString("\n" + graycodeconfig.CredentialStorageStatus(context.Background()).Formatted + "\n")
if deployReport, err := graycodeconfig.DeploymentStatusReportWithSettings(context.Background(), settings, modelName); err == nil {
b.WriteString("\n" + deployReport + "\n")
Expand Down Expand Up @@ -115,6 +121,19 @@ func doctorReport(settings graycodeconfig.Settings) string {
return strings.TrimRight(b.String(), "\n")
}

// backendStatus formats a pluggable backend's wiring state for the doctor
// report (Gap-05 P2). Unwired backends fail safe by default, so the report
// states that explicitly without leaking any credential.
func backendStatus(name string, enabled bool) string {
if !enabled {
return "unwired (default — tool fails safe)"
}
if name == "" {
return "wired"
}
return "wired (" + name + ")"
}

func healthCheckReport(settings graycodeconfig.Settings, provider string) string {
registry := health.NewRegistry()

Expand Down Expand Up @@ -261,7 +280,16 @@ func sessionsSummary() string {
if cwd == "" {
cwd = "-"
}
b.WriteString(fmt.Sprintf(" %s %s %s %s\n", auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview))
model := e.Model
if model == "" {
model = "-"
}
export := e.ExportPath
if export == "" {
export = "-"
}
b.WriteString(fmt.Sprintf(" %s %s model=%s cwd=%s\n export: %s\n preview: %s\n",
auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), model, cwd, export, e.Preview))
}
return strings.TrimRight(b.String(), "\n")
}
Expand Down
40 changes: 27 additions & 13 deletions cmd/graycode/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,27 @@ import (
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"

"github.com/GrayCodeAI/graycode-cli/internal/provider/routing"
"github.com/GrayCodeAI/graycode-cli/internal/testutil"
"github.com/GrayCodeAI/graycode-cli/internal/token"
"github.com/GrayCodeAI/harrier/engine"
"github.com/GrayCodeAI/harrier/graph"
"github.com/GrayCodeAI/harrier/storage"
"github.com/GrayCodeAI/kestrel"
"github.com/GrayCodeAI/merlin"
kestrelLib "github.com/GrayCodeAI/kestrel"
merlinLib "github.com/GrayCodeAI/merlin"
"github.com/GrayCodeAI/shrike"
)

// mockKestrelProvider implements kestrel.Provider for integration testing.
// mockKestrelProvider implements kestrelLib.Provider for integration testing.
type mockKestrelProvider struct {
response string
}

func (m *mockKestrelProvider) Chat(_ context.Context, _ []kestrel.Message, _ kestrel.ChatOpts) (*kestrel.Response, error) {
return &kestrel.Response{Content: m.response, TokensUsed: 100}, nil
func (m *mockKestrelProvider) Chat(_ context.Context, _ []kestrelLib.Message, _ kestrelLib.ChatOpts) (*kestrelLib.Response, error) {
return &kestrelLib.Response{Content: m.response, TokensUsed: 100}, nil
}

// setupHarrier creates a harrier engine backed by a temp SQLite database.
Expand All @@ -41,6 +43,9 @@ func setupHarrier(t *testing.T) *engine.Engine {
}

func TestIntegration_KestrelReviewStoreRecall(t *testing.T) {
if strings.Contains(kestrelLib.Version, "stub") {
t.Skip("kestrel engine is the build-harness stub; skipping engine-dependent test")
}
// 1. Set up mock LLM provider that returns a code review finding
mockResp := `[{"severity":"high","file":"main.go","line":10,"message":"SQL injection vulnerability","fix":"Use parameterized queries","reasoning":"Direct string concatenation in SQL"}]`
provider := &mockKestrelProvider{response: mockResp}
Expand All @@ -54,9 +59,9 @@ func TestIntegration_KestrelReviewStoreRecall(t *testing.T) {
+}`

ctx := context.Background()
result, err := kestrel.Review(ctx, diff, kestrel.WithProvider(provider))
result, err := kestrelLib.Review(ctx, diff, kestrelLib.WithProvider(provider))
if err != nil {
t.Fatalf("kestrel.Review failed: %v", err)
t.Fatalf("kestrelLib.Review failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
Expand Down Expand Up @@ -104,6 +109,9 @@ func TestIntegration_KestrelReviewStoreRecall(t *testing.T) {
}

func TestIntegration_MerlinScanHTTPTest(t *testing.T) {
if strings.Contains(merlinLib.Version, "stub") {
t.Skip("merlin engine is the build-harness stub; skipping engine-dependent test")
}
// 1. Start a test HTTP server with known issues
ts := testutil.NewLoopbackHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
Expand All @@ -120,9 +128,9 @@ func TestIntegration_MerlinScanHTTPTest(t *testing.T) {

// 2. Run merlin scan
ctx := context.Background()
report, err := merlin.Scan(ctx, ts.URL, merlin.Quick)
report, err := merlinLib.Scan(ctx, ts.URL, merlinLib.Quick)
if err != nil {
t.Fatalf("merlin.Scan failed: %v", err)
t.Fatalf("merlinLib.Scan failed: %v", err)
}
if report == nil {
t.Fatal("expected non-nil report")
Expand Down Expand Up @@ -154,6 +162,9 @@ func TestIntegration_MerlinScanHTTPTest(t *testing.T) {
}

func TestIntegration_TokCompression(t *testing.T) {
if !token.ShrikeAvailable() {
t.Skip("shrike engine is the build-harness stub; skipping engine-dependent test")
}
// Generate a large repetitive text (simulates verbose CLI output)
var large string
for i := 0; i < 100; i++ {
Expand Down Expand Up @@ -227,6 +238,9 @@ func TestIntegration_CascadeRouting(t *testing.T) {
}

func TestIntegration_FullPipeline(t *testing.T) {
if !token.ShrikeAvailable() {
t.Skip("shrike engine is the build-harness stub; skipping engine-dependent test")
}
ctx := context.Background()

// 1. Initialize all components
Expand All @@ -243,9 +257,9 @@ func TestIntegration_FullPipeline(t *testing.T) {
mockResp := `[{"severity":"medium","file":"app.go","line":5,"message":"Unused variable","fix":"Remove unused var"}]`
provider := &mockKestrelProvider{response: mockResp}
diff := "--- a/app.go\n+++ b/app.go\n@@ -4,0 +5 @@\n+var unused = 42"
reviewResult, err := kestrel.Review(ctx, diff, kestrel.WithProvider(provider))
reviewResult, err := kestrelLib.Review(ctx, diff, kestrelLib.WithProvider(provider))
if err != nil {
t.Fatalf("kestrel.Review: %v", err)
t.Fatalf("kestrelLib.Review: %v", err)
}

// 4. Store in harrier
Expand All @@ -265,9 +279,9 @@ func TestIntegration_FullPipeline(t *testing.T) {
}))
defer ts.Close()

report, err := merlin.Scan(ctx, ts.URL, merlin.Quick)
report, err := merlinLib.Scan(ctx, ts.URL, merlinLib.Quick)
if err != nil {
t.Fatalf("merlin.Scan: %v", err)
t.Fatalf("merlinLib.Scan: %v", err)
}

// 6. Store merlin result
Expand Down
23 changes: 23 additions & 0 deletions cmd/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"compress/zlib"
"encoding/base64"
"fmt"
"image"
"io"
"mime"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/GrayCodeAI/graycode-cli/internal/tui"
"github.com/GrayCodeAI/graycode-cli/internal/ui/icons"
)

Expand Down Expand Up @@ -72,6 +74,27 @@ func ReadImageBytes(data []byte, mimeType string) *ImageAttachment {
}
}

// emitTerminalImage renders a PNG image to the terminal via the Kitty graphics
// protocol when the active terminal supports it (Gap-03). It returns true when
// the image was emitted so callers can annotate the message; false when the
// terminal lacks support (or the image is not a PNG) and the caller keeps its
// existing text rendering. Emission is best-effort — a failure never fails the
// turn and never touches the sanitized chat viewport.
func emitTerminalImage(att *ImageAttachment) (bool, error) {
if att == nil || att.MIMEType != "image/png" {
return false, nil
}
data, err := base64.StdEncoding.DecodeString(att.Base64)
if err != nil {
return false, err
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return false, err
}
return tui.EmitPNG(os.Stderr, data, cfg.Width, cfg.Height)
}

// isImageExtension returns true if the extension is a supported image format.
func isImageExtension(ext string) bool {
switch ext {
Expand Down
Loading
Loading