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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,10 @@ Features adopted from open-source agent projects. All are off by default unless
| X/Twitter search | `SearchX` tool | Live X search by forwarding a query to an xAI endpoint with server-side search; returns a cited summary. Requires `XAI_API_KEY` (or `GROK_API_KEY`) |
| Desktop computer-use | `ComputerUse` tool | snapshot/click/type/scroll/press/screenshot via a pluggable `tool.SetComputerBackend` seam (host wires a native macOS accessibility backend) |
| Token-cheaper file views | `Read` tool `--minify` | Read-only, comment-stripped, whitespace-dense file view (Go via `go/parser`; other languages string-aware; never touches disk) — fewer tokens per read |
| Classified provider hints | `internal/errhint` | Buckets provider errors (Auth/RateLimit/Connectivity/ModelNotFound/ContextOverflow) into a one-line fixable next step |
| Atomic install transactions | `internal/installtxn` | Cross-process staged install/remove with rollback (plugin/skill install paths) |
| Stale-lock reclaim | `internal/lockutil` | Race-correct atomic reclaim of O_EXCL lock files with live-restore |
| Test command discovery | `internal/testrunner` | Auto-detect test/verify commands (Go/npm/bun/pnpm/yarn/pytest/cargo) and parse runner output into structured results |
| Classified provider hints | `internal/errhint` | Buckets provider errors (Auth/RateLimit/Connectivity/ModelNotFound/ContextOverflow) into a one-line fixable next step. Wired into TUI error rows (`friendlyErrorMessage`) and `hawk exec` CLI errors |
| Atomic install transactions | `internal/installtxn` | Cross-process staged install/remove with rollback. Wired into skill install (atomic `SKILL.md` publish) |
| Stale-lock reclaim | `internal/lockutil` | Race-correct atomic reclaim of O_EXCL lock files with live-restore (ready for O_EXCL lock sites) |
| Test command discovery | `internal/testrunner` | Auto-detect test/verify commands (Go/npm/bun/pnpm/yarn/pytest/cargo) and parse runner output into structured results. Wired into `hawk verify` |

## Usage

Expand Down
8 changes: 8 additions & 0 deletions cmd/error_classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/errhint"
"github.com/GrayCodeAI/hawk/internal/hawkerr"
)

Expand Down Expand Up @@ -41,5 +42,12 @@ func friendlyErrorMessage(err error) string {
msg += "\n The request took too long. Try again, or use /model to switch to a faster provider."
}

// Provider-specific one-line hint. errhint.Classify is deliberately
// conservative (gates on a provider-origin marker), so local errors draw no
// hint here.
if h := errhint.TUIHint(err); h != "" {
msg += "\n " + h
}

return msg
}
26 changes: 26 additions & 0 deletions cmd/error_classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cmd

import (
"errors"
"strings"
"testing"
)

func TestFriendlyErrorMessageAppendsProviderHint(t *testing.T) {
// A provider-origin error should get an errhint next-step appended.
msg := friendlyErrorMessage(errors.New("provider error: 401 unauthorized"))
if !strings.Contains(msg, "provider") && !strings.Contains(strings.ToLower(msg), "api key") {
t.Fatalf("expected provider hint in message, got: %q", msg)
}
}

func TestFriendlyErrorMessageNoHintForLocalError(t *testing.T) {
// A local error must not draw an errhint provider hint (hawk's own
// ExitNotFound enrichment is separate and fine).
msg := friendlyErrorMessage(errors.New("file not found: x"))
for _, marker := range []string{"API key rejected", "Rate limited", "Can't reach the provider", "Context window full", "Model unavailable"} {
if strings.Contains(msg, marker) {
t.Fatalf("local error drew errhint hint %q: %q", marker, msg)
}
}
}
5 changes: 5 additions & 0 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
Expand All @@ -14,6 +15,7 @@ import (

hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
"github.com/GrayCodeAI/hawk/internal/engine"
"github.com/GrayCodeAI/hawk/internal/errhint"
"github.com/GrayCodeAI/hawk/internal/multiagent/agents"
"github.com/GrayCodeAI/hawk/internal/notify"
"github.com/GrayCodeAI/hawk/internal/observability/logger"
Expand Down Expand Up @@ -321,6 +323,9 @@ func runExec(_ *cobra.Command, args []string) error {
execErr = ev.Content
if execOutputFormat == "text" {
_, _ = fmt.Fprintf(os.Stderr, "\nerror: %s\n", ev.Content)
if h := errhint.CLIHint(errors.New(ev.Content)); h != "" {
_, _ = fmt.Fprintf(os.Stderr, " hint: %s\n", h)
}
}
if execOutputFormat == "stream-json" {
_ = jsonEnc.Encode(map[string]interface{}{
Expand Down
65 changes: 65 additions & 0 deletions cmd/verify_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package cmd
import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/GrayCodeAI/hawk/internal/governance"
"github.com/GrayCodeAI/hawk/internal/securitylog"
"github.com/GrayCodeAI/hawk/internal/testrunner"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -43,6 +46,16 @@ Exits non-zero on the first failed check.`,
cmd.Printf("[OK] governance policy: valid (%s)\n", policyPath)
}

// 3. Project test/verify checks discovered from the workspace.
for _, c := range runWorkspaceChecks() {
if c.Err != nil {
ok = false
cmd.Printf("[FAIL] %s: %v\n", c.Name, c.Err)
continue
}
cmd.Printf("[OK] %s: %s\n", c.Name, c.Detail)
}

if !ok {
return fmt.Errorf("verification failed — see messages above")
}
Expand All @@ -51,6 +64,58 @@ Exits non-zero on the first failed check.`,
},
}

// workspaceCheckResult is one discovered test/verify check's outcome.
type workspaceCheckResult struct {
Name string
Detail string
Err error
}

// runWorkspaceChecks detects project test/verify commands via testrunner and
// runs them, parsing runner output into a structured summary. It is purely
// additive: a project without a detected runner yields no checks.
func runWorkspaceChecks() []workspaceCheckResult {
checks, err := testrunner.Detect(".")
if err != nil {
return []workspaceCheckResult{{Name: "project checks", Err: fmt.Errorf("detect: %w", err)}}
}
var results []workspaceCheckResult
for _, c := range checks {
run := exec.Command(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()
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()))})
continue
}
if summary != nil {
detail := fmt.Sprintf("%d/%d passed (%d failed, %d skipped)", summary.Passed, summary.Total, summary.Failed, summary.Skipped)
for _, f := range summary.Failures {
if f.File != "" {
detail += fmt.Sprintf("\n %s: %s (%s)", f.Name, f.Message, f.File)
} else {
detail += fmt.Sprintf("\n %s", f.Name)
}
}
if runErr != nil {
results = append(results, workspaceCheckResult{Name: c.Name, Detail: detail, Err: fmt.Errorf("%w", runErr)})
} else {
results = append(results, workspaceCheckResult{Name: c.Name, Detail: detail})
}
continue
}
if runErr != nil {
results = append(results, workspaceCheckResult{Name: c.Name, Err: fmt.Errorf("%w", runErr)})
} else {
results = append(results, workspaceCheckResult{Name: c.Name, Detail: "ok"})
}
}
return results
}

func init() {
rootCmd.AddCommand(verifyCmd)
}
48 changes: 48 additions & 0 deletions cmd/verify_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestRunWorkspaceChecksDetectsGoProject(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go not on PATH")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/checktest\n\ngo 1.26\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package checktest\n"), 0o644); err != nil {
t.Fatal(err)
}
// runWorkspaceChecks detects from ".", so run it with the working dir set.
t.Chdir(dir)

results := runWorkspaceChecks()
if len(results) == 0 {
t.Fatal("expected at least one discovered check for a Go project")
}
found := false
for _, r := range results {
if r.Name == "Go tests" {
found = true
}
}
if !found {
t.Fatalf("expected a 'Go tests' check, got %+v", results)
}
}

func TestRunWorkspaceChecksEmptyDir(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// An empty dir has no detected runner; Detect returns no checks.
for _, r := range runWorkspaceChecks() {
if r.Err != nil {
t.Fatalf("unexpected error in empty dir: %v", r.Err)
}
}
}
3 changes: 2 additions & 1 deletion internal/plugin/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"time"

"github.com/GrayCodeAI/hawk/internal/installtxn"
"github.com/GrayCodeAI/hawk/internal/storage"
)

Expand Down Expand Up @@ -303,7 +304,7 @@ func (rc *RegistryClient) Install(repo, skillName, scope string) (string, error)
installed = append(installed, name)
}

if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), []byte(content), 0o600); err != nil {
if err := installtxn.WriteFileAtomically(filepath.Join(destDir, "SKILL.md"), []byte(content), 0o600); err != nil {
continue
}
}
Expand Down
Loading