From 82454f8a6b37bbc79be41255412448cd6c0b995a Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 17:52:09 +0200 Subject: [PATCH 1/3] fix(skills): support opencode backend so /orch activation stops failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode backend (1.20.8, #141) routes skill install through the generic InstallSkillsReport(result.Backend) call in the /orch picker, but the skills materializer only knew about "cc" and "codex". Activating opencode crashed with: skills: unknown backend "opencode". Add opencode as a first-class skills backend: - BackendOpenCode writes to ~/.config/opencode/skills//SKILL.md (opencode's global discovery dir), rendering name+description only — opencode ignores allowed-tools and has no openai.yaml sibling. - main.go: sync skills on every opencode activation (idempotent), matching the cc/codex startup sync. opencode has no global-install prompt, so this is unconditional. - InstallSkillsBoth -> InstallSkillsAll (now three backends); /skills status gains an "oc" column. - Tests: opencode materialization + a guard that all catalog names satisfy opencode's stricter name regex (no underscores). --- internal/repl/repl.go | 2 +- internal/setup/orch.go | 21 +++++++----- internal/skills/catalog.go | 17 ++++++---- internal/skills/materialize.go | 11 +++++-- internal/skills/materialize_test.go | 51 +++++++++++++++++++++++++++++ main.go | 4 +++ 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 87aaecd..6b8ffbe 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) 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..aa80121 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,56 @@ 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) + } + } +} + 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..dcb429b 100644 --- a/main.go +++ b/main.go @@ -440,6 +440,10 @@ 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. + _, _ = setup.InstallSkills("opencode") cliAgent = oc } From 660ec24edd012c93088eabf86540eb03da1a75e2 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 18:11:27 +0200 Subject: [PATCH 2/3] fix(orch): sync opencode skills on /orch activation regardless of GlobalInstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /orch picker gated InstallSkillsReport behind consent.GlobalInstall, which opencode never sets (its consent flow skips the global-install prompt). So opencode-only users activating via /orch got no skills, even though WriteOpenCodeConfig already ran unconditionally in the same flow and main.go syncs opencode skills on every startup. Decouple the skills install from the GlobalInstall gate for opencode only: cc/codex keep their explicit opt-in; opencode matches its managed-config model (and main.go). RunOrch stays gated — IsOrchInstalled("opencode") already returns true, so it never runs for opencode anyway. --- internal/repl/repl.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 6b8ffbe..7440e87 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -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) } } From 864abfc62d4a9a2c4a1971eb5d27a7ca39079761 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Mon, 13 Jul 2026 18:15:51 +0200 Subject: [PATCH 3/3] test(skills): cover unknown-backend path + opencode name-regex negatives; surface startup error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (sigilix): - TestSkillsDirRejectsUnknownBackend: assert invalid backends error and that Materialize propagates it (guards the default branch). - TestOpenCodeNameRegexRejectsInvalid: underscores, leading/trailing/double hyphens, uppercase, empty all rejected — confirms the regex itself. - main.go: log the opencode InstallSkills error to stderr instead of discarding it, matching the adjacent WriteOpenCodeConfig warning. --- internal/skills/materialize_test.go | 39 +++++++++++++++++++++++++++++ main.go | 4 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/skills/materialize_test.go b/internal/skills/materialize_test.go index aa80121..4a055ef 100644 --- a/internal/skills/materialize_test.go +++ b/internal/skills/materialize_test.go @@ -135,6 +135,45 @@ func TestCatalogNamesAreOpenCodeCompatible(t *testing.T) { } } +// 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 dcb429b..289c5bb 100644 --- a/main.go +++ b/main.go @@ -443,7 +443,9 @@ func main() { // 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. - _, _ = setup.InstallSkills("opencode") + if _, err := setup.InstallSkills("opencode"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not install opencode skills: %v\n", err) + } cliAgent = oc }