From ad41b9a8e49aa09b0e8c1aaf6a963d56b890c840 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 24 Aug 2026 11:59:22 +0530 Subject: [PATCH] feat(wire): integrate errhint, testrunner, installtxn into hawk --- README.md | 8 ++--- cmd/error_classify.go | 8 +++++ cmd/error_classify_test.go | 26 +++++++++++++++ cmd/exec.go | 5 +++ cmd/verify_cmd.go | 65 +++++++++++++++++++++++++++++++++++++ cmd/verify_cmd_test.go | 48 +++++++++++++++++++++++++++ internal/plugin/registry.go | 3 +- 7 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 cmd/error_classify_test.go create mode 100644 cmd/verify_cmd_test.go diff --git a/README.md b/README.md index 38c07bfe..22fabd0b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/error_classify.go b/cmd/error_classify.go index dcb3599c..e5216247 100644 --- a/cmd/error_classify.go +++ b/cmd/error_classify.go @@ -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" ) @@ -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 } diff --git a/cmd/error_classify_test.go b/cmd/error_classify_test.go new file mode 100644 index 00000000..82eca742 --- /dev/null +++ b/cmd/error_classify_test.go @@ -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) + } + } +} diff --git a/cmd/exec.go b/cmd/exec.go index 3bbe398e..195cf13a 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -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" @@ -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{}{ diff --git a/cmd/verify_cmd.go b/cmd/verify_cmd.go index c7883d41..d1f4db28 100644 --- a/cmd/verify_cmd.go +++ b/cmd/verify_cmd.go @@ -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" ) @@ -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") } @@ -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) } diff --git a/cmd/verify_cmd_test.go b/cmd/verify_cmd_test.go new file mode 100644 index 00000000..57318e8b --- /dev/null +++ b/cmd/verify_cmd_test.go @@ -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) + } + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index bd9abb94..ebd6741e 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/GrayCodeAI/hawk/internal/installtxn" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -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 } }