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
65 changes: 56 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,53 @@
# Ship 🚀
# Ship

A CLI that runs agents sequentially.
Ship turns labeled GitHub issues into a pull request by driving **your** local coding agent through a fixed sequence of phases.

Confirm a queue from ship-labeled GitHub issues, then spin agents through an Implement → Review loop for each of the tickets and open a pull request.
It is not an agent itself. You bring Cursor Agent, Claude Code, Codex, or pi. Ship queues the tickets, starts a fresh agent for each phase, and opens the PR in whatever checkout you already have.

**Status:** v0.1.0.

## What “agents in a loop” means

One **Run** of `ship` processes a confirmed queue of tickets **one after another**, and for each ticket it starts **two fresh agent sessions** (Implement, then Review). When the queue is done, Ship starts **one more** agent for the Final phase which reviews the branch and opens a PR.

```
Run (`ship`)
├── pick ordered tickets labeled `ship`
├── for each ticket (one Iteration):
│ ├── Implement → fresh agent: write the work, must commit
│ └── Review → fresh agent: one pass over those commits
└── Final → fresh agent: branch review, green bar, open PR
```

Each phase is a **new** agent process with a built-in prompt. Implement commits the ticket; Review may fix or leave the commits alone; Final must open a pull request.

It is recommended to run ship in a seperate worktree. I personnaly use [treehouse](https://github.com/kunchenguid/treehouse) to do manage them.

## Prerequisites

- Authenticated [`gh`](https://cli.github.com/)
- One of: Cursor Agent (`agent`), `pi`, `codex`, or `claude` on your `PATH`
- A git checkout of the GitHub repo whose issues you want to ship

## Install

#### Linux/MacOS
#### Linux / macOS

```bash
curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | bash
```

#### Windows

[Releases](https://github.com/maxBRT/ship-cli/releases) page for manual download.
Download a binary from [Releases](https://github.com/maxBRT/ship-cli/releases).

## Update

## Prerequisites
```bash
ship update
```

- Authenticated [`gh`](https://cli.github.com/)
- Any of `cursor`, `codex`, `claude caude`, `pi` CLI installed
Downloads the latest GitHub Release, verifies checksums, and replaces the running binary in place. Check what you have with `ship --version`.

## Quickstart

Expand All @@ -32,7 +57,29 @@ gh issue edit <n> --add-label "ship"
ship
```

Run `ship --help` for flags and commands.
On first run, Ship creates `.ship/config.yaml` (or run `ship init`). Pick tickets in the picker, then leave the agent phases running. When Final succeeds, Ship prints the PR URL.

## Configuration

Settings live in `.ship/config.yaml`. Create one with `ship init`, or let the first `ship` run write defaults.

Precedence: YAML → flags.

```yaml
branch: "" # empty → generate ship/<id>
agent: cursor # cursor | pi | codex | claude
model: "" # optional model for the Agent
max_iterations: 10 # max tickets (Implement+Review) before Final
timeout: 20m # per-Phase timeout (Go duration)
```

One-off overrides:

```bash
ship --agent pi --model gpt-5 --max-iterations 3 --branch ship/my-feature --timeout 30m
```

Run `ship --help` for flags and commands (`init`, `update`, `--version`).

## Install from source

Expand Down
3 changes: 0 additions & 3 deletions cmd/ship/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ func TestMain_helpDocumentsDomainLanguage(t *testing.T) {
func TestMain_invalidFlagExitsNonZero(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: cursor
model: ""
max_iterations: 10
Expand All @@ -168,7 +167,6 @@ timeout: 20m
func TestMain_invalidTimeoutExitsNonZero(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: cursor
model: ""
max_iterations: 10
Expand All @@ -187,7 +185,6 @@ timeout: 20m
func TestMain_missingAgentBinaryExitsBeforeRun(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: cursor
model: ""
max_iterations: 10
Expand Down
2 changes: 0 additions & 2 deletions internal/run/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
// Config holds the Run configuration parsed from .ship/config.yaml and flags.
type Config struct {
Branch string
Feature string
Agent string
Model string
MaxIterations int
Expand Down Expand Up @@ -75,7 +74,6 @@ func defaultConfig() Config {
func newFlagSet(cfg *Config) *flag.FlagSet {
fs := flag.NewFlagSet("ship", flag.ContinueOnError)
fs.StringVar(&cfg.Branch, "branch", cfg.Branch, "git branch for the Run (empty means generate ship/<id>)")
fs.StringVar(&cfg.Feature, "feature", cfg.Feature, "optional extra filter label for ship Tickets")
fs.StringVar(&cfg.Agent, "agent", cfg.Agent, "Agent kind for every Phase (cursor, pi, codex, claude)")
fs.StringVar(&cfg.Model, "model", cfg.Model, "optional model for the Agent")
fs.IntVar(&cfg.MaxIterations, "max-iterations", cfg.MaxIterations, "max Iterations (one Ticket each) before Final")
Expand Down
16 changes: 0 additions & 16 deletions internal/run/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ func writeShipYAML(t *testing.T, dir, content string) {
}

const fullYAML = `branch: ""
feature: ""
agent: cursor
model: ""
max_iterations: 10
Expand All @@ -41,9 +40,6 @@ func TestParseConfig_yamlDefaults(t *testing.T) {
if cfg.Branch != "" {
t.Errorf("Branch = %q, want empty", cfg.Branch)
}
if cfg.Feature != "" {
t.Errorf("Feature = %q, want empty", cfg.Feature)
}
if cfg.Agent != "cursor" {
t.Errorf("Agent = %q, want %q", cfg.Agent, "cursor")
}
Expand All @@ -61,7 +57,6 @@ func TestParseConfig_yamlDefaults(t *testing.T) {
func TestParseConfig_yamlOverridesBuiltInDefaults(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: feat/widget
feature: widget
agent: pi
model: composer
max_iterations: 3
Expand All @@ -76,9 +71,6 @@ timeout: 5m
if cfg.Branch != "feat/widget" {
t.Errorf("Branch = %q, want feat/widget", cfg.Branch)
}
if cfg.Feature != "widget" {
t.Errorf("Feature = %q, want widget", cfg.Feature)
}
if cfg.Agent != "pi" {
t.Errorf("Agent = %q, want pi", cfg.Agent)
}
Expand All @@ -96,15 +88,13 @@ timeout: 5m
func TestParseConfig_flagsOverrideYAML(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: from-yaml
feature: yaml-feature
agent: cursor
model: yaml-model
max_iterations: 7
timeout: 2m
`)
args := []string{
"--branch", "from-flag",
"--feature", "flag-feature",
"--agent", "claude",
"--model", "flag-model",
"--max-iterations", "4",
Expand All @@ -118,9 +108,6 @@ timeout: 2m
if cfg.Branch != "from-flag" {
t.Errorf("Branch = %q, want from-flag", cfg.Branch)
}
if cfg.Feature != "flag-feature" {
t.Errorf("Feature = %q, want flag-feature", cfg.Feature)
}
if cfg.Agent != "claude" {
t.Errorf("Agent = %q, want claude", cfg.Agent)
}
Expand All @@ -138,7 +125,6 @@ timeout: 2m
func TestParseConfig_rejectsUnknownAgentKind(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: opencode
model: ""
max_iterations: 10
Expand All @@ -157,7 +143,6 @@ timeout: 20m
func TestParseConfig_rejectsLegacyBinaryAgentWithMigrationHint(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: agent
model: ""
max_iterations: 10
Expand All @@ -177,7 +162,6 @@ timeout: 20m
func TestParseConfig_rejectsAbsolutePathAgentWithMigrationHint(t *testing.T) {
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
agent: /usr/local/bin/agent
model: ""
max_iterations: 10
Expand Down
2 changes: 1 addition & 1 deletion internal/run/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (r Orchestrator) Run(ctx context.Context) error {
return fmt.Errorf("ensure tracker labels: %w", err)
}

ready, err := r.Tickets.ListReady(ctx, r.Config.Feature)
ready, err := r.Tickets.ListReady(ctx)
if err != nil {
return fmt.Errorf("list ship Tickets: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/run/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,7 @@ func (f *fakeTickets) EnsureLabels(context.Context) error {
return f.ensureErr
}

func (f *fakeTickets) ListReady(context.Context, string) ([]ticket.Ticket, error) {
func (f *fakeTickets) ListReady(context.Context) ([]ticket.Ticket, error) {
out := make([]ticket.Ticket, len(f.ready))
copy(out, f.ready)
return out, nil
Expand Down
8 changes: 1 addition & 7 deletions internal/run/shipfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,11 @@ func (i Init) Config(dir string) (created bool, err error) {
cfg.Agent = kind
}
content := fmt.Sprintf(`branch: %q
feature: %q
agent: %s
model: %q
max_iterations: %d
timeout: %s
`, cfg.Branch, cfg.Feature, cfg.Agent, cfg.Model, cfg.MaxIterations, formatDuration(cfg.Timeout))
`, cfg.Branch, cfg.Agent, cfg.Model, cfg.MaxIterations, formatDuration(cfg.Timeout))
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return false, err
}
Expand Down Expand Up @@ -139,7 +138,6 @@ func LoadConfig(dir string) (Config, error) {

return Config{
Branch: *raw.Branch,
Feature: *raw.Feature,
Agent: *raw.Agent,
Model: *raw.Model,
MaxIterations: *raw.MaxIterations,
Expand All @@ -149,7 +147,6 @@ func LoadConfig(dir string) (Config, error) {

type shipYAML struct {
Branch *string `yaml:"branch"`
Feature *string `yaml:"feature"`
Agent *string `yaml:"agent"`
Model *string `yaml:"model"`
MaxIterations *int `yaml:"max_iterations"`
Expand All @@ -161,9 +158,6 @@ func (s shipYAML) missingKeys() []string {
if s.Branch == nil {
missing = append(missing, "branch")
}
if s.Feature == nil {
missing = append(missing, "feature")
}
if s.Agent == nil {
missing = append(missing, "agent")
}
Expand Down
8 changes: 1 addition & 7 deletions internal/run/shipfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ func TestInitConfig_writesFilledDefaults(t *testing.T) {
got := string(data)
for _, want := range []string{
"branch:",
"feature:",
"agent: cursor",
"model:",
"max_iterations: 10",
Expand Down Expand Up @@ -173,7 +172,6 @@ func TestInitConfig_alreadyExistsDoesNotOverwrite(t *testing.T) {
func TestLoadConfig_readsAllFields(t *testing.T) {
dir := t.TempDir()
content := `branch: feat/widget
feature: widget
agent: pi
model: composer
max_iterations: 3
Expand All @@ -188,9 +186,6 @@ timeout: 5m
if cfg.Branch != "feat/widget" {
t.Errorf("Branch = %q, want feat/widget", cfg.Branch)
}
if cfg.Feature != "widget" {
t.Errorf("Feature = %q, want widget", cfg.Feature)
}
if cfg.Agent != "pi" {
t.Errorf("Agent = %q, want pi", cfg.Agent)
}
Expand All @@ -208,7 +203,6 @@ timeout: 5m
func TestLoadConfig_missingKeyErrors(t *testing.T) {
dir := t.TempDir()
content := `branch: ""
feature: ""
agent: cursor
model: ""
timeout: 10m
Expand All @@ -227,11 +221,11 @@ timeout: 10m
func TestLoadConfig_ignoresUnknownKeys(t *testing.T) {
dir := t.TempDir()
content := `branch: ""
feature: ""
agent: cursor
model: ""
max_iterations: 10
timeout: 10m
feature: leftover
extra_thing: ignored
`
writeShipConfig(t, dir, content)
Expand Down
20 changes: 1 addition & 19 deletions internal/ticket/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ query($owner: String!, $name: String!) {
number
title
createdAt
labels(first: 20) { nodes { name } }
}
}
}
Expand All @@ -70,11 +69,6 @@ type gqlIssue struct {
Number int `json:"number"`
Title string `json:"title"`
CreatedAt time.Time `json:"createdAt"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
}

type orderedTicket struct {
Expand All @@ -84,7 +78,7 @@ type orderedTicket struct {

// ListReady returns ship-labeled Tickets in stable default order:
// ascending issue number, then oldest created date.
func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error) {
func (g *GitHub) ListReady(ctx context.Context) ([]Ticket, error) {
execGH := g.exec()

repoOut, err := execGH(ctx, "repo", "view", "--json", "nameWithOwner")
Expand Down Expand Up @@ -116,9 +110,6 @@ func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error

ordered := make([]orderedTicket, 0, len(resp.Data.Repository.Issues.Nodes))
for _, issue := range resp.Data.Repository.Issues.Nodes {
if feature != "" && !hasLabel(issue, feature) {
continue
}
ordered = append(ordered, orderedTicket{
Ticket: Ticket{
Number: issue.Number,
Expand All @@ -142,15 +133,6 @@ func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error
return outTickets, nil
}

func hasLabel(issue gqlIssue, want string) bool {
for _, l := range issue.Labels.Nodes {
if l.Name == want {
return true
}
}
return false
}

// requiredLabels are the tracker labels Ship needs for triage and ship queue
// membership (candidates for the Run picker).
var requiredLabels = []struct {
Expand Down
Loading
Loading