diff --git a/README.md b/README.md index 1fa5d71..ad2838e 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,20 @@ native changes, then writes the corresponding Claude Code, Codex, and OpenCode f | Agent prompts | Markdown body | `developer_instructions` | `prompts/.md` referenced from JSON | | Slash commands | `~/.claude/commands/.md` | `~/.codex/prompts/.md` | `command.` plus `command/.md` | | Permissions | `tools` and `permissionMode` | Sandbox, approvals, and web search | Native `permission` object | -| Per-agent skills | YAML frontmatter `skills` | Instruction hint | Native JSON `skills` array | +| Per-agent skills | YAML frontmatter `skills` | Instruction hint | Dropped (see `docs/SKILLS.md`) | | Bootstrap with `shenron install` | After OpenCode | After Claude Code | Preferred import source | Shenron targets Claude Code, Codex, and OpenCode. +> **Why no `skills` in OpenCode output?** OpenCode v1.x does not recognize +> `skills` as an agent field, so the CLI forwards unknown top-level options to +> the LLM provider as payload fields. Strict providers (Pydantic +> `additionalProperties: false`, e.g. GLM-5.2) reject them with 400 +> `Extra inputs are not permitted`. Pivot `agents[].skills` is therefore not +> emitted to OpenCode. Claude Code (native frontmatter) and Codex (instruction +> hint) keep receiving the bindings, and OpenCode agents are expected to +> reference skills from their prompt instead. + ## Install Shenron requires Go 1.24 or newer. @@ -175,14 +184,17 @@ skills: | `systemPrompt` / `promptFile` | Mutually exclusive. `promptFile` is relative to the pivot directory and must exist. | | `permissions` | Portable grants translated by each adapter. | | `extensions` | Target-specific overrides and fields. | -| `skills` | Optional ordered list of kebab-case skill names, emitted as native agent metadata. Local skill existence is not required. | +| `skills` | Optional ordered list of kebab-case skill names, emitted to Claude Code frontmatter and Codex instructions only (see below). Local skill existence is not required. | ### Per-agent skills and global skill references These two fields have different purposes: -- `agents[].skills` binds skills to an agent. It round-trips through OpenCode's - JSON `skills` array and Claude Code's frontmatter `skills` list. +- `agents[].skills` binds skills to an agent. It is emitted to Claude Code's + agent frontmatter and added as a Codex instruction hint. **OpenCode output + drops the field** because OpenCode v1.x forwards unknown agent keys to the + LLM provider, which strict providers reject (see the table note above and + [`docs/SKILLS.md`](docs/SKILLS.md)). - Top-level `skills: [{name: ...}]` stores global references only. Shenron does not manage skill contents or install skills on another machine. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 999c88c..d27396a 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,8 +1,16 @@ # Agent skill bindings `shenron.yaml` is the source of truth for skill metadata. `shenron push ` -emits each binding to both the Claude Code agent frontmatter and the OpenCode -agent object. +emits each binding to the **Claude Code** agent frontmatter and adds it to the +**Codex** agent's instruction prompt. + +> **OpenCode output does not contain a `skills` key on generated agents.** +> OpenCode v1.x does not recognize `skills` as an agent field, so the CLI +> forwards unknown top-level options to the LLM provider as payload fields. +> Strict providers (Pydantic `additionalProperties: false`, e.g. GLM-5.2) +> reject them with HTTP 400 `Extra inputs are not permitted`. Shenron therefore +> drops the field for OpenCode; OpenCode agents are expected to reference +> skills from their prompt body (e.g. an `## Available skills` section). | agent | skills | file | |---|---|---| diff --git a/docs/prd/shenron.md b/docs/prd/shenron.md index 695539b..6f7595a 100644 --- a/docs/prd/shenron.md +++ b/docs/prd/shenron.md @@ -65,7 +65,7 @@ Chaque agent dans le pivot a les champs suivants : | `promptFile` | string (chemin relatif au répertoire du fichier pivot) | Non | Alternative à `systemPrompt` : référence un fichier Markdown externe contenant le prompt système. Exemple : `prompts/build.md` (relatif au répertoire où se trouve `shenron.yaml`). | | `permissions` | objet | Non | Permissions de l'agent (voir section dédiée) | | `extensions` | map string→any | Non | Champs spécifiques à un CLI cible (ex. `claudeCode.maxTurns`, `opencode.steps`). Le cœur ignore ces champs ; les adaptateurs les consomment. | -| `skills` | list[string] (kebab-case) | Non | Skills que cet agent déclare utiliser. Référence des noms de skills dans `~/.agents/skills/`. Les adaptateurs émettent ce champ dans le format natif. | +| `skills` | list[string] (kebab-case) | Non | Skills que cet agent déclare utiliser. Référence des noms de skills dans `~/.agents/skills/`. Émis par l'adaptateur Claude Code (frontmatter) et par l'adaptateur Codex (hint d'instruction). **Omis par l'adaptateur OpenCode** (voir décision D9). | **Règle :** `systemPrompt` et `promptFile` ne peuvent pas être spécifiés simultanément. @@ -389,7 +389,7 @@ Les questions ouvertes de conception ont été résolues avant la v1. | D6 | Walk-up CWD→root + fallback `$HOME/.shenron/shenron.yaml`. Les deux modes (projet et global) coexistent. | FR1 | | D7 | Adaptateur pur + méthode `MergeFile` optionnelle dans l'interface `Adapter`. L'adaptateur OpenCode renvoie les fragments JSON pour les blocs `agent`/`command` ; le cœur fait le merge en préservant les champs hors-scope et l'indentation. | FR10 | | D8 | Nom du binaire : `shenron`. Distribution : binaire statique macOS/Linux via GitHub Releases + script `curl\|sh`. Pas de package npm/pip/brew en v1. | Stack | -| D9 | Le binding skills-par-agent est désormais dans le scope v1 (amend FR2). D5 reste inchangé pour le contenu des skills, qui demeure read-only. Les adaptateurs émettent `skills` comme metadata, pas comme contenu. L'existence locale d'une skill n'est pas bloquante. | FR2, FR7, FR10 | +| D9 | Le binding skills-par-agent est désormais dans le scope v1 (amend FR2). D5 reste inchangé pour le contenu des skills, qui demeure read-only. Les adaptateurs Claude Code et Codex émettent `skills` comme metadata ; **l'adaptateur OpenCode omet la clé `skills`** parce qu'OpenCode v1.x la forward au provider LLM en tant qu'option top-level inconnue, ce que les providers stricts (schéma Pydantic `additionalProperties: false`, ex. GLM-5.2) rejettent en 400 `Extra inputs are not permitted`. Les agents OpenCode sont censés référencer leurs skills depuis leur prompt (`## Available skills`). L'existence locale d'une skill n'est pas bloquante. | FR2, FR7, FR10 | | D10 | Le flux single-pivot de la v1 (`init` / `validate` / `diff` / `push` sur un `shenron.yaml` nu) a été retiré au profit du flux par package. Le contrat CLI actuel vit dans `docs/prd/scope-flatten-commands.md` : cinq commandes top-level (`install`, `list`, `update`, `diff`, `push`), `init` et `validate` supprimés, `--store` promu au niveau racine. FR1-FR4 (schéma pivot) restent valides ; FR5-FR8 sont remplacés par les FR1-FR7 du nouveau PRD. | FR5, FR6, FR7, FR8 | ## Acceptance criteria (v1) @@ -413,7 +413,7 @@ Une session de test valide le scénario suivant : 8. `shenron push --target opencode` → refuse d'écraser (sauf `--force`). 9. `shenron push --target claude-code` → écrit `~/.claude/agents/build.md` à partir du même pivot, avec le frontmatter YAML Claude Code. 10. Les permissions mappées (`edit: ask`, `bash` avec patterns) apparaissent correctement dans les deux fichiers natifs. -11. `shenron push` round-trip un champ `skills: [foo]` modifié : il est visible dans `opencode.json` et `~/.claude/agents/.md` après le push. +11. `shenron push` round-trip un champ `skills: [foo]` modifié : il est visible dans `~/.claude/agents/.md` (frontmatter) et comme instruction Codex, mais **absent de `opencode.json`** (voir décision D9). 12. `shenron push --target codex` génère les agents TOML et prompts Markdown, puis `shenron diff --target codex` retourne "No changes". ## Stack diff --git a/internal/adapter/opencode/adapter_test.go b/internal/adapter/opencode/adapter_test.go index 8c21c01..82cfc35 100644 --- a/internal/adapter/opencode/adapter_test.go +++ b/internal/adapter/opencode/adapter_test.go @@ -82,7 +82,6 @@ func TestGenerateAgent(t *testing.T) { "model": "anthropic/claude-sonnet-4-5", "temperature": 0.7, "prompt": "{file:./prompts/build.md}", - "skills": []string{"test-driven-development"}, "steps": 50, "permission": map[string]any{ "glob": "allow", @@ -106,6 +105,11 @@ func TestGenerateAgent(t *testing.T) { } func TestGenerateAgentSkills(t *testing.T) { + // OpenCode v1.x does not recognize `skills` as an agent field; the opencode + // CLI forwards unknown top-level options to the LLM provider as payload + // fields, which strict providers reject. Shenron must therefore drop the + // `skills` key entirely from generated OpenCode fragments, even when the + // pivot declares agent-level bindings. agent := pivot.AgentDefinition{ ID: "build", Description: "Build agent", @@ -117,9 +121,8 @@ func TestGenerateAgentSkills(t *testing.T) { if err != nil { t.Fatal(err) } - want := []string{"test-driven-development"} - if !reflect.DeepEqual(fragment["skills"], want) { - t.Errorf("skills = %#v, want %#v", fragment["skills"], want) + if _, ok := fragment["skills"]; ok { + t.Errorf("fragment must not contain a `skills` key for OpenCode; got %#v", fragment["skills"]) } } diff --git a/internal/adapter/opencode/agent.go b/internal/adapter/opencode/agent.go index f4fb3c8..b0e68e0 100644 --- a/internal/adapter/opencode/agent.go +++ b/internal/adapter/opencode/agent.go @@ -42,9 +42,13 @@ func GenerateAgentFragment(agent pivot.AgentDefinition, pivotDir string) (jsonFr fragment["permission"] = perms } - if len(agent.Skills) > 0 { - fragment["skills"] = agent.Skills - } + // Pivot `agent.Skills` is intentionally NOT emitted to the OpenCode fragment. + // OpenCode v1.x does not recognize `skills` as an agent field, so it forwards + // unknown top-level options to the LLM provider as payload fields. Strict + // providers (e.g. GLM-5.2) reject them with 400 "Extra inputs are not + // permitted". Skill bindings remain available to the agent through the + // Claude Code and Codex adapters, and through prompt-side `## Available skills` + // sections authored in the pivot. promptContent, err = resolvePromptContent(agent, pivotDir) if err != nil { diff --git a/internal/adapter/opencode/testdata/expected_opencode.json b/internal/adapter/opencode/testdata/expected_opencode.json index 52dfcad..3de785f 100644 --- a/internal/adapter/opencode/testdata/expected_opencode.json +++ b/internal/adapter/opencode/testdata/expected_opencode.json @@ -16,9 +16,6 @@ "webfetch": "deny" }, "prompt": "{file:./prompts/build.md}", - "skills": [ - "test-driven-development" - ], "steps": 50, "temperature": 0.7 }, diff --git a/internal/cli/testdata/init/opencode/opencode.json b/internal/cli/testdata/init/opencode/opencode.json index 97fde84..395c84e 100644 --- a/internal/cli/testdata/init/opencode/opencode.json +++ b/internal/cli/testdata/init/opencode/opencode.json @@ -12,7 +12,6 @@ "lsp": "deny" }, "prompt": "{file:./prompts/build.md}", - "skills": ["test-driven-development"], "steps": 50 } }, diff --git a/internal/integration_test.go b/internal/integration_test.go index 57c3a77..4704466 100644 --- a/internal/integration_test.go +++ b/internal/integration_test.go @@ -233,8 +233,8 @@ func TestEndToEnd_SkillsRoundTrip(t *testing.T) { } agents := root["agent"].(map[string]any) build := agents["build"].(map[string]any) - if got := stringSliceValue(build["skills"]); len(got) != 1 || got[0] != "test-driven-development" { - t.Errorf("OpenCode build skills = %#v", got) + if _, ok := build["skills"]; ok { + t.Errorf("OpenCode build must not carry a `skills` key (forwarded to LLM providers as an unknown payload field); got %#v", build["skills"]) } legacy := agents["legacy-helper"].(map[string]any) if got := stringSliceValue(legacy["skills"]); len(got) != 1 || got[0] != "native-existing-skill" {