Skip to content

Commit d090227

Browse files
authored
docs: add CLI vs top-20 OSS competitive analysis and gap plans (#299)
* docs: add CLI vs top-20 OSS competitive analysis and gap plans * feat: implement top-20 competitive gap plans and enable ecosystem build Implement the five competitive gap plans and enable the ecosystem workspace build: - Docker onboarding checklist and readiness probes - share command with hosted upload and local deeplink, multi-session visibility - Kitty terminal graphics for image rendering - published benchmarks and content-hash cache - backend wiring for media/STT and engine services - restore sibling engine modules (harrier/shrike/kestrel/merlin/swift) in go.work - ToT/LATS beam search, self-consistency, reflexion, site audit, skills create - format (gofumpt/goimports) and lint (errcheck) clean * fix: make module-mode CI green (no local-only replace directives) The feature branch pointed go.mod at local sibling checkouts (../harrier, ../shrike, ../kestrel, ../merlin, ../swift, ../graycode-router) that do not exist as reachable GitHub repos, so GOWORK=off CI could not resolve them. Restore module-mode compatibility so engines resolve from the committed modproxy (as main does): - Remove local replace directives from go.mod/go.sum (regenerate clean in module mode; no new deps required) - Gate internal/provider/gateway/media_backends.go behind //go:build media_engine (it uses graycode-router GenerateImage/Transcribe facade not in published v0.0.1) and add a no-op stub for normal builds - Revert engine-version-skewed test expectations (smart_reader, consensus) to the published-engine values used by CI - Fix stale golden help file (swift command description) * chore: drop removed engine repos from ecosystem inventory Remove shrike/harrier/kestrel/merlin/swift from ecosystem.yaml — these sibling engine checkouts are not published GitHub repos. They remain resolvable as required modules via the committed modproxy (as main does), so the build and release-parity are unaffected; CI's workspace generation no longer attempts to clone unreachable repos. * docs: fix MD004 ul-style in roadmap and research notes
1 parent aa09cfd commit d090227

65 files changed

Lines changed: 2953 additions & 84 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<a href="#skills">Skills</a> ·
2020
<a href="#tools">Tools</a> ·
2121
<a href="#architecture">Architecture</a> ·
22+
<a href="#performance--benchmarks">Benchmarks</a> ·
2223
<a href="#contributing">Contributing</a>
2324
</p>
2425

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

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

33-
- **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
34+
- **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
3435
- **Zero CGO** — single static binary, cross-compiled for linux/darwin/windows on amd64/arm64
3536
- **Privacy-first** — your code never leaves your machine except to the LLM API you choose
3637
- **Docker-only execution** — agent commands run in an isolated container and
@@ -80,13 +81,16 @@ go build -o graycode ./cmd/graycode
8081
./graycode path
8182
```
8283

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

89-
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.
92+
See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model and
93+
the sandbox checklist. Do not put API keys in shell env or `.env` for graycode.
9094

9195
Optional for contributors:
9296

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

524+
### Performance & Benchmarks
525+
526+
Published, reproducible CPU benchmarks (session save/load, repo-map size/tokens)
527+
live in [docs/BENCHMARKS.md](docs/BENCHMARKS.md), recorded with machine + commit so
528+
numbers are comparable across runs. Reproduce with the existing `go test -bench`
529+
targets there; nothing gates releases on them.
530+
531+
Headline numbers (Go 1.26.6, AMD EPYC 7543P, `go test -bench -benchmem -count=3`):
532+
533+
| Benchmark | Result |
534+
|---|---|
535+
| Session save (1000 msgs) | ~790 µs, 256 KB, 2,056 allocs |
536+
| Session load (1000 msgs) | ~5.3 ms |
537+
| Repo-map generate (100-file tree, 2,500 symbols) | ~2.2 ms, ~21.6k est. tokens, ~364 KB |
538+
| Session save (100 msgs) | ~183 µs, 32 KB, 256 allocs |
539+
520540
### Project Structure
521541

522542
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.

‎cmd/chat_commands_image.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ func (m *chatModel) handleImageCommand(parts []string, text string) (tea.Model,
4646
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
4747
return m, nil
4848
}
49+
// Gap-03: render the image directly to the terminal via Kitty graphics
50+
// when supported; the text placeholder below always remains as the
51+
// sanitized chat record.
52+
if emitted, _ := emitTerminalImage(att); emitted {
53+
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s Rendered image in terminal: %s", icons.Image(), filepath.Base(path))})
54+
}
4955
display := prompt
5056
if display == "" {
5157
display = FormatImageMessage("", path)

‎cmd/chat_model.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ type chatModel struct {
418418
sessionPickerEntries []session.Entry
419419
sessionPickerFiltered []session.Entry
420420
sessionPickerSel int
421+
// sessionPickerDetail caches the selected session's share detail (deeplink +
422+
// export path) so it is computed once per selection, not per render frame.
423+
sessionPickerDetailID string
424+
sessionPickerDetailCached string
421425
}
422426

423427
const streamRenderInterval = 50 * time.Millisecond

‎cmd/chat_session_picker.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,39 @@ func (m *chatModel) renderSessionPickerOverlay(viewWidth int) string {
166166
if len(m.sessionPickerFiltered) > maxVisible {
167167
b.WriteString(sessPickDimStyle.Render(" " + strconv.Itoa(m.sessionPickerSel+1) + "/" + strconv.Itoa(len(m.sessionPickerFiltered)) + " sessions"))
168168
}
169+
170+
// Share detail for the selected session (Gap-02 P1): deeplink + export
171+
// path, cached per selection to avoid reloading on every frame.
172+
if sel := m.sessionPickerFiltered[m.sessionPickerSel]; sel.ID != "" {
173+
b.WriteString("\n")
174+
b.WriteString(m.sessionPickerDetailFor(sel))
175+
}
169176
}
170177

171178
return sessPickBoxStyle.Width(boxWidth).Render(b.String())
172179
}
173180

181+
// sessionPickerDetailFor returns the cached share detail (deeplink + export
182+
// path + model) for a selected session entry. The deeplink is computed once per
183+
// selection and cached on the model.
184+
func (m *chatModel) sessionPickerDetailFor(e session.Entry) string {
185+
if m.sessionPickerDetailID == e.ID && m.sessionPickerDetailCached != "" {
186+
return m.sessionPickerDetailCached
187+
}
188+
var b strings.Builder
189+
if e.Model != "" {
190+
b.WriteString(sessPickDimStyle.Render(" model: "+e.Model) + "\n")
191+
}
192+
b.WriteString(sessPickDimStyle.Render(" export: "+e.ExportPath) + "\n")
193+
if link := session.ShareLinkForID(e.ID); link != "" {
194+
b.WriteString(sessPickDimStyle.Render(" share: " + link))
195+
}
196+
detail := strings.TrimRight(b.String(), "\n")
197+
m.sessionPickerDetailID = e.ID
198+
m.sessionPickerDetailCached = detail
199+
return detail
200+
}
201+
174202
// formatSessionEntry formats a single session entry for display.
175203
// Shows: ID, preview, CWD (shortened), and time-ago.
176204
func formatSessionEntry(e session.Entry, query string, maxWidth int) string {

‎cmd/diagnostics.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/GrayCodeAI/graycode-cli/internal/resilience/health"
1818
"github.com/GrayCodeAI/graycode-cli/internal/session"
1919
"github.com/GrayCodeAI/graycode-cli/internal/storage"
20+
"github.com/GrayCodeAI/graycode-cli/internal/stt"
2021
"github.com/GrayCodeAI/graycode-cli/internal/tool"
2122
"github.com/GrayCodeAI/graycode-cli/internal/ui/icons"
2223
)
@@ -73,6 +74,11 @@ func doctorReport(settings graycodeconfig.Settings) string {
7374
b.WriteString("\n" + graycodeconfig.FormatCatalogHealth(graycodeconfig.CatalogHealthReport(context.Background())) + "\n")
7475
preflight := graycodeconfig.EnginePreflightReportWithSettings(context.Background(), settings, graycodeconfig.EnginePreflightOptions{})
7576
b.WriteString("\n" + graycodeconfig.FormatEnginePreflight(preflight) + "\n")
77+
b.WriteString("\n" + graycodeconfig.FormatSandboxChecklist(graycodeconfig.EvaluateSandboxChecklist(context.Background())) + "\n")
78+
b.WriteString("\nBackends (Gap-05):\n")
79+
b.WriteString(fmt.Sprintf(" media: %s\n", backendStatus(tool.MediaEngineName(), tool.MediaEngineName() != "")))
80+
b.WriteString(fmt.Sprintf(" stt: %s\n", backendStatus("", stt.Enabled())))
81+
b.WriteString(fmt.Sprintf(" computer: %s\n", backendStatus(tool.ComputerBackendName(), tool.ComputerBackendName() != "")))
7682
b.WriteString("\n" + graycodeconfig.CredentialStorageStatus(context.Background()).Formatted + "\n")
7783
if deployReport, err := graycodeconfig.DeploymentStatusReportWithSettings(context.Background(), settings, modelName); err == nil {
7884
b.WriteString("\n" + deployReport + "\n")
@@ -115,6 +121,19 @@ func doctorReport(settings graycodeconfig.Settings) string {
115121
return strings.TrimRight(b.String(), "\n")
116122
}
117123

124+
// backendStatus formats a pluggable backend's wiring state for the doctor
125+
// report (Gap-05 P2). Unwired backends fail safe by default, so the report
126+
// states that explicitly without leaking any credential.
127+
func backendStatus(name string, enabled bool) string {
128+
if !enabled {
129+
return "unwired (default — tool fails safe)"
130+
}
131+
if name == "" {
132+
return "wired"
133+
}
134+
return "wired (" + name + ")"
135+
}
136+
118137
func healthCheckReport(settings graycodeconfig.Settings, provider string) string {
119138
registry := health.NewRegistry()
120139

@@ -261,7 +280,16 @@ func sessionsSummary() string {
261280
if cwd == "" {
262281
cwd = "-"
263282
}
264-
b.WriteString(fmt.Sprintf(" %s %s %s %s\n", auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), cwd, e.Preview))
283+
model := e.Model
284+
if model == "" {
285+
model = "-"
286+
}
287+
export := e.ExportPath
288+
if export == "" {
289+
export = "-"
290+
}
291+
b.WriteString(fmt.Sprintf(" %s %s model=%s cwd=%s\n export: %s\n preview: %s\n",
292+
auditTint(e.ID, textPrimary), e.UpdatedAt.Format("2006-01-02 15:04"), model, cwd, export, e.Preview))
265293
}
266294
return strings.TrimRight(b.String(), "\n")
267295
}

‎cmd/graycode/integration_test.go‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,27 @@ import (
66
"fmt"
77
"net/http"
88
"path/filepath"
9+
"strings"
910
"testing"
1011

1112
"github.com/GrayCodeAI/graycode-cli/internal/provider/routing"
1213
"github.com/GrayCodeAI/graycode-cli/internal/testutil"
14+
"github.com/GrayCodeAI/graycode-cli/internal/token"
1315
"github.com/GrayCodeAI/harrier/engine"
1416
"github.com/GrayCodeAI/harrier/graph"
1517
"github.com/GrayCodeAI/harrier/storage"
16-
"github.com/GrayCodeAI/kestrel"
17-
"github.com/GrayCodeAI/merlin"
18+
kestrelLib "github.com/GrayCodeAI/kestrel"
19+
merlinLib "github.com/GrayCodeAI/merlin"
1820
"github.com/GrayCodeAI/shrike"
1921
)
2022

21-
// mockKestrelProvider implements kestrel.Provider for integration testing.
23+
// mockKestrelProvider implements kestrelLib.Provider for integration testing.
2224
type mockKestrelProvider struct {
2325
response string
2426
}
2527

26-
func (m *mockKestrelProvider) Chat(_ context.Context, _ []kestrel.Message, _ kestrel.ChatOpts) (*kestrel.Response, error) {
27-
return &kestrel.Response{Content: m.response, TokensUsed: 100}, nil
28+
func (m *mockKestrelProvider) Chat(_ context.Context, _ []kestrelLib.Message, _ kestrelLib.ChatOpts) (*kestrelLib.Response, error) {
29+
return &kestrelLib.Response{Content: m.response, TokensUsed: 100}, nil
2830
}
2931

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

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

5661
ctx := context.Background()
57-
result, err := kestrel.Review(ctx, diff, kestrel.WithProvider(provider))
62+
result, err := kestrelLib.Review(ctx, diff, kestrelLib.WithProvider(provider))
5863
if err != nil {
59-
t.Fatalf("kestrel.Review failed: %v", err)
64+
t.Fatalf("kestrelLib.Review failed: %v", err)
6065
}
6166
if result == nil {
6267
t.Fatal("expected non-nil result")
@@ -104,6 +109,9 @@ func TestIntegration_KestrelReviewStoreRecall(t *testing.T) {
104109
}
105110

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

121129
// 2. Run merlin scan
122130
ctx := context.Background()
123-
report, err := merlin.Scan(ctx, ts.URL, merlin.Quick)
131+
report, err := merlinLib.Scan(ctx, ts.URL, merlinLib.Quick)
124132
if err != nil {
125-
t.Fatalf("merlin.Scan failed: %v", err)
133+
t.Fatalf("merlinLib.Scan failed: %v", err)
126134
}
127135
if report == nil {
128136
t.Fatal("expected non-nil report")
@@ -154,6 +162,9 @@ func TestIntegration_MerlinScanHTTPTest(t *testing.T) {
154162
}
155163

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

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

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

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

268-
report, err := merlin.Scan(ctx, ts.URL, merlin.Quick)
282+
report, err := merlinLib.Scan(ctx, ts.URL, merlinLib.Quick)
269283
if err != nil {
270-
t.Fatalf("merlin.Scan: %v", err)
284+
t.Fatalf("merlinLib.Scan: %v", err)
271285
}
272286

273287
// 6. Store merlin result

‎cmd/image.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import (
55
"compress/zlib"
66
"encoding/base64"
77
"fmt"
8+
"image"
89
"io"
910
"mime"
1011
"os"
1112
"path/filepath"
1213
"regexp"
1314
"strings"
1415

16+
"github.com/GrayCodeAI/graycode-cli/internal/tui"
1517
"github.com/GrayCodeAI/graycode-cli/internal/ui/icons"
1618
)
1719

@@ -72,6 +74,27 @@ func ReadImageBytes(data []byte, mimeType string) *ImageAttachment {
7274
}
7375
}
7476

77+
// emitTerminalImage renders a PNG image to the terminal via the Kitty graphics
78+
// protocol when the active terminal supports it (Gap-03). It returns true when
79+
// the image was emitted so callers can annotate the message; false when the
80+
// terminal lacks support (or the image is not a PNG) and the caller keeps its
81+
// existing text rendering. Emission is best-effort — a failure never fails the
82+
// turn and never touches the sanitized chat viewport.
83+
func emitTerminalImage(att *ImageAttachment) (bool, error) {
84+
if att == nil || att.MIMEType != "image/png" {
85+
return false, nil
86+
}
87+
data, err := base64.StdEncoding.DecodeString(att.Base64)
88+
if err != nil {
89+
return false, err
90+
}
91+
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
92+
if err != nil {
93+
return false, err
94+
}
95+
return tui.EmitPNG(os.Stderr, data, cfg.Width, cfg.Height)
96+
}
97+
7598
// isImageExtension returns true if the extension is a supported image format.
7699
func isImageExtension(ext string) bool {
77100
switch ext {

0 commit comments

Comments
 (0)