Skip to content

Commit ab50c39

Browse files
committed
snapshot
1 parent 7687079 commit ab50c39

File tree

10 files changed

+239
-50
lines changed

10 files changed

+239
-50
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Gradient Engineer — 60‑Second Linux Analysis (Nix + LLM)
2+
3+
Run the classic [“60‑second Linux Performance Analysis”](https://netflixtechblog.com/linux-performance-analysis-in-60-000-milliseconds-accc10403c55) checklist in one command. A portable Nix toolbox is downloaded on the fly, diagnostics run in parallel with a simple TUI, and an optional AI summary is shown at the end.
4+
5+
- One command to run it all
6+
- Fast and portable
7+
- No Docker, no system‑wide installs
8+
- Optional AI summary
9+
10+
## Quick start
11+
12+
```bash
13+
# Any provider works; set one of these env vars to enable AI summary
14+
export ANTHROPIC_API_KEY="<your Anthropic API key>" # Anthropic
15+
export OPENAI_API_KEY="<your OpenAI API key>" # OpenAI
16+
export OPENROUTER_API_KEY="<your OpenRouter API key>" # OpenRouter
17+
18+
curl -fsSL https://gradient.engineer/60-second-linux.sh | sh
19+
```
20+
21+
Notes:
22+
23+
- If no key is set, diagnostics still run; only the AI summary is skipped.
24+
- TUI controls: Tab toggles details; q / Esc / Ctrl+C quits.
25+
26+
## Build from source
27+
28+
```bash
29+
cd app
30+
go build -o gradient-engineer-go
31+
./gradient-engineer-go
32+
```
33+
34+
## Advanced
35+
36+
- You can override the API base URL via `OPENAI_BASE_URL` (for OpenAI/OpenRouter) if needed.

app/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module gradient-engineer
33
go 1.24
44

55
require (
6+
github.com/anthropics/anthropic-sdk-go v1.9.1
67
github.com/charmbracelet/bubbles v0.21.0
78
github.com/charmbracelet/bubbletea v1.3.6
89
github.com/charmbracelet/glamour v0.10.0

app/go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NT
44
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
55
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
66
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
7+
github.com/anthropics/anthropic-sdk-go v1.9.1 h1:raRhZKmayVSVZtLpLDd6IsMXvxLeeSU03/2IBTerWlg=
8+
github.com/anthropics/anthropic-sdk-go v1.9.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
79
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
810
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
911
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -30,6 +32,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250821175832-f235fab04313 h1:pRcKW
3032
github.com/charmbracelet/x/exp/slice v0.0.0-20250821175832-f235fab04313/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
3133
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
3234
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
35+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
36+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3337
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
3438
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
3539
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -59,10 +63,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
5963
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
6064
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
6165
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
66+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
67+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6268
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
6369
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
6470
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
6571
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
72+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
73+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
6674
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
6775
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
6876
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

app/playbook.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package main
2+
3+
import _ "embed"
4+
5+
//go:embed playbooks/60-second-linux.yaml
6+
var playbook60SecondLinux []byte
7+
8+
func embeddedPlaybook() []byte {
9+
return playbook60SecondLinux
10+
}
11+
12+
// PlaybookConfig mirrors the structure of the embedded playbook YAML.
13+
type PlaybookConfig struct {
14+
Nixpkgs struct {
15+
Version string `yaml:"version"`
16+
Packages []string `yaml:"packages"`
17+
} `yaml:"nixpkgs"`
18+
SystemPrompt string `yaml:"system_prompt,omitempty"`
19+
Commands []PlaybookCommand `yaml:"commands"`
20+
}
21+
22+
// PlaybookCommand describes a single command entry in a playbook.
23+
type PlaybookCommand struct {
24+
Command string `yaml:"command"`
25+
Description string `yaml:"description"`
26+
Sudo bool `yaml:"sudo,omitempty"`
27+
TimeoutSeconds int `yaml:"timeout_seconds,omitempty"`
28+
}
29+
30+

app/playbooks.go

Lines changed: 0 additions & 12 deletions
This file was deleted.

app/playbooks/60-second-linux.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ nixpkgs:
55
- util-linux
66
- procps
77
- sysstat
8+
system_prompt: |
9+
### 60-second Linux analysis
10+
11+
Please analyze the following Linux system diagnostic output. Focus on identifying any performance issues, errors, or notable system characteristics.
12+
13+
- Be concise but comprehensive
14+
- Start with a 1-sentence overview and the key finding
15+
- Prefer bullet points where helpful
16+
- Keep the total length to 2-3 short paragraphs (including bullets)
17+
18+
When recommending actions, prioritize practical steps.
819
commands:
920
- command: 186m66li0jgnsx31rv524y2s871yrfcp-procps-4.0.4/bin/uptime
1021
description: System uptime and load averages

app/summarize.go

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,109 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"strings"
78

89
tea "github.com/charmbracelet/bubbletea"
10+
anthropic "github.com/anthropics/anthropic-sdk-go"
11+
anthopt "github.com/anthropics/anthropic-sdk-go/option"
912
openai "github.com/openai/openai-go"
10-
"github.com/openai/openai-go/option"
13+
openaiopt "github.com/openai/openai-go/option"
1114
)
1215

1316
// SummaryCommand represents a command's description and its captured output
1417
// used for generating an LLM summary.
1518
type SummaryCommand struct {
16-
Description string
19+
Description *PlaybookCommand
1720
Output string
1821
}
1922

2023
// Summarizer encapsulates LLM client configuration used for summarization.
2124
type Summarizer struct {
22-
client openai.Client
23-
model string
25+
provider string
26+
openaiClient openai.Client
27+
anthropicClient anthropic.Client
28+
model string
29+
disabled bool
2430
}
2531

26-
// NewSummarizer constructs a Summarizer with sensible defaults.
32+
// NewSummarizer constructs a Summarizer with provider selection based on env vars.
33+
// Priority:
34+
// - If ANTHROPIC_API_KEY is set, use Anthropic (claude-sonnet-4-0)
35+
// - Else if OPENROUTER_API_KEY is set, use OpenRouter base and that key
36+
// - Else if OPENAI_API_KEY starts with "sk-or-v1-", treat it as an OpenRouter key
37+
// - Else if OPENAI_API_KEY is set, use default OpenAI base and that key
38+
// Base URL can be overridden via OPENAI_BASE_URL for OpenAI/OpenRouter.
2739
func NewSummarizer() *Summarizer {
40+
baseOverride := os.Getenv("OPENAI_BASE_URL")
41+
openRouterKey := os.Getenv("OPENROUTER_API_KEY")
42+
openAIKey := os.Getenv("OPENAI_API_KEY")
43+
anthropicKey := os.Getenv("ANTHROPIC_API_KEY")
44+
45+
// Heuristic: detect OpenRouter key provided via OPENAI_API_KEY
46+
if openRouterKey == "" && strings.HasPrefix(openAIKey, "sk-or-v1-") {
47+
openRouterKey = openAIKey
48+
}
49+
50+
// If no key is provided for any provider, mark summarizer as disabled.
51+
if strings.TrimSpace(anthropicKey) == "" && strings.TrimSpace(openRouterKey) == "" && strings.TrimSpace(openAIKey) == "" {
52+
return &Summarizer{
53+
provider: "none",
54+
model: "",
55+
disabled: true,
56+
}
57+
}
58+
59+
// Anthropic has highest priority if explicitly provided
60+
if strings.TrimSpace(anthropicKey) != "" {
61+
cli := anthropic.NewClient(anthopt.WithAPIKey(anthropicKey))
62+
return &Summarizer{
63+
provider: "anthropic",
64+
anthropicClient: cli,
65+
model: "claude-sonnet-4-0",
66+
disabled: false,
67+
}
68+
}
69+
70+
usingOpenRouter := openRouterKey != ""
71+
72+
// Determine base URL for OpenAI/OpenRouter
73+
baseURL := ""
74+
if baseOverride != "" {
75+
baseURL = baseOverride
76+
} else if usingOpenRouter {
77+
baseURL = "https://openrouter.ai/api/v1"
78+
}
79+
80+
// Build OpenAI client options
81+
var opts []openaiopt.RequestOption
82+
if baseURL != "" {
83+
opts = append(opts, openaiopt.WithBaseURL(baseURL))
84+
}
85+
if usingOpenRouter {
86+
opts = append(opts, openaiopt.WithAPIKey(openRouterKey))
87+
// OpenRouter attribution headers
88+
opts = append(opts,
89+
openaiopt.WithHeader("X-Title", "gradient-engineer"),
90+
openaiopt.WithHeader("HTTP-Referer", "https://github.com/QuesmaOrg/gradient-engineer"),
91+
)
92+
} else if openAIKey != "" {
93+
opts = append(opts, openaiopt.WithAPIKey(openAIKey))
94+
}
95+
96+
cli := openai.NewClient(opts...)
97+
98+
// Choose a model slug compatible with provider
99+
model := "gpt-4.1"
100+
if usingOpenRouter {
101+
model = "openai/gpt-4.1"
102+
}
103+
28104
return &Summarizer{
29-
client: openai.NewClient(option.WithBaseURL("https://openrouter.ai/api/v1")),
30-
model: "openai/gpt-4.1",
105+
provider: "openai",
106+
openaiClient: cli,
107+
model: model,
108+
disabled: false,
31109
}
32110
}
33111

@@ -43,13 +121,43 @@ func (s *Summarizer) Summarize(systemPrompt string, commands []SummaryCommand) (
43121
if strings.TrimSpace(c.Output) == "" {
44122
continue
45123
}
46-
b.WriteString(fmt.Sprintf("Command %d: %s\n", i+1, c.Description))
124+
desc := ""
125+
if c.Description != nil {
126+
desc = c.Description.Description
127+
}
128+
b.WriteString(fmt.Sprintf("Command %d: %s\n", i+1, desc))
47129
b.WriteString(c.Output)
48130
b.WriteString("\n\n")
49131
}
50132
userContent := b.String()
51133

52-
resp, err := s.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
134+
if s.provider == "anthropic" {
135+
// Anthropic Messages API
136+
msg, err := s.anthropicClient.Messages.New(ctx, anthropic.MessageNewParams{
137+
Model: anthropic.Model(s.model),
138+
MaxTokens: 4096,
139+
System: []anthropic.TextBlockParam{
140+
{Text: systemPrompt},
141+
},
142+
Messages: []anthropic.MessageParam{
143+
anthropic.NewUserMessage(anthropic.NewTextBlock(userContent)),
144+
},
145+
})
146+
if err != nil {
147+
return "", err
148+
}
149+
// Concatenate text blocks
150+
var out strings.Builder
151+
for _, c := range msg.Content {
152+
if c.Type == "text" {
153+
out.WriteString(c.Text)
154+
}
155+
}
156+
return out.String(), nil
157+
}
158+
159+
// OpenAI/OpenRouter path
160+
resp, err := s.openaiClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
53161
Model: s.model,
54162
Messages: []openai.ChatCompletionMessageParamUnion{
55163
openai.SystemMessage(systemPrompt),

0 commit comments

Comments
 (0)