diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 87aaecd..7440e87 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -338,7 +338,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin setup.PrintSkillsStatus(term) continue case input == "/skills install": - setup.InstallSkillsBoth(term) + setup.InstallSkillsAll(term) continue case input == "/set": handleSetCommand(input, ag, term) @@ -534,6 +534,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if !setup.IsOrchInstalled(result.Backend) { setup.RunOrch(result.Backend, term) } + } + // opencode manages its config and skills directly: its consent flow + // skips the global-install prompt and WriteOpenCodeConfig runs + // unconditionally below, so its skills sync on activation regardless + // of GlobalInstall — matching the unconditional startup sync in + // main.go. cc/codex skills stay behind the explicit opt-in above. + if consent.GlobalInstall || result.Backend == "opencode" { setup.InstallSkillsReport(result.Backend, term) } } diff --git a/internal/setup/orch.go b/internal/setup/orch.go index 63042f4..00d40af 100644 --- a/internal/setup/orch.go +++ b/internal/setup/orch.go @@ -179,16 +179,17 @@ func InstallSkillsReport(backend string, term *tui.Terminal) { term.PrintSystem(fmt.Sprintf("Installed %d qmax QA skills into %s", len(res.Written), res.Dir)) } -// InstallSkillsBoth materializes the catalog into both CLI backends and reports -// each. Used by the `/skills install` command. -func InstallSkillsBoth(term *tui.Terminal) { +// InstallSkillsAll materializes the catalog into every supported CLI backend +// and reports each. Used by the `/skills install` command. +func InstallSkillsAll(term *tui.Terminal) { InstallSkillsReport("cc", term) InstallSkillsReport("codex", term) + InstallSkillsReport("opencode", term) } // PrintSkillsStatus lists the qmax QA skill catalog and shows, per skill, -// whether it is currently materialized into the Claude Code and Codex skills -// directories. Backs the `/skills` command so the catalog is visible from +// whether it is currently materialized into the Claude Code, Codex, and opencode +// skills directories. Backs the `/skills` command so the catalog is visible from // inside qmax-code, even though the skills themselves run in the CLI backends. func PrintSkillsStatus(term *tui.Terminal) { home, err := os.UserHomeDir() @@ -198,20 +199,22 @@ func PrintSkillsStatus(term *tui.Terminal) { } ccDir, _ := skills.SkillsDir(skills.BackendCC, home) cxDir, _ := skills.SkillsDir(skills.BackendCodex, home) + ocDir, _ := skills.SkillsDir(skills.BackendOpenCode, home) catalog := skills.SortedCatalog() - term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills", len(catalog))) + term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills · oc = ~/.config/opencode/skills", len(catalog))) for _, sk := range catalog { cc := installMark(filepath.Join(ccDir, sk.Name, "SKILL.md")) cx := installMark(filepath.Join(cxDir, sk.Name, "SKILL.md")) + oc := installMark(filepath.Join(ocDir, sk.Name, "SKILL.md")) desc := sk.ShortDescription if desc == "" { desc = sk.Description } - fmt.Printf(" cc:%s codex:%s %s%-22s%s %s\n", cc, cx, tui.ColorBold, sk.Name, tui.ColorReset, desc) + fmt.Printf(" cc:%s codex:%s oc:%s %s%-22s%s %s\n", cc, cx, oc, tui.ColorBold, sk.Name, tui.ColorReset, desc) } - term.PrintSystem("Skills load inside Claude Code / Codex sessions — auto-invoked by description, or `$name` in Codex.") - term.PrintSystem("Run /skills install to (re)install them into both backends now.") + term.PrintSystem("Skills load inside Claude Code / Codex / opencode sessions — auto-invoked by description, or `$name` in Codex.") + term.PrintSystem("Run /skills install to (re)install them into all backends now.") } // installMark returns a check or cross depending on whether path exists. diff --git a/internal/skills/catalog.go b/internal/skills/catalog.go index 71080f9..645c5b4 100644 --- a/internal/skills/catalog.go +++ b/internal/skills/catalog.go @@ -1,21 +1,26 @@ // Package skills defines the backend-neutral catalog of qmax QA skills and // materializes them into the native skill directories of each supported CLI -// backend (Claude Code and Codex). +// backend (Claude Code, Codex, and opencode). // -// Both Claude Code and Codex load "agent skills" from a folder containing a -// SKILL.md file with YAML frontmatter (name + description), auto-invoked when a -// user request matches the description. The two CLIs share that core format but -// diverge on the optional enrichment they understand: +// All three CLIs load "agent skills" from a folder containing a SKILL.md file +// with YAML frontmatter (name + description), auto-invoked when a user request +// matches the description. They share that core format but diverge on the +// optional enrichment they understand: // // - Claude Code reads an `allowed-tools:` frontmatter key to gate which tools // the skill may call. // - Codex ignores `allowed-tools`; it reads an optional sibling // `agents/openai.yaml` for UI metadata, MCP dependencies, and invocation // policy. +// - opencode recognizes only name + description in frontmatter (plus optional +// license/compatibility/metadata); it ignores `allowed-tools` and has no +// sibling config. (opencode also auto-discovers ~/.claude/skills, but +// materializing into its native ~/.config/opencode/skills keeps the catalog +// authoritative for opencode-only users.) // // A single Skill in this catalog is the source of truth. Materialize() emits // the right SKILL.md (and, for Codex, openai.yaml) for whichever backend is -// being installed, so one definition stays in sync across both CLIs. +// being installed, so one definition stays in sync across all three CLIs. package skills import ( diff --git a/internal/skills/materialize.go b/internal/skills/materialize.go index b802aa2..3b1606f 100644 --- a/internal/skills/materialize.go +++ b/internal/skills/materialize.go @@ -18,8 +18,9 @@ var skillNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) type Backend string const ( - BackendCC Backend = "cc" - BackendCodex Backend = "codex" + BackendCC Backend = "cc" + BackendCodex Backend = "codex" + BackendOpenCode Backend = "opencode" ) // MaterializeResult reports what Materialize wrote, for display to the user. @@ -38,6 +39,8 @@ func SkillsDir(backend Backend, home string) (string, error) { return filepath.Join(home, ".claude", "skills"), nil case BackendCodex: return filepath.Join(home, ".codex", "skills"), nil + case BackendOpenCode: + return filepath.Join(home, ".config", "opencode", "skills"), nil default: return "", fmt.Errorf("skills: unknown backend %q", backend) } @@ -169,6 +172,10 @@ func renderSkillMD(backend Backend, sk Skill) string { b.WriteString("metadata:\n") b.WriteString(" short-description: " + yamlScalar(sk.ShortDescription) + "\n") } + case BackendOpenCode: + // opencode recognizes only name + description in frontmatter (plus + // optional license/compatibility/metadata); it ignores allowed-tools, so + // we emit nothing extra. The body is identical across backends. default: // Materialize validates the backend via SkillsDir before reaching here, // so an unknown value is a programming error, not a runtime condition. diff --git a/internal/skills/materialize_test.go b/internal/skills/materialize_test.go index 054c4c7..4a055ef 100644 --- a/internal/skills/materialize_test.go +++ b/internal/skills/materialize_test.go @@ -3,6 +3,7 @@ package skills import ( "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -84,6 +85,95 @@ func TestMaterializeCodexEmitsOpenAIYAML(t *testing.T) { } } +func TestMaterializeOpenCode(t *testing.T) { + home := t.TempDir() + res, err := Materialize(BackendOpenCode, home) + if err != nil { + t.Fatalf("Materialize(opencode): %v", err) + } + if len(res.Written) != len(Catalog) { + t.Fatalf("wrote %d skills, want %d", len(res.Written), len(Catalog)) + } + + for _, sk := range Catalog { + md := filepath.Join(home, ".config", "opencode", "skills", sk.Name, "SKILL.md") + data, err := os.ReadFile(md) + if err != nil { + t.Fatalf("read %s: %v", md, err) + } + text := string(data) + if !strings.HasPrefix(text, "---\n") { + t.Errorf("%s: missing frontmatter open", sk.Name) + } + if !strings.Contains(text, "name: "+sk.Name) { + t.Errorf("%s: frontmatter missing name", sk.Name) + } + if !strings.Contains(text, "description:") { + t.Errorf("%s: frontmatter missing description", sk.Name) + } + // opencode ignores allowed-tools; the opencode render omits it so the + // frontmatter stays within opencode's recognized schema. + if strings.Contains(text, "allowed-tools:") { + t.Errorf("%s: opencode SKILL.md should not carry allowed-tools", sk.Name) + } + // opencode must NOT get Codex's openai.yaml sibling. + if _, err := os.Stat(filepath.Join(home, ".config", "opencode", "skills", sk.Name, "agents", "openai.yaml")); err == nil { + t.Errorf("%s: opencode backend should not write agents/openai.yaml", sk.Name) + } + } +} + +// opencode enforces a stricter skill-name regex (^[a-z0-9]+(-[a-z0-9]+)*$ — no +// underscores). Every catalog name must satisfy it so skills load in opencode. +var opencodeNameRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func TestCatalogNamesAreOpenCodeCompatible(t *testing.T) { + for _, sk := range Catalog { + if !opencodeNameRe.MatchString(sk.Name) { + t.Errorf("catalog skill name %q is not opencode-compatible (underscores/segments rejected)", sk.Name) + } + } +} + +// TestOpenCodeNameRegexRejectsInvalid confirms the regex itself is correct: +// opencode bans underscores, leading/trailing/double hyphens, and uppercase. +func TestOpenCodeNameRegexRejectsInvalid(t *testing.T) { + bad := []string{ + "has_underscore", // underscores not allowed (qmax's own regex permits them) + "trailing-", + "-leading", + "double--dash", + "CamelCase", + "with space", + "", + } + for _, name := range bad { + if opencodeNameRe.MatchString(name) { + t.Errorf("opencodeNameRe unexpectedly accepted invalid name %q", name) + } + } +} + +// TestSkillsDirRejectsUnknownBackend guards the default branch so an invalid +// backend string fails loudly instead of writing to an arbitrary path. +func TestSkillsDirRejectsUnknownBackend(t *testing.T) { + home := t.TempDir() + for _, b := range []Backend{"", "claude", "CC", "open-code", "unknown"} { + if _, err := SkillsDir(b, home); err == nil { + t.Errorf("SkillsDir(%q): expected error, got nil", b) + } + } + for _, b := range []Backend{BackendCC, BackendCodex, BackendOpenCode} { + if _, err := SkillsDir(b, home); err != nil { + t.Errorf("SkillsDir(%q): unexpected error: %v", b, err) + } + } + // And Materialize must surface the same error, not write anything. + if _, err := Materialize(Backend("bogus"), home); err == nil { + t.Fatal("Materialize(bogus): expected error, got nil") + } +} + func TestMaterializeIsIdempotent(t *testing.T) { home := t.TempDir() if _, err := Materialize(BackendCodex, home); err != nil { diff --git a/main.go b/main.go index 0901402..289c5bb 100644 --- a/main.go +++ b/main.go @@ -440,6 +440,12 @@ func main() { if _, err := agent.WriteOpenCodeConfig(appConfig, ctx, appConfig.OrchPermissionMode); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not write opencode config: %v\n", err) } + // Skills sync every launch (idempotent). opencode has no global-install + // prompt — qmax manages its config directly — so this is unconditional, + // unlike the OrchGlobalInstall-gated cc/codex sync above. + if _, err := setup.InstallSkills("opencode"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not install opencode skills: %v\n", err) + } cliAgent = oc }