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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ native changes, then writes the corresponding Claude Code, Codex, and OpenCode f
| Agent prompts | Markdown body | `developer_instructions` | `prompts/<id>.md` referenced from JSON |
| Slash commands | `~/.claude/commands/<id>.md` | `~/.codex/prompts/<id>.md` | `command.<id>` plus `command/<id>.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.
Expand Down Expand Up @@ -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.

Expand Down
12 changes: 10 additions & 2 deletions docs/SKILLS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# Agent skill bindings

`shenron.yaml` is the source of truth for skill metadata. `shenron push <name>`
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 |
|---|---|---|
Expand Down
6 changes: 3 additions & 3 deletions docs/prd/shenron.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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/<id>.md` après le push.
11. `shenron push` round-trip un champ `skills: [foo]` modifié : il est visible dans `~/.claude/agents/<id>.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
Expand Down
11 changes: 7 additions & 4 deletions internal/adapter/opencode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"])
}
}

Expand Down
10 changes: 7 additions & 3 deletions internal/adapter/opencode/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 0 additions & 3 deletions internal/adapter/opencode/testdata/expected_opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@
"webfetch": "deny"
},
"prompt": "{file:./prompts/build.md}",
"skills": [
"test-driven-development"
],
"steps": 50,
"temperature": 0.7
},
Expand Down
1 change: 0 additions & 1 deletion internal/cli/testdata/init/opencode/opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"lsp": "deny"
},
"prompt": "{file:./prompts/build.md}",
"skills": ["test-driven-development"],
"steps": 50
}
},
Expand Down
4 changes: 2 additions & 2 deletions internal/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Loading