[agent/claude:sonnet] Implement the plan at docs/plans/2026-03-18-absorb-sail-prog... #3

Merged
Virgil merged 1 commit from agent/implement-the-plan-at-docs-plans-2026-03 into main 2026-03-18 15:07:18 +00:00
3 changed files with 210 additions and 0 deletions

View file

@ -0,0 +1,62 @@
# Plan: Absorb Sail Program
**Date:** 2026-03-18
**Branch:** agent/implement-the-plan-at-docs-plans-2026-03
## Overview
Add a `Program` struct to the `process` package that locates a named binary on PATH and provides lightweight run helpers. This absorbs the "sail program" pattern — a simple way to find and invoke a known CLI tool without wiring the full Core IPC machinery.
## API
```go
// Program represents a named executable located on the system PATH.
type Program struct {
Name string // binary name, e.g. "go", "node"
Path string // absolute path resolved by Find()
}
// Find resolves the program's absolute path via exec.LookPath.
func (p *Program) Find() error
// Run executes the program with args in the current working directory.
// Returns combined stdout+stderr output and any error.
func (p *Program) Run(ctx context.Context, args ...string) (string, error)
// RunDir executes the program with args in dir.
// Returns combined stdout+stderr output and any error.
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)
```
## Tasks
### Task 1: Implement Program in program.go
- Create `program.go` in the root `process` package
- Add `ErrProgramNotFound` sentinel error using `coreerr.E`
- Add `Program` struct with exported `Name` and `Path` fields
- Implement `Find() error` using `exec.LookPath`; if `Name` is empty return error
- Implement `RunDir(ctx, dir, args...) (string, error)` using `exec.CommandContext`
- Capture combined stdout+stderr into a `bytes.Buffer`
- Set `cmd.Dir` if `dir` is non-empty
- Wrap run errors with `coreerr.E`
- Trim trailing whitespace from output
- Implement `Run(ctx, args...) (string, error)` as `p.RunDir(ctx, "", args...)`
- Commit: `feat(process): add Program struct`
### Task 2: Write tests in program_test.go
- Create `program_test.go` in the root `process` package
- Test `Find()` succeeds for a binary that exists on PATH (`echo`)
- Test `Find()` fails for a binary that does not exist
- Test `Run()` executes and returns output
- Test `RunDir()` runs in the specified directory (verify via `pwd` or `ls`)
- Test `Run()` before `Find()` still works (falls back to `Name`)
- Commit: `test(process): add Program tests`
## Acceptance Criteria
- `go test ./...` passes with zero failures
- No `fmt.Errorf` or `errors.New` — only `coreerr.E`
- `Program` is in the root `process` package (not exec subpackage)
- `Run` delegates to `RunDir` — no duplication

68
program.go Normal file
View file

@ -0,0 +1,68 @@
package process
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
coreerr "forge.lthn.ai/core/go-log"
)
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
// Callers may use errors.Is to detect this condition.
var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil)
// Program represents a named executable located on the system PATH.
// Create one with a Name, call Find to resolve its path, then Run or RunDir.
type Program struct {
// Name is the binary name (e.g. "go", "node", "git").
Name string
// Path is the absolute path resolved by Find.
// If empty, Run and RunDir fall back to Name for OS PATH resolution.
Path string
}
// Find resolves the program's absolute path using exec.LookPath.
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
func (p *Program) Find() error {
if p.Name == "" {
return coreerr.E("Program.Find", "program name is empty", nil)
}
path, err := exec.LookPath(p.Name)
if err != nil {
return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound)
}
p.Path = path
return nil
}
// Run executes the program with args in the current working directory.
// Returns trimmed combined stdout+stderr output and any error.
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
return p.RunDir(ctx, "", args...)
}
// RunDir executes the program with args in dir.
// Returns trimmed combined stdout+stderr output and any error.
// If dir is empty, the process inherits the caller's working directory.
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) {
binary := p.Path
if binary == "" {
binary = p.Name
}
var out bytes.Buffer
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Stdout = &out
cmd.Stderr = &out
if dir != "" {
cmd.Dir = dir
}
if err := cmd.Run(); err != nil {
return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err)
}
return strings.TrimSpace(out.String()), nil
}

80
program_test.go Normal file
View file

@ -0,0 +1,80 @@
package process_test
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
process "forge.lthn.ai/core/go-process"
)
func testCtx(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
return ctx
}
func TestProgram_Find_KnownBinary(t *testing.T) {
p := &process.Program{Name: "echo"}
require.NoError(t, p.Find())
assert.NotEmpty(t, p.Path)
}
func TestProgram_Find_UnknownBinary(t *testing.T) {
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
err := p.Find()
require.Error(t, err)
assert.ErrorIs(t, err, process.ErrProgramNotFound)
}
func TestProgram_Find_EmptyName(t *testing.T) {
p := &process.Program{}
require.Error(t, p.Find())
}
func TestProgram_Run_ReturnsOutput(t *testing.T) {
p := &process.Program{Name: "echo"}
require.NoError(t, p.Find())
out, err := p.Run(testCtx(t), "hello")
require.NoError(t, err)
assert.Equal(t, "hello", out)
}
func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
p := &process.Program{Name: "echo"}
out, err := p.Run(testCtx(t), "fallback")
require.NoError(t, err)
assert.Equal(t, "fallback", out)
}
func TestProgram_RunDir_UsesDirectory(t *testing.T) {
p := &process.Program{Name: "pwd"}
require.NoError(t, p.Find())
dir := t.TempDir()
out, err := p.RunDir(testCtx(t), dir)
require.NoError(t, err)
// Resolve symlinks on both sides for portability (macOS uses /private/ prefix).
canonicalDir, err := filepath.EvalSymlinks(dir)
require.NoError(t, err)
canonicalOut, err := filepath.EvalSymlinks(out)
require.NoError(t, err)
assert.Equal(t, canonicalDir, canonicalOut)
}
func TestProgram_Run_FailingCommand(t *testing.T) {
p := &process.Program{Name: "false"}
require.NoError(t, p.Find())
_, err := p.Run(testCtx(t))
require.Error(t, err)
}