diff --git a/internal/skills/dep_installer.go b/internal/skills/dep_installer.go index 140b6b40..fe8f596f 100644 --- a/internal/skills/dep_installer.go +++ b/internal/skills/dep_installer.go @@ -73,6 +73,9 @@ func InstallSingleDep(ctx context.Context, dep string) (bool, string) { if err != nil { msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) slog.Error("skills: dep install failed", "dep", dep, "error", msg) + if hint := pipBuildFailHint(pkg, string(out)); hint != "" { + slog.Warn("skills: dep install hint", "dep", dep, "hint", hint) + } return false, msg } case strings.HasPrefix(dep, "npm:"): @@ -141,6 +144,9 @@ func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string) cmd := exec.CommandContext(ctx, "pip3", "install", "--no-cache-dir", "--break-system-packages", pkg) if out, err := cmd.CombinedOutput(); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("pip %s: %s (%v)", pkg, strings.TrimSpace(string(out)), err)) + if hint := pipBuildFailHint(pkg, string(out)); hint != "" { + slog.Warn("skills: dep install hint", "pkg", pkg, "hint", hint) + } } else { successful = append(successful, pkg) } diff --git a/internal/skills/dep_installer_hint.go b/internal/skills/dep_installer_hint.go new file mode 100644 index 00000000..f0b59d0a --- /dev/null +++ b/internal/skills/dep_installer_hint.go @@ -0,0 +1,32 @@ +package skills + +import "strings" + +// pipBuildFailHint returns a human-readable hint when a pip install fails +// due to a build-from-source error (missing system lib or compiler). +// Returns "" when no known pattern matches the combined stdout+stderr. +// +// Hints are log-side only — never returned to callers / HTTP responses — +// so they can safely suggest alternatives without altering install semantics. +// There is no auto-retry: failing packages typically have no wheel at all, +// and a retry would burn the 5-minute install timeout uselessly. +func pipBuildFailHint(pkg, combinedOutput string) string { + out := combinedOutput + if !strings.Contains(out, "Failed building wheel") && + !strings.Contains(out, "Could not build wheels") && + !strings.Contains(out, "pg_config") && + !strings.Contains(out, "mysql_config") { + return "" + } + switch { + case strings.Contains(out, "pg_config"): + return "install 'pip:psycopg2-binary' (prebuilt wheel, no pg_config needed) or declare deps: in SKILL.md" + case strings.Contains(out, "mysql_config"): + return "install 'pip:PyMySQL' (pure-Python) or add 'system:mysql-dev' to deps: in SKILL.md" + case strings.Contains(pkg, "psycopg") && !strings.Contains(pkg, "binary"): + return "try 'pip:psycopg[binary]' (v3) or 'pip:psycopg2-binary' (v2) and declare explicitly in SKILL.md deps:" + case strings.Contains(pkg, "crypto") || strings.Contains(pkg, "Crypto"): + return "use 'pip:pycryptodome' (modern successor) and declare in SKILL.md deps:" + } + return "pip failed to build from source — try a '-binary' variant if available, or declare explicit deps: in SKILL.md" +} diff --git a/internal/skills/dep_installer_hint_test.go b/internal/skills/dep_installer_hint_test.go new file mode 100644 index 00000000..4a9e2ce0 --- /dev/null +++ b/internal/skills/dep_installer_hint_test.go @@ -0,0 +1,84 @@ +package skills + +import ( + "strings" + "testing" +) + +func TestPipBuildFailHint(t *testing.T) { + cases := []struct { + name string + pkg string + output string + wantSub string // substring expected in hint; "" = hint must be "" + }{ + { + name: "pg_config psycopg2", + pkg: "psycopg2", + output: "Error: pg_config executable not found. Failed building wheel for psycopg2", + wantSub: "psycopg2-binary", + }, + { + name: "mysql_config mysqlclient", + pkg: "mysqlclient", + output: "mysql_config not found... Failed building wheel for mysqlclient", + wantSub: "PyMySQL", + }, + { + name: "psycopg v3 no binary", + pkg: "psycopg", + output: "Failed building wheel for psycopg", + wantSub: "psycopg[binary]", + }, + { + name: "pycrypto old", + pkg: "pycrypto", + output: "Failed building wheel for pycrypto", + wantSub: "pycryptodome", + }, + { + name: "generic wheel fail", + pkg: "somepkg", + output: "ERROR: Could not build wheels for somepkg", + wantSub: "-binary", + }, + { + name: "success has no hint", + pkg: "requests", + output: "", + wantSub: "", + }, + { + name: "network error not build", + pkg: "foo", + output: "ERROR: Could not find a version that satisfies the requirement foo", + wantSub: "", + }, + { + name: "auth 403 not build", + pkg: "internal-pkg", + output: "ERROR: HTTP 403 Forbidden", + wantSub: "", + }, + { + name: "binary pkg already — no psycopg hint loop", + pkg: "psycopg2-binary", + output: "Failed building wheel for psycopg2-binary", + wantSub: "-binary", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pipBuildFailHint(tc.pkg, tc.output) + if tc.wantSub == "" { + if got != "" { + t.Errorf("expected empty hint, got %q", got) + } + return + } + if !strings.Contains(got, tc.wantSub) { + t.Errorf("hint %q does not contain %q", got, tc.wantSub) + } + }) + } +}