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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ jobs:
go-version-file: go.mod
cache: true

- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: ./...

- name: Check formatting
run: |
unformatted="$(gofmt -l .)"
Expand Down
164 changes: 164 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
version: "2"
run:
tests: true
linters:
enable:
- asasalint
- asciicheck
- bidichk
- containedctx
- copyloopvar
- decorder
- depguard
- dogsled
- durationcheck
- embeddedstructfieldcheck
- errcheck
- errname
- errorlint
- exptostd
- fatcontext
- forbidigo
- ginkgolinter
- gocheckcompilerdirectives
- godoclint
- gochecknoinits
- gochecksumtype
- gocritic
- gosec
- gomoddirectives
- gomodguard_v2
- goprintffuncname
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- intrange
- iotamixing
- loggercheck
- misspell
- mirror
- modernize
- nakedret
- noctx
- nolintlint
- nosprintfhostport
- nilnesserr
- perfsprint
- predeclared
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- staticcheck
- testableexamples
- testifylint
- thelper
- unconvert
- unparam
- unused
- usestdlibvars
- unqueryvet
- usetesting
- whitespace
- wastedassign
- bodyclose
- makezero
- sqlclosecheck
settings:
forbidigo:
forbid:
- pattern: context\.Background()
msg: "do not use context.Background() in tests, use t.Context()"
- pattern: context\.TODO()
msg: "do not use context.TODO() in tests, use t.Context()"
- pattern: os\.MkdirTemp()
msg: "do not use os.MkdirTemp() in tests, use t.TempDir()"
- pattern: os\.Setenv()
msg: "do not use os.Setenv() in tests, use t.Setenv()"
- pattern: os\.Chdir()
msg: "do not use os.Chdir() in tests, use t.Chdir()"
- pattern: fmt\.Print.*()
msg: "do not use fmt.Print() or fmt.Println() in tests"
depguard:
rules:
old_yaml:
files:
- "**/*.go"
deny:
- pkg: gopkg.in/yaml.v3
desc: don't use deprecated gopkg.in/yaml.v3, use github.com/goccy/go-yaml instead
testify_is_for_tests:
files:
- "**/*.go"
- "!**/*_test.go"
deny:
- pkg: github.com/stretchr/testify
desc: don't use testify in production code
gocritic:
disabled-checks:
- dupImport
- hugeParam
- rangeValCopy
- unnamedResult
- appendAssign
enabled-tags:
- diagnostic
- style
- performance
revive:
rules:
- name: exported
arguments:
- checkPrivateReceivers
- name: package-comments
disabled: false
staticcheck:
checks:
- all
gosec:
excludes:
- G104
- G101
- G204
- G304
- G404
- G602
- G702
- G703
- G704
exclusions:
generated: lax
presets:
- comments
- std-error-handling
rules:
- path-except: _test\.go
linters:
- forbidigo
- path: _test\.go
linters:
- gosec
issues:
max-same-issues: 3
formatters:
enable:
- gci
- gofmt
- gofumpt
settings:
gci:
custom-order: true
sections:
- standard
- default
- prefix(github.com/rumpl/harness)
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
gofumpt:
extra-rules: true
4 changes: 2 additions & 2 deletions claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ func normalizeModel(model string) string {
func (p *provider) PrintCommand(prompt string) string {
modelFlag := ""
if p.model != "" {
modelFlag = fmt.Sprintf(" --model %s", harness.ShellEscape(p.model))
modelFlag = " --model " + harness.ShellEscape(p.model)
}
effortFlag := ""
if p.effort != "" {
effortFlag = fmt.Sprintf(" --effort %s", p.effort)
effortFlag = " --effort " + string(p.effort)
}
return fmt.Sprintf(
"claude --print --verbose --dangerously-skip-permissions --include-partial-messages --output-format stream-json%s%s -p %s",
Expand Down
22 changes: 7 additions & 15 deletions claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package claudecode

import (
"encoding/json"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -95,42 +96,42 @@ func TestInteractiveArgs(t *testing.T) {
if args[0] != "claude" {
t.Errorf("args[0] = %q, want claude", args[0])
}
if !contains(args, "claude-sonnet-4-6") || !contains(args, "--model") {
if !slices.Contains(args, "claude-sonnet-4-6") || !slices.Contains(args, "--model") {
t.Errorf("args missing model: %v", args)
}
})

t.Run("strips anthropic provider prefix from model", func(t *testing.T) {
p := New("anthropic/claude-opus-4-6")
args := p.InteractiveArgs("")
if !contains(args, "claude-opus-4-6") {
if !slices.Contains(args, "claude-opus-4-6") {
t.Errorf("args missing normalized model: %v", args)
}
if contains(args, "anthropic/claude-opus-4-6") {
if slices.Contains(args, "anthropic/claude-opus-4-6") {
t.Errorf("args should not pass provider/model form to claude: %v", args)
}
})

t.Run("includes effort when set", func(t *testing.T) {
p := New("claude-opus-4-6", WithEffort(EffortLow))
args := p.InteractiveArgs("")
if !contains(args, "--effort") || !contains(args, "low") {
if !slices.Contains(args, "--effort") || !slices.Contains(args, "low") {
t.Errorf("args missing effort: %v", args)
}
})

t.Run("omits model when empty", func(t *testing.T) {
p := New("")
args := p.InteractiveArgs("")
if contains(args, "--model") {
if slices.Contains(args, "--model") {
t.Errorf("args should not contain model: %v", args)
}
})

t.Run("omits effort when not set", func(t *testing.T) {
p := New("claude-opus-4-6")
args := p.InteractiveArgs("")
if contains(args, "--effort") {
if slices.Contains(args, "--effort") {
t.Errorf("args should not contain --effort: %v", args)
}
})
Expand Down Expand Up @@ -367,15 +368,6 @@ func jsonStr(v any) string {
return string(b)
}

func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}

func assertEqual(t *testing.T, got, want []harness.Event) {
t.Helper()
if len(got) != len(want) {
Expand Down
14 changes: 1 addition & 13 deletions claudecode/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,6 @@ import (
"github.com/rumpl/harness"
)

// parseStreamLine handles a single Claude Code stream-json line without
// retaining cross-line state. Provider.ParseStreamLine uses a stateful parser
// so it can de-duplicate Claude's partial stream events from the full message
// snapshots that follow them.
func parseStreamLine(line string) []harness.Event {
return newParser().parseLine(line)
}

type parser struct {
blocks map[int]*streamBlock
streamedTextSinceAssistant bool
Expand Down Expand Up @@ -340,9 +332,5 @@ func join(ss []string) string {
if len(ss) == 1 {
return ss[0]
}
out := ""
for _, s := range ss {
out += s
}
return out
return strings.Join(ss, "")
}
2 changes: 1 addition & 1 deletion cmd/harness-example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ func main() {
}

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

fmt.Fprintf(os.Stderr, "provider: %s\n", p.Name())
fmt.Fprintf(os.Stderr, "prompt: %s\n\n", prompt)
Expand Down Expand Up @@ -87,6 +86,7 @@ func main() {
}
}
})
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
Expand Down
2 changes: 1 addition & 1 deletion codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (p *provider) Name() string { return "codex" }
func (p *provider) PrintCommand(prompt string) string {
modelFlag := ""
if p.model != "" {
modelFlag = fmt.Sprintf(" -m %s", harness.ShellEscape(p.model))
modelFlag = " -m " + harness.ShellEscape(p.model)
}
return fmt.Sprintf(
"codex exec --json --dangerously-bypass-approvals-and-sandbox%s %s",
Expand Down
14 changes: 3 additions & 11 deletions codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codex

import (
"encoding/json"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -59,15 +60,15 @@ func TestInteractiveArgs(t *testing.T) {
if args[0] != "codex" {
t.Errorf("args[0] = %q, want codex", args[0])
}
if !contains(args, "gpt-5.4-mini") || !contains(args, "--model") {
if !slices.Contains(args, "gpt-5.4-mini") || !slices.Contains(args, "--model") {
t.Errorf("args missing model: %v", args)
}
})

t.Run("omits model when empty", func(t *testing.T) {
p := New("")
args := p.InteractiveArgs("")
if contains(args, "--model") {
if slices.Contains(args, "--model") {
t.Errorf("args should not contain model: %v", args)
}
})
Expand Down Expand Up @@ -233,15 +234,6 @@ func jsonStr(v any) string {
return string(b)
}

func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}

func assertEqual(t *testing.T, got, want []harness.Event) {
t.Helper()
if len(got) != len(want) {
Expand Down
12 changes: 2 additions & 10 deletions dockeragent/dockeragent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dockeragent

import (
"encoding/json"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -53,7 +54,7 @@ func TestInteractiveArgs(t *testing.T) {
if args[0] != "docker-agent" {
t.Errorf("args[0] = %q, want docker-agent", args[0])
}
if !contains(args, "coder") || !contains(args, "--yolo") {
if !slices.Contains(args, "coder") || !slices.Contains(args, "--yolo") {
t.Errorf("args missing image or --yolo: %v", args)
}
}
Expand Down Expand Up @@ -262,15 +263,6 @@ func jsonStr(v any) string {
return string(b)
}

func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}

func assertEqual(t *testing.T, got, want []harness.Event) {
t.Helper()
if len(got) != len(want) {
Expand Down
Loading
Loading