Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| Linux (Ubuntu/Debian) | apt | Supported |
| Linux (Arch) | pacman | Supported |
| Linux (Fedora/RHEL family) | dnf | Supported |
| Linux (NixOS) | nix | Supported |
| Windows 10/11 | Scoop | Supported |

Derivatives are detected via `ID_LIKE` in `/etc/os-release` (Linux Mint, Pop!_OS, Manjaro, EndeavourOS, CentOS Stream, Rocky Linux, AlmaLinux, etc.).
Expand Down
15 changes: 13 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -793,15 +793,26 @@ func (s componentApplyStep) Run() error {
engramCommand = binaryPath
} else if installedPath, err := cmdLookPath("engram"); err != nil {
// Engram not on PATH — install it.
if s.profile.PackageManager == "brew" {
// macOS (or Linux with Homebrew): use brew tap + brew install.
if s.profile.PackageManager == "brew" || s.profile.PackageManager == "nix" {
// macOS/Brew or NixOS: use component resolver. NixOS installs from source via go install.
commands, err := engram.InstallCommand(s.profile)
if err != nil {
return fmt.Errorf("resolve install command for component %q: %w", s.component, err)
}
if err := runCommandSequence(commands); err != nil {
return err
}
if s.profile.PackageManager == "nix" {
// Prepends GOBIN/GOPATH bin to path so subsequent commands find the new binary.
if binDir, err := goInstallBinDirFromGoEnv(); err == nil && binDir != "" {
_ = prependToPath(binDir)
binaryName := "engram"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
engramCommand = filepath.Join(binDir, binaryName)
}
}
Comment on lines +805 to +815

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'func prependToPath|func addUserPath|func goInstallBinDirFromGoEnv' -g '*.go' internal/cli

Repository: Gentleman-Programming/gentle-ai

Length of output: 306


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- around goInstallBinDirFromGoEnv / prependToPath ---'
sed -n '300,390p' internal/cli/run.go

echo '--- around nix/download branches ---'
sed -n '780,860p' internal/cli/run.go

echo '--- addUserPath search ---'
rg -n 'addUserPath|prependToPath|goInstallBinDirFromGoEnv' -g '*.go' .

Repository: Gentleman-Programming/gentle-ai

Length of output: 7629


Propagate goInstallBinDirFromGoEnv() failures here
internal/cli/run.go:805-815
If go env can’t resolve the install bin dir, this branch silently leaves engramCommand as "engram" even though the binary was just installed outside PATH. The follow-up engram setup call can then fail with no useful hint. Return an error or warn like the beta install path does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/run.go` around lines 805 - 815, The nix package-manager branch
currently suppresses failures from goInstallBinDirFromGoEnv, leaving
engramCommand unresolved and causing an opaque setup failure. Update the
handling around goInstallBinDirFromGoEnv to propagate the error or emit a
warning consistent with the beta install path, while preserving the existing
PATH and executable resolution behavior on success.

} else {
// Linux / Windows: download the pre-built binary from GitHub Releases.
// No Go required — engram ships pre-built binaries.
Expand Down
63 changes: 62 additions & 1 deletion internal/cli/run_engram_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -501,7 +502,11 @@ func TestRunInstallMacOSEngramStillUsesBrew(t *testing.T) {
func TestRunInstallBetaEngramUsesMainGoInstallAndInstalledBinary(t *testing.T) {
home := t.TempDir()
gobin := filepath.Join(home, "go-bin")
betaEngram := filepath.Join(gobin, "engram")
betaEngramName := "engram"
if runtime.GOOS == "windows" {
betaEngramName += ".exe"
}
betaEngram := filepath.Join(gobin, betaEngramName)

restoreCommand := runCommand
restoreLookPath := cmdLookPath
Expand Down Expand Up @@ -554,5 +559,61 @@ func TestRunInstallBetaEngramUsesMainGoInstallAndInstalledBinary(t *testing.T) {
}
}

// TestRunInstallNixOSEngramUsesGoInstall verifies NixOS uses go install.
func TestRunInstallNixOSEngramUsesGoInstall(t *testing.T) {
home := t.TempDir()
restoreHome := osUserHomeDir
restoreCommand := runCommand
restoreLookPath := cmdLookPath
restoreGoEnv := goEnv
t.Cleanup(func() {
osUserHomeDir = restoreHome
runCommand = restoreCommand
cmdLookPath = restoreLookPath
goEnv = restoreGoEnv
})

osUserHomeDir = func() (string, error) { return home, nil }
cmdLookPath = missingBinaryLookPath
gobin := filepath.Join(home, "go-bin")
goEnv = func(keys ...string) (map[string]string, error) {
return map[string]string{"GOBIN": gobin, "GOPATH": filepath.Join(home, "go")}, nil
}
recorder := &commandRecorder{}
runCommand = recorder.record

// DownloadFn should NOT be called for NixOS (go install handles it).
origDownloadFn := engramDownloadFn
engramDownloadFn = func(profile system.PlatformProfile) (string, error) {
t.Error("DownloadLatestBinary should NOT be called on NixOS (go install handles it)")
return "", nil
}
t.Cleanup(func() { engramDownloadFn = origDownloadFn })

detection := linuxDetectionResult(system.LinuxDistroNixOS, "nix")
result, err := RunInstall(
[]string{"--agent", "opencode", "--component", "engram"},
detection,
)
if err != nil {
t.Fatalf("RunInstall() error = %v", err)
}
if !result.Verify.Ready {
t.Fatalf("verification ready = false")
}

// Must use go install.
commands := recorder.get()
foundGoInstall := false
for _, cmd := range commands {
if strings.Contains(cmd, "go install github.com/Gentleman-Programming/engram/cmd/engram@latest") {
foundGoInstall = true
}
}
if !foundGoInstall {
t.Fatalf("expected go install on NixOS, got commands: %v", commands)
}
}
Comment on lines +562 to +616

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for the goInstallBinDirFromGoEnv failure path.

This test only exercises the happy path where goEnv succeeds and returns a valid GOBIN. Given the silent-failure gap flagged in internal/cli/run.go (Lines 805-815), consider adding a case where goEnv returns an error or empty values to confirm the install either surfaces a clear warning or still resolves engram correctly, rather than silently leaving engramCommand unusable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/run_engram_download_test.go` around lines 562 - 616, Add a test
case for the goInstallBinDirFromGoEnv failure path used by RunInstall,
configuring goEnv to return an error or empty GOBIN/GOPATH values. Assert that
installation either emits the expected warning or falls back to a usable engram
command and completes verification without silently leaving engramCommand
invalid, while preserving the existing happy-path coverage in
TestRunInstallNixOSEngramUsesGoInstall.


// Make sure the engram package's DownloadLatestBinary is accessible.
var _ = engram.DownloadLatestBinary
9 changes: 9 additions & 0 deletions internal/components/gga/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gga
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -119,6 +120,10 @@ func TestBuildConfigDifferentProviders(t *testing.T) {
func TestInjectWritesConfigAndAgents(t *testing.T) {
home := t.TempDir()

if runtime.GOOS == "windows" {
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
}

result, err := Inject(home, []model.AgentID{model.AgentClaudeCode})
if err != nil {
t.Fatalf("Inject() error = %v", err)
Expand Down Expand Up @@ -163,6 +168,10 @@ func TestInjectWritesConfigAndAgents(t *testing.T) {
func TestInjectIsIdempotent(t *testing.T) {
home := t.TempDir()

if runtime.GOOS == "windows" {
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
}

first, err := Inject(home, []model.AgentID{model.AgentOpenCode})
if err != nil {
t.Fatalf("Inject() first error = %v", err)
Expand Down
9 changes: 9 additions & 0 deletions internal/components/gga/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ func TestInstallCommandByProfile(t *testing.T) {
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "nixos uses git clone and install.sh",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"},
want: [][]string{
{"rm", "-rf", "/tmp/gentleman-guardian-angel"},
{"git", "clone", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", "/tmp/gentleman-guardian-angel"},
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "unsupported package manager returns error",
profile: system.PlatformProfile{
Expand Down
8 changes: 7 additions & 1 deletion internal/installcmd/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func resolveGGAInstall(profile system.PlatformProfile) (CommandSequence, error)
{"brew", "tap", "Gentleman-Programming/homebrew-tap"},
{"brew", "reinstall", "gga"},
}, nil
case "apt", "pacman", "dnf":
case "apt", "pacman", "dnf", "nix":
const tmpDir = "/tmp/gentleman-guardian-angel"
return CommandSequence{
{"rm", "-rf", tmpDir},
Expand Down Expand Up @@ -402,6 +402,12 @@ func resolveEngramInstall(profile system.PlatformProfile) (CommandSequence, erro
{"brew", "tap", "Gentleman-Programming/homebrew-tap"},
{"brew", "install", "engram"},
}, nil
case "nix":
// NixOS requires installing from source via go install because pre-built release
// binaries fail to run natively due to hardcoded dynamic linker paths.
return CommandSequence{
{"go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"},
}, nil
Comment on lines +405 to +410

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'validateGoForModuleInstall' -g '*.go'

Repository: Gentleman-Programming/gentle-ai

Length of output: 169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- resolver.go outline ---'
ast-grep outline internal/installcmd/resolver.go --view expanded || true

echo
echo '--- relevant symbols and strings ---'
rg -n 'validateGo|go install|1\.24|NixOS|nix' internal/installcmd -g '*.go' || true

echo
echo '--- resolver.go around nix branch ---'
sed -n '320,440p' internal/installcmd/resolver.go

Repository: Gentleman-Programming/gentle-ai

Length of output: 8844


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- resolveEngramInstall usages ---'
rg -n 'resolveEngramInstall\(' -g '*.go' .

echo
echo '--- ResolveComponentInstall / dependency paths ---'
sed -n '180,245p' internal/installcmd/resolver.go

echo
echo '--- preflight helpers around validateGoForModuleInstall ---'
sed -n '110,180p' internal/installcmd/resolver.go

echo
echo '--- engram-related tests ---'
sed -n '600,635p' internal/installcmd/resolver_test.go

Repository: Gentleman-Programming/gentle-ai

Length of output: 7196


Add the Go preflight to the Nix install path. resolveEngramInstall already has validateGoForModuleInstall, but the nix branch bypasses it and returns go install unconditionally. Calling the helper here would surface the same actionable Go 1.24+/PATH error instead of a raw exec failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/installcmd/resolver.go` around lines 405 - 410, Update the "nix"
branch in resolveEngramInstall to call validateGoForModuleInstall before
returning the go install CommandSequence, propagating any validation error and
only constructing the command when Go satisfies the required version and PATH
checks.

default:
return nil, fmt.Errorf(
"engram on %q/%q uses direct binary download — use engram.DownloadLatestBinary() instead of CommandSequence",
Expand Down
16 changes: 16 additions & 0 deletions internal/installcmd/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ func TestResolveComponentInstall(t *testing.T) {
component: model.ComponentEngram,
wantErr: true,
},
{
name: "engram on nixos uses go install",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"},
component: model.ComponentEngram,
want: CommandSequence{{"go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"}},
},
{
name: "gga on darwin uses brew tap and reinstall",
profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew"},
Expand Down Expand Up @@ -647,6 +653,16 @@ func TestResolveComponentInstall(t *testing.T) {
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "gga on nixos uses git clone and install.sh",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"},
component: model.ComponentGGA,
want: CommandSequence{
{"rm", "-rf", "/tmp/gentleman-guardian-angel"},
{"git", "clone", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", "/tmp/gentleman-guardian-angel"},
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "engram on windows returns error (uses DownloadLatestBinary instead)",
profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"},
Expand Down
40 changes: 37 additions & 3 deletions internal/system/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
LinuxDistroDebian = "debian"
LinuxDistroArch = "arch"
LinuxDistroFedora = "fedora"
LinuxDistroNixOS = "nixos"
)

type DetectionResult struct {
Expand All @@ -50,7 +51,7 @@ func Detect(ctx context.Context) (DetectionResult, error) {
return DetectionResult{}, err
}

tools := DetectTools(ctx, []string{"git", "curl", "brew", "node", "go"})
tools := DetectTools(ctx, []string{"git", "curl", "brew", "node", "go", "nix"})
configs := ScanConfigs(homeDir)
osReleaseContent, _ := osReleaseContent(runtime.GOOS)

Expand Down Expand Up @@ -131,6 +132,13 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt
distro := detectLinuxDistro(linuxOSRelease)
profile.LinuxDistro = distro

// NixOS should always resolve to nix, even if Homebrew is also installed.
if distro == LinuxDistroNixOS {
profile.PackageManager = "nix"
profile.Supported = true
return profile
}

// Check if brew is available on Linux
if brew, ok := tools["brew"]; ok && brew.Installed {
profile.PackageManager = "brew"
Expand All @@ -148,9 +156,17 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt
case LinuxDistroFedora:
profile.PackageManager = "dnf"
profile.Supported = true
case LinuxDistroNixOS:
profile.PackageManager = "nix"
profile.Supported = true
default:
profile.PackageManager = ""
profile.Supported = false
if nix, ok := tools["nix"]; ok && nix.Installed {
profile.PackageManager = "nix"
profile.Supported = true
} else {
profile.PackageManager = ""
profile.Supported = false
}
}

return profile
Expand Down Expand Up @@ -204,6 +220,10 @@ func detectLinuxDistro(linuxOSRelease string) string {
return LinuxDistroFedora
}

if isNixOSLike(id, idLike) {
return LinuxDistroNixOS
}

return LinuxDistroUnknown
}

Expand Down Expand Up @@ -248,3 +268,17 @@ func isFedoraLike(id, idLike string) bool {

return false
}

func isNixOSLike(id, idLike string) bool {
if id == LinuxDistroNixOS {
return true
}

for _, token := range strings.Fields(idLike) {
if token == LinuxDistroNixOS {
return true
}
}

return false
}
80 changes: 80 additions & 0 deletions internal/system/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,81 @@ func TestDetectFromInputsMarksArchSupported(t *testing.T) {
}
}

func TestDetectFromInputsMarksNixOSSupported(t *testing.T) {
osRelease := "ID=nixos\n"
result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil)

if !result.System.Supported {
t.Fatalf("expected nixos linux to be supported")
}

if result.System.Profile.LinuxDistro != LinuxDistroNixOS {
t.Fatalf("expected nixos distro, got %q", result.System.Profile.LinuxDistro)
}

if result.System.Profile.PackageManager != "nix" {
t.Fatalf("expected nix package manager, got %q", result.System.Profile.PackageManager)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestDetectFromInputsMarksNixOSLikeSupported(t *testing.T) {
osRelease := "ID=custom-distro\nID_LIKE=nixos\n"
result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil)

if !result.System.Supported {
t.Fatalf("expected nixos-like linux to be supported")
}

if result.System.Profile.LinuxDistro != LinuxDistroNixOS {
t.Fatalf("expected nixos distro for ID_LIKE=nixos, got %q", result.System.Profile.LinuxDistro)
}
}

func TestDetectFromInputsPrefersBrewOverNixOnUbuntu(t *testing.T) {
tools := map[string]ToolStatus{
"nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"},
"brew": {Name: "brew", Installed: true, Path: "/home/linuxbrew/.linuxbrew/bin/brew"},
}
result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=ubuntu\n", tools, nil)

if result.System.Profile.PackageManager != "brew" {
t.Fatalf("expected brew package manager when both nix and brew installed on Ubuntu, got %q", result.System.Profile.PackageManager)
}
}

func TestDetectFromInputsPrefersAptOverNixOnUbuntu(t *testing.T) {
tools := map[string]ToolStatus{
"nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"},
}
result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=ubuntu\n", tools, nil)

if result.System.Profile.PackageManager != "apt" {
t.Fatalf("expected apt package manager when only nix is installed on Ubuntu, got %q", result.System.Profile.PackageManager)
}
}

func TestDetectFromInputsFallsBackToNixOnUnsupportedLinux(t *testing.T) {
tools := map[string]ToolStatus{
"nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"},
}
result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=alpine\n", tools, nil)

if result.System.Profile.PackageManager != "nix" {
t.Fatalf("expected nix package manager fallback on unsupported Linux with nix installed, got %q", result.System.Profile.PackageManager)
}
}

func TestDetectFromInputsNixOSPrefersNixOverBrew(t *testing.T) {
tools := map[string]ToolStatus{
"brew": {Name: "brew", Installed: true, Path: "/home/linuxbrew/.linuxbrew/bin/brew"},
}
result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=nixos\n", tools, nil)

if result.System.Profile.PackageManager != "nix" {
t.Fatalf("expected nix package manager for NixOS even when brew is installed, got %q", result.System.Profile.PackageManager)
}
}

// --- Batch E: Comprehensive platform detection matrix ---

func TestDetectLinuxDistroMatrix(t *testing.T) {
Expand All @@ -98,6 +173,11 @@ func TestDetectLinuxDistroMatrix(t *testing.T) {
osRelease string
wantDistro string
}{
{
name: "nixos",
osRelease: "ID=nixos\n",
wantDistro: LinuxDistroNixOS,
},
{
name: "ubuntu 22.04",
osRelease: "ID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"22.04\"\n",
Expand Down
Loading