diff --git a/.github/workflows/publish-claude-plugin.yml b/.github/workflows/publish-claude-plugin.yml index c24ff7130..4d54bb9b4 100644 --- a/.github/workflows/publish-claude-plugin.yml +++ b/.github/workflows/publish-claude-plugin.yml @@ -117,9 +117,9 @@ jobs: # the freshly-regenerated bundle. --delete removes files that # no longer exist in the source (e.g. a removed skill). The # --exclude entries protect repo-level files we don't manage - # from the gortex side: .git/, .github/ (publish target may - # carry its own readme-only CI), and a top-level README/LICENSE - # if the publish target maintains its own. + # from the gortex side: .git/ and .github/ (publish target may + # carry its own readme-only CI). The root README and LICENSE + # are generated as part of the marketplace layout. rsync -av --delete \ --exclude='.git/' \ --exclude='.github/' \ diff --git a/Makefile b/Makefile index 3e8ceab17..ded3ffbee 100644 --- a/Makefile +++ b/Makefile @@ -148,14 +148,16 @@ build-windows: # Marketplace plugin bundle # --------------------------------------------------------------------------- -# claude-plugin regenerates the Anthropic Plugin Marketplace bundle at -# claude-plugin/. The bundle is checked in so the marketplace's -# "git-subdir" source can pull it directly. The CI guard +# claude-plugin regenerates the Claude Code plugin marketplace at +# claude-plugin/: .claude-plugin/marketplace.json plus the plugin under +# plugins/gortex/. publish-claude-plugin.yml syncs it to +# gortexhq/claude-plugin. The CI guard # (claude-plugin-check) asserts that re-running this target produces # no diff against what's checked in — drift means the bundle is stale # vs the source-of-truth content in # internal/agents/commands_bodies.go. claude-plugin: build + rm -rf ./claude-plugin ./$(BINARY) plugin emit --target ./claude-plugin --variant anthropic @echo "✓ Regenerated claude-plugin/ from internal/agents commands" diff --git a/cmd/gortex/plugin.go b/cmd/gortex/plugin.go index 4f66fb8a0..cb421a2b4 100644 --- a/cmd/gortex/plugin.go +++ b/cmd/gortex/plugin.go @@ -22,10 +22,11 @@ var pluginCmd = &cobra.Command{ Short: "Manage Gortex marketplace plugin bundles", Long: `Generate plugin bundles for agent-host marketplaces. -Today this command emits the Anthropic Plugin Marketplace layout — -a directory containing plugin.json, README, LICENSE, .mcp.json, -skills, sub-agents, and hooks. The Cursor variant lands in a -follow-up. +Today this command emits a Claude Code plugin marketplace: +.claude-plugin/marketplace.json, README and LICENSE at the root, and +the gortex plugin under plugins/gortex/ (plugin.json, README, LICENSE, +.mcp.json, skills, sub-agents, and hooks). The Cursor variant lands in +a follow-up. The marketplace bundle is generated, not hand-maintained. The single source of truth lives in internal/agents/commands_bodies.go and @@ -42,7 +43,9 @@ Example: gortex plugin emit --target ./claude-plugin --variant anthropic --version 0.18.2 The target directory is created if missing; existing files are -overwritten so re-runs converge on a deterministic output.`, +overwritten so re-runs converge on a deterministic output. A target +that still holds the older flat layout (the plugin at its root) is +refused; remove it or emit into an empty directory.`, RunE: runPluginEmit, } @@ -67,7 +70,7 @@ func runPluginEmit(_ *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("resolve target dir: %w", err) } - written, err := claudecode.EmitPluginBundle(claudecode.PluginBundleSpec{ + written, err := claudecode.EmitMarketplace(claudecode.PluginBundleSpec{ TargetDir: abs, Version: v, LayoutVariant: claudecode.LayoutVariant(pluginEmitVariant), diff --git a/docs/installation.md b/docs/installation.md index a4590aba8..2142aea06 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -97,6 +97,19 @@ xattr -d com.apple.quarantine /usr/local/bin/gortex gortex version ``` +## Claude Code plugin + +Gortex also ships as a Claude Code plugin with the MCP server, workflow skills, sub-agents and hooks. With the `gortex` binary installed, add the marketplace and install the plugin: + +```bash +claude plugin marketplace add gortexhq/claude-plugin +claude plugin install gortex@gortex +``` + +The plugin covers Claude Code's MCP server, workflow skills, sub-agents and hooks. Running `gortex install` and `gortex init` for Claude Code as well registers Gortex twice, which duplicates them. To avoid that, pass `--agents-skip claude-code` to both. This also skips the `CLAUDE.md` blocks and the per-repo community skills under `.claude/skills/generated/`, which the plugin does not provide. + +The workflows appear as `/gortex:`, for example `/gortex:explore`. The marketplace at [gortexhq/claude-plugin](https://github.com/gortexhq/claude-plugin) is generated from this repository by `gortex plugin emit` on each release. + ## System requirements Every published binary is self-contained — there is no runtime dependency to diff --git a/docs/onboarding.md b/docs/onboarding.md index 0f41986b8..cae21f658 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -17,6 +17,8 @@ Setup is split into two commands so codebase-agnostic machinery lives once per u You can run them independently — `gortex init` doesn't require `gortex install` first; it just writes less if the user-level wiring isn't there. +Using Claude Code? Gortex also ships as a Claude Code plugin; see [Claude Code plugin](installation.md#claude-code-plugin) for how it fits with these two commands. + ```bash # One-time: machine-wide user-level setup gortex install --start --track # install, start daemon, track current dir diff --git a/internal/agents/claudecode/marketplace.go b/internal/agents/claudecode/marketplace.go new file mode 100644 index 000000000..966545443 --- /dev/null +++ b/internal/agents/claudecode/marketplace.go @@ -0,0 +1,119 @@ +package claudecode + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// marketplaceName is the id users type after "@" when installing: +// `claude plugin install gortex@gortex`. +const marketplaceName = "gortex" + +// marketplacePluginSource is the plugin entry's source, relative to the +// marketplace root. A plugins// layout leaves room for more +// plugins in the same marketplace later. +const marketplacePluginSource = "./plugins/" + pluginName + +const ( + marketplaceDescription = "Gortex plugins for Claude Code: graph-based code intelligence served by a local daemon." + marketplacePluginDescription = "Graph-based code intelligence for Claude Code: MCP tools, workflow skills, sub-agents and hooks backed by the local Gortex daemon." +) + +// marketplaceReadmeBody is the README at the marketplace root, which is +// also the landing page of the published gortexhq/claude-plugin repo. +const marketplaceReadmeBody = `# Gortex plugins for Claude Code + +This repository is a Claude Code plugin marketplace. It is generated by +` + "`gortex plugin emit`" + ` from https://github.com/zzet/gortex, so change the +source there rather than editing it here. + +## Install + +The plugin runs the ` + "`gortex`" + ` binary, so install that first +(https://github.com/zzet/gortex/blob/main/docs/installation.md). Then: + +` + "```sh\nclaude plugin marketplace add gortexhq/claude-plugin\nclaude plugin install gortex@gortex\n```" + ` + +If you also run ` + "`gortex install`" + ` or ` + "`gortex init`" + `, pass +` + "`--agents-skip claude-code`" + ` so Gortex is not registered twice. See the +plugin README for what that skips. + +## Plugins + +| Plugin | What it adds | +|--------|--------------| +| [` + "`gortex`" + `](plugins/gortex/) | MCP server, workflow skills (` + "`/gortex:explore`" + `, ` + "`/gortex:impact`" + `, ...), sub-agents and hooks | +` + +// EmitMarketplace writes a Claude Code plugin marketplace under +// spec.TargetDir: .claude-plugin/marketplace.json, a README and LICENSE +// at the root, and the plugin itself under plugins/gortex/. Returns the +// paths written, relative to TargetDir, in stable order. +// +// A target that still holds a bundle from before the marketplace +// layout, with the plugin at its root, is refused rather than cleaned: +// the emitter cannot tell its own old files from files someone added. +func EmitMarketplace(spec PluginBundleSpec) ([]string, error) { + if spec.TargetDir == "" { + return nil, fmt.Errorf("EmitMarketplace: TargetDir is required") + } + flatManifest := filepath.Join(spec.TargetDir, ".claude-plugin", "plugin.json") + if _, err := os.Lstat(flatManifest); err == nil { + return nil, fmt.Errorf("%s holds a plugin bundle from the older flat layout; remove it or emit into an empty directory", spec.TargetDir) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("check %s: %w", flatManifest, err) + } + + pluginSpec := spec + pluginSpec.TargetDir = filepath.Join(spec.TargetDir, filepath.FromSlash(marketplacePluginSource)) + pluginFiles, err := EmitPluginBundle(pluginSpec) + if err != nil { + return nil, err + } + + manifest := map[string]any{ + "name": marketplaceName, + "description": marketplaceDescription, + "owner": map[string]any{ + "name": pluginAuthorName, + "email": pluginAuthorEmail, + }, + "plugins": []any{ + map[string]any{ + "name": pluginName, + "source": marketplacePluginSource, + "description": marketplacePluginDescription, + }, + }, + } + manifestJSON, err := marshalJSONStable(manifest) + if err != nil { + return nil, fmt.Errorf("marshal marketplace.json: %w", err) + } + + root := []struct { + rel string + body []byte + }{ + {".claude-plugin/marketplace.json", manifestJSON}, + {"README.md", []byte(marketplaceReadmeBody)}, + {"LICENSE", []byte(pluginLicenseBody)}, + } + written := make([]string, 0, len(root)+len(pluginFiles)) + for _, f := range root { + full := filepath.Join(spec.TargetDir, f.rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(f.rel), err) + } + if err := os.WriteFile(full, f.body, 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", f.rel, err) + } + written = append(written, f.rel) + } + for _, rel := range pluginFiles { + written = append(written, filepath.Join(filepath.FromSlash(marketplacePluginSource), rel)) + } + return written, nil +} diff --git a/internal/agents/claudecode/marketplace_test.go b/internal/agents/claudecode/marketplace_test.go new file mode 100644 index 000000000..34e6522ae --- /dev/null +++ b/internal/agents/claudecode/marketplace_test.go @@ -0,0 +1,199 @@ +package claudecode + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEmitMarketplace_Layout(t *testing.T) { + dir := t.TempDir() + written, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) + if err != nil { + t.Fatal(err) + } + + for _, rel := range []string{ + filepath.Join(".claude-plugin", "marketplace.json"), + "README.md", + "LICENSE", + filepath.Join("plugins", "gortex", ".claude-plugin", "plugin.json"), + filepath.Join("plugins", "gortex", ".mcp.json"), + filepath.Join("plugins", "gortex", "skills", "explore", "SKILL.md"), + } { + if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { + t.Errorf("expected %s: %v", rel, err) + } + } + // The marketplace root must not also look like a plugin. + for _, rel := range []string{ + filepath.Join(".claude-plugin", "plugin.json"), + ".mcp.json", + "skills", + "hooks", + } { + if _, err := os.Stat(filepath.Join(dir, rel)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("marketplace root must not contain %s (stat err = %v)", rel, err) + } + } + for _, rel := range written { + if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { + t.Errorf("reported path %s was not written: %v", rel, err) + } + } +} + +func TestEmitMarketplace_ManifestPointsAtPlugin(t *testing.T) { + dir := t.TempDir() + if _, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil { + t.Fatal(err) + } + + var m struct { + Name string `json:"name"` + Description string `json:"description"` + Owner struct { + Name string `json:"name"` + } `json:"owner"` + Plugins []struct { + Name string `json:"name"` + Source string `json:"source"` + Version string `json:"version"` + } `json:"plugins"` + } + raw, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("marketplace.json: %v", err) + } + if m.Name != "gortex" || m.Owner.Name == "" { + t.Errorf("marketplace name/owner = %q/%q", m.Name, m.Owner.Name) + } + // claude plugin validate warns when the description is missing. + if m.Description == "" { + t.Error("marketplace.json must carry a description") + } + if len(m.Plugins) != 1 { + t.Fatalf("plugins = %d, want 1", len(m.Plugins)) + } + entry := m.Plugins[0] + if !strings.HasPrefix(entry.Source, "./") { + t.Errorf("relative source must start with ./, got %q", entry.Source) + } + // plugin.json owns the version; a second copy in the entry would drift. + if entry.Version != "" { + t.Errorf("entry must not pin a version, got %q", entry.Version) + } + + var plugin struct { + Name string `json:"name"` + } + raw, err = os.ReadFile(filepath.Join(dir, filepath.FromSlash(entry.Source), ".claude-plugin", "plugin.json")) + if err != nil { + t.Fatalf("entry source %q has no plugin.json: %v", entry.Source, err) + } + if err := json.Unmarshal(raw, &plugin); err != nil { + t.Fatal(err) + } + if plugin.Name != entry.Name { + t.Errorf("entry name %q must match plugin.json name %q", entry.Name, plugin.Name) + } +} + +func TestEmitMarketplace_RefusesFlatBundle(t *testing.T) { + dir := t.TempDir() + // A bundle from before the marketplace layout, with the plugin at + // the target root. + if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil { + t.Fatal(err) + } + + _, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) + if err == nil || !strings.Contains(err.Error(), "older flat layout") { + t.Fatalf("expected a flat-layout refusal, got %v", err) + } + // Refusing must leave the target untouched. + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { + t.Errorf("flat bundle must be left in place: %v", err) + } + for _, rel := range []string{ + filepath.Join(".claude-plugin", "marketplace.json"), + "plugins", + } { + if _, err := os.Stat(filepath.Join(dir, rel)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("refused emit must not write %s (stat err = %v)", rel, err) + } + } +} + +func TestEmitMarketplace_KeepsUnrelatedFiles(t *testing.T) { + dir := t.TempDir() + // Files someone keeps next to the marketplace, including names the + // plugin also uses, must survive an emit. + userFiles := []string{ + filepath.Join(dir, ".mcp.json"), + filepath.Join(dir, "skills", "explore", "SKILL.md"), + } + for _, full := range userFiles { + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil { + t.Fatal(err) + } + for _, full := range userFiles { + b, err := os.ReadFile(full) + if err != nil || string(b) != "keep" { + t.Errorf("%s must be left alone (err = %v, body = %q)", full, err, b) + } + } +} + +func TestEmitMarketplace_Idempotent(t *testing.T) { + dir := t.TempDir() + first, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) + if err != nil { + t.Fatal(err) + } + bodies := map[string]string{} + for _, rel := range first { + b, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Fatal(err) + } + bodies[rel] = string(b) + } + + second, err := EmitMarketplace(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) + if err != nil { + t.Fatalf("second emit: %v", err) + } + if len(first) != len(second) { + t.Fatalf("file count drift between runs: %d vs %d", len(first), len(second)) + } + for _, rel := range second { + b, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Fatalf("%s missing after second emit: %v", rel, err) + } + if bodies[rel] != string(b) { + t.Errorf("non-idempotent output at %s", rel) + } + } +} + +func TestEmitMarketplace_RejectsEmptyTarget(t *testing.T) { + if _, err := EmitMarketplace(PluginBundleSpec{Version: "0.18.2"}); err == nil { + t.Error("expected an error for an empty TargetDir") + } +} diff --git a/internal/agents/claudecode/plugin.go b/internal/agents/claudecode/plugin.go index 59decb8df..9fb9d4d82 100644 --- a/internal/agents/claudecode/plugin.go +++ b/internal/agents/claudecode/plugin.go @@ -2,7 +2,6 @@ package claudecode import ( "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -44,7 +43,7 @@ const ( // pluginAuthorName / pluginAuthorEmail / pluginHomepage are the // fields baked into every plugin.json. They are package-level so the -// marketplace.json submission script can read the same values +// marketplace.json emitter (EmitMarketplace) reads the same values // without re-deriving them. const ( pluginName = "gortex" @@ -204,11 +203,22 @@ This plugin assumes the ` + "`gortex`" + ` binary is on PATH. Install it via: ` + "```sh\ncurl -fsSL https://get.gortex.dev | sh\n```" + ` The installer fetches a signed release tarball, verifies cosign + SHA256, -installs to ` + "`~/.local/bin`" + ` (or ` + "`/usr/local/bin`" + `), and runs ` + "`gortex install`" + ` -+ ` + "`gortex init`" + `. Homebrew users on macOS can also use: +and installs to ` + "`~/.local/bin`" + ` (or ` + "`/usr/local/bin`" + `). Homebrew users on macOS +can also use: ` + "```sh\nbrew install zzet/tap/gortex\n```" + ` +Then add the marketplace and install the plugin: + +` + "```sh\nclaude plugin marketplace add gortexhq/claude-plugin\nclaude plugin install gortex@gortex\n```" + ` + +The plugin covers Claude Code's MCP server, workflow skills, sub-agents and +hooks. Running ` + "`gortex install`" + ` and ` + "`gortex init`" + ` for Claude Code as well +registers Gortex twice, which duplicates them. To avoid that, pass +` + "`--agents-skip claude-code`" + ` to both. This also skips the CLAUDE.md blocks and +the per-repo community skills under ` + "`.claude/skills/generated/`" + `, which the +plugin does not provide. + ## What this plugin adds | Surface | What you get | @@ -258,8 +268,8 @@ Copyright 2024-2026 Andrey Kumanyaev // Single source of truth: skills come from agents.GlobalSkills, reused // from the agents package without duplication, so a change to a skill's // body flows through both the user-level install (~/.claude/skills/) and -// the marketplace plugin (claude-plugin/skills/) on the next emit. Only -// the skill name differs; see pluginSkillName. +// the marketplace plugin (claude-plugin/plugins/gortex/skills/) on the +// next emit. Only the skill name differs; see pluginSkillName. func EmitPluginBundle(spec PluginBundleSpec) ([]string, error) { if spec.LayoutVariant == "" { spec.LayoutVariant = LayoutVariantAnthropic @@ -325,13 +335,6 @@ func EmitPluginBundle(spec PluginBundleSpec) ([]string, error) { return nil, err } - // Bundles from before this layout also carried commands/ and - // prefixed skills/gortex-*/ directories. Remove those generated paths - // so emitting over an old bundle does not list every workflow twice. - if err := removeLegacyPluginPaths(spec.TargetDir); err != nil { - return nil, err - } - // 5. skills//SKILL.md — sorted for deterministic output. No // commands/ directory: a plugin skill is already a slash command, so // emitting both would list every workflow twice. @@ -425,35 +428,6 @@ func pluginSkillBody(body, name string) string { return strings.Join(lines, "") } -// removeLegacyPluginPaths deletes the files an older emit wrote: -// commands/ for every slash command and skills//SKILL.md for -// every skill whose plugin name now drops the gortex- prefix. Only those -// files are deleted; their directories go only once empty, so files -// someone added next to them survive. -func removeLegacyPluginPaths(targetDir string) error { - stale := make([]string, 0, len(SlashCommands)+len(GlobalSkills)) - dirs := []string{"commands"} - for _, name := range sortedKeys(SlashCommands) { - stale = append(stale, filepath.Join("commands", name)) - } - for _, id := range sortedKeys(GlobalSkills) { - if pluginSkillName(id) != id { - stale = append(stale, filepath.Join("skills", id, "SKILL.md")) - dirs = append(dirs, filepath.Join("skills", id)) - } - } - for _, rel := range stale { - if err := os.Remove(filepath.Join(targetDir, rel)); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove stale %s: %w", rel, err) - } - } - for _, rel := range dirs { - // Fails harmlessly when the directory is absent or not empty. - _ = os.Remove(filepath.Join(targetDir, rel)) - } - return nil -} - // marshalJSONStable returns indented JSON with sorted keys at every // level. Two-space indent matches Claude Code's reference plugins; // trailing newline keeps the file POSIX-text-friendly and avoids diff --git a/internal/agents/claudecode/plugin_test.go b/internal/agents/claudecode/plugin_test.go index 613e8c67c..0d18028e3 100644 --- a/internal/agents/claudecode/plugin_test.go +++ b/internal/agents/claudecode/plugin_test.go @@ -325,59 +325,6 @@ func TestEmitPluginBundle_NoCommandsDir(t *testing.T) { } } -func TestEmitPluginBundle_RemovesLegacyPaths(t *testing.T) { - dir := t.TempDir() - cmdName := sortedKeys(SlashCommands)[0] - skillID := sortedKeys(GlobalSkills)[0] - legacy := []string{ - filepath.Join("commands", cmdName), - filepath.Join("skills", skillID, "SKILL.md"), - } - for _, rel := range legacy { - full := filepath.Join(dir, rel) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte("stale"), 0o644); err != nil { - t.Fatal(err) - } - } - // Files the emitter never wrote, including one inside a legacy - // skill directory, must survive the cleanup. - userFiles := []string{ - filepath.Join(dir, "skills", "my-own", "SKILL.md"), - filepath.Join(dir, "skills", sortedKeys(GlobalSkills)[1], "references", "team.md"), - } - for _, full := range userFiles { - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte("keep"), 0o644); err != nil { - t.Fatal(err) - } - } - - if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil { - t.Fatal(err) - } - - if _, err := os.Stat(filepath.Join(dir, "commands")); !errors.Is(err, os.ErrNotExist) { - t.Errorf("legacy commands/ should be removed (stat err = %v)", err) - } - if _, err := os.Stat(filepath.Join(dir, "skills", skillID)); !errors.Is(err, os.ErrNotExist) { - t.Errorf("legacy skills/%s should be removed (stat err = %v)", skillID, err) - } - for _, full := range userFiles { - if _, err := os.Stat(full); err != nil { - t.Errorf("files the emitter did not write must survive: %v", err) - } - } - legacySkill := filepath.Join(dir, "skills", sortedKeys(GlobalSkills)[1], "SKILL.md") - if _, err := os.Stat(legacySkill); !errors.Is(err, os.ErrNotExist) { - t.Errorf("legacy %s should be removed (stat err = %v)", legacySkill, err) - } -} - func TestPluginSkillBody_OnlyRewritesLeadingFrontmatter(t *testing.T) { cases := map[string]string{ "---\nname: gortex-x\ndescription: d\n---\nbody\n": "---\nname: x\ndescription: d\n---\nbody\n",