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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in
| `upgrade [--dry-run] [--force] [--global] [--self]` | Refresh host templates; `--self` replaces this binary from GitHub Releases |
| `doctor` / `heartbeat` | Health checks |
| `status` / `cast` | Team table |
| `cast --add <name> [--role <role>]` | Add a member and regenerate `.opencode/agents` |
| `cast --add <name> [--role <role>] [--model <provider/id>]` | Add a member and regenerate `.opencode/agents` |
| `cast --model <name> <provider/id>` | Pin a member to an OpenCode model id |
| `cast --model squad <provider/id>` | Pin the coordinator model |
| `cast --model <name|-> -` | Clear a pin (inherit Squad, then session) |
| `cast --remove <name>` | Remove a member and regenerate `.opencode/agents` |
| `cast --theme office` / `none` | `init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) |
| `recast` | Regenerate `.opencode/agents` from `.squad/team.md` |
Expand Down
2 changes: 2 additions & 0 deletions docs/get-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Inside OpenCode:
3. Paste your API key
4. Quit when done

Optional: pin a seat to an OpenCode id (`provider/model-id`) with `squad-oc cast --model squad xai/grok-3` or `cast --add NAME --model anthropic/claude-sonnet-4-5`. Empty cells inherit the Squad model, then the session default. This does not write `opencode.json`.

---

## 3. Create a project
Expand Down
8 changes: 8 additions & 0 deletions docs/use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ Today’s stand-ins:
| Shared team + decisions | `squad-oc link <team-dir>` | still `link` (`mcp apply` reads the linked team dir) |
| Memory | `.squad/decisions.md` | still files — not `squad_state` |

### OpenCode-native (not original Squad)

Not a Copilot port and not an original-Squad ease mapping.

| Capability | squad-oc | Status |
|------------|----------|--------|
| Per-agent model | `cast --model` / `cast --add --model`; empty `team.md` Model cells inherit Squad, then session | **shipped** |

---

## Won’t port
Expand Down
75 changes: 67 additions & 8 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ Commands:
upgrade [--dry-run] [--force] [--global] [--self]
doctor | heartbeat
status | cast
cast --add <name> [--role <role>]
cast --add <name> [--role <role>] [--model <provider/id>]
cast --remove <name>
cast --theme office|none
cast --model <name> <provider/id>
cast --model squad <provider/id>
cast --model <name|-> -
recast
run -p <prompt> | --file <path> [--agent name] [--url]
watch | triage | loop [--execute] [--interval minutes] [--once] [--health] [--url]
Expand Down Expand Up @@ -260,7 +263,14 @@ func cmdStatus() int {
}

func cmdCast(args []string) int {
var add, role, remove, theme string
var add, role, remove, theme, model, modelName string
addMode := false
for _, a := range args {
if a == "--add" {
addMode = true
break
}
}
for i := 0; i < len(args); i++ {
a := args[i]
switch {
Expand All @@ -279,6 +289,24 @@ func cmdCast(args []string) int {
case a == "--theme":
fmt.Fprintln(os.Stderr, "cast --theme requires office or none")
return 2
case a == "--model":
if addMode {
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "cast --model requires a model id")
return 2
}
i++
model = args[i]
} else {
if i+2 >= len(args) {
fmt.Fprintln(os.Stderr, "cast --model requires <name> <provider/id>")
return 2
}
i++
modelName = args[i]
i++
model = args[i]
}
case a == "--help" || a == "-h":
printHelp()
return 0
Expand All @@ -287,20 +315,31 @@ func cmdCast(args []string) int {
return 2
}
}
if theme != "" && (add != "" || remove != "") {
haveModel := model != "" || modelName != ""
if theme != "" && (add != "" || remove != "" || haveModel) {
fmt.Fprintln(os.Stderr, "cast: use --theme alone")
return 2
}
if add != "" && remove != "" {
fmt.Fprintln(os.Stderr, "cast: use --add or --remove, not both")
return 2
}
if haveModel && remove != "" {
fmt.Fprintln(os.Stderr, "cast: use --model or --remove, not both")
return 2
}
if theme != "" {
return cmdCastTheme(theme)
}
if add == "" && remove == "" {
if add == "" && remove == "" && !haveModel {
return cmdStatus()
}
if haveModel {
if _, err := squad.ValidateModelID(model); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
}
root, code := cwd()
if code != 0 {
return code
Expand All @@ -318,16 +357,36 @@ func cmdCast(args []string) int {
fmt.Printf("removed %s; recast %d agent file(s)\n", remove, res.Written)
return 0
}
if err := squad.AddMember(root, add, role); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
if add != "" {
if err := squad.AddMember(root, add, role, model); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
res, err := squad.Recast(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("added %s; recast %d agent file(s)\n", add, res.Written)
return 0
}
if strings.EqualFold(modelName, "squad") {
if err := squad.SetSquadModel(root, model); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
} else {
if err := squad.SetMemberModel(root, modelName, model); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
}
res, err := squad.Recast(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("added %s; recast %d agent file(s)\n", add, res.Written)
fmt.Printf("model %s; recast %d agent file(s)\n", modelName, res.Written)
return 0
}

Expand Down
41 changes: 41 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,47 @@ func TestCastThemeNoneAfterInitOffice(t *testing.T) {
}
}

func TestCastModelFlags(t *testing.T) {
root := t.TempDir()
prev, _ := os.Getwd()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(prev) })
if Execute([]string{"init", "--preset", "default", "--description", "models"}) != 0 {
t.Fatal("init")
}
if Execute([]string{"cast", "--model", "squad", "xai/grok-3"}) != 0 {
t.Fatal("set squad")
}
if Execute([]string{"cast", "--add", "Designer", "--role", "Design", "--model", "opencode/gpt-5.1-codex"}) != 0 {
t.Fatal("add")
}
got, _ := os.ReadFile(filepath.Join(root, ".opencode", "agents", "designer.md"))
if !strings.Contains(string(got), "model: opencode/gpt-5.1-codex") {
t.Fatal(string(got))
}
if Execute([]string{"cast", "--model", "Designer", "-"}) != 0 {
t.Fatal("clear")
}
got, _ = os.ReadFile(filepath.Join(root, ".opencode", "agents", "designer.md"))
if !strings.Contains(string(got), "model: xai/grok-3") {
t.Fatalf("should inherit squad after clear:\n%s", got)
}
if Execute([]string{"cast", "--model", "Lead", "nonesuch"}) != 2 {
t.Fatal("missing slash must be usage 2")
}
if Execute([]string{"cast", "--model"}) != 2 {
t.Fatal("missing args")
}
if Execute([]string{"cast", "--theme", "office", "--model", "Lead", "xai/grok-3"}) != 2 {
t.Fatal("exclusive")
}
if Execute([]string{"cast", "--remove", "Designer", "--model", "Lead", "xai/grok-3"}) != 2 {
t.Fatal("exclusive remove")
}
}

func TestRecastListsHostAgentIDs(t *testing.T) {
root := t.TempDir()
prev, _ := os.Getwd()
Expand Down
82 changes: 82 additions & 0 deletions internal/squad/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,71 @@ func TestParseTeamMarkdown(t *testing.T) {
}
}

func TestParseTeamMarkdownModelColumn(t *testing.T) {
md := `
## Coordinator

| Name | Role | Model | Notes |
|------|------|-------|-------|
| Squad | Coordinator | xai/grok-3 | Routes work |

## Members

| Name | Role | Charter | Status | Model |
|------|------|---------|--------|-------|
| Lead | Lead | ` + "`.squad/agents/lead/charter.md`" + ` | Active | anthropic/claude-sonnet-4-5 |
| Tester | Tester | ` + "`.squad/agents/tester/charter.md`" + ` | Active | |
`
members := ParseTeamMarkdown(md)
if len(members) != 2 {
t.Fatalf("want 2, got %+v", members)
}
if members[0].Model != "anthropic/claude-sonnet-4-5" {
t.Fatalf("lead model: %+v", members[0])
}
if members[1].Model != "" {
t.Fatalf("tester must be empty inherit: %+v", members[1])
}
if got := ParseSquadModel(md); got != "xai/grok-3" {
t.Fatalf("squad model %q", got)
}
}

func TestParseTeamMarkdownOldFourColumn(t *testing.T) {
md := `
## Coordinator

| Name | Role | Notes |
|------|------|-------|
| Squad | Coordinator | Routes work |

## Members

| Name | Role | Charter | Status |
|------|------|---------|--------|
| Lead | Lead | x | Active |
`
members := ParseTeamMarkdown(md)
if len(members) != 1 || members[0].Name != "Lead" || members[0].Status != "Active" || members[0].Model != "" {
t.Fatalf("%+v", members)
}
if ParseSquadModel(md) != "" {
t.Fatal("missing model column must be empty")
}
}

func TestEffectiveModel(t *testing.T) {
if EffectiveModel("anthropic/claude-sonnet-4-5", "xai/grok-3") != "anthropic/claude-sonnet-4-5" {
t.Fatal("own wins")
}
if EffectiveModel("", "xai/grok-3") != "xai/grok-3" {
t.Fatal("inherit team")
}
if EffectiveModel(" ", "") != "" {
t.Fatal("both empty")
}
}

func TestReadTeamAfterInit(t *testing.T) {
root := t.TempDir()
if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil {
Expand All @@ -179,6 +244,23 @@ func TestReadTeamAfterInit(t *testing.T) {
}
}

func TestInitTeamMarkdownHasModelColumn(t *testing.T) {
root := t.TempDir()
if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(ResolveDir(root), "team.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "Model") {
t.Fatalf("new team.md should include Model column:\n%s", raw)
}
if ParseSquadModel(string(raw)) != "" {
t.Fatal("default model must be empty")
}
}

func isolateHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
Expand Down
Loading