Skip to content

Commit efc920c

Browse files
theletterfclaude
andcommitted
feat(adapters): install and update the Claude plugin during sync
When elastic-docs-utils syncs the Claude adapter it now calls `claude plugin install` (first run) or `claude plugin update` (subsequent runs) for the elastic-docs-skills plugin, so skills stay current alongside MCP and hook configuration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fee3133 commit efc920c

2 files changed

Lines changed: 78 additions & 0 deletions

File tree

‎internal/adapters/adapters.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
publicMCP = "https://www.elastic.co/docs/_mcp/"
3535
legacyPublicMCP = "https://d34ipnu52o64md.cloudfront.net/docs/_mcp"
3636
internalMCP = "https://codex.elastic.dev/mcp"
37+
publicPlugin = "elastic-docs-skills"
3738
)
3839

3940
type Result struct {
@@ -210,6 +211,11 @@ func addClaude(internal, dryRun bool, executable string) ([]string, error) {
210211
return nil, err
211212
}
212213
}
214+
if !dryRun {
215+
if err := claudePluginInstallOrUpdate(); err != nil {
216+
return nil, err
217+
}
218+
}
213219
path := filepath.Join(home, ".claude", "settings.json")
214220
if dryRun {
215221
return []string{filepath.Join(home, ".claude.json"), path}, nil
@@ -240,6 +246,57 @@ func addCodex(internal, dryRun bool) ([]string, error) {
240246
return []string{filepath.Join(home, ".codex", "config.toml")}, nil
241247
}
242248

249+
// claudePluginInstallOrUpdate installs the public Elastic Docs skills plugin
250+
// when it is absent, or updates it when it is already present.
251+
func claudePluginInstallOrUpdate() error {
252+
installed, err := claudePluginInstalled(publicPlugin)
253+
if err != nil {
254+
return err
255+
}
256+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
257+
defer cancel()
258+
var args []string
259+
if installed {
260+
args = []string{"plugin", "update", "--scope", "user", publicPlugin}
261+
} else {
262+
args = []string{"plugin", "install", "--scope", "user", publicPlugin}
263+
}
264+
out, err := exec.CommandContext(ctx, "claude", args...).CombinedOutput()
265+
if err != nil {
266+
return fmt.Errorf("claude %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
267+
}
268+
return nil
269+
}
270+
271+
// claudePluginInstalled checks whether the named plugin appears in the output
272+
// of `claude plugin list --json`. The plugin ID has the form name@marketplace.
273+
func claudePluginInstalled(name string) (bool, error) {
274+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
275+
defer cancel()
276+
out, err := exec.CommandContext(ctx, "claude", "plugin", "list", "--json").CombinedOutput()
277+
if err != nil {
278+
return false, fmt.Errorf("claude plugin list: %s", strings.TrimSpace(string(out)))
279+
}
280+
return parseInstalledPlugins(string(out), name)
281+
}
282+
283+
// parseInstalledPlugins is the pure parsing step extracted for testing.
284+
func parseInstalledPlugins(output, name string) (bool, error) {
285+
var plugins []struct {
286+
ID string `json:"id"`
287+
}
288+
if err := json.Unmarshal([]byte(output), &plugins); err != nil {
289+
return false, fmt.Errorf("parse claude plugin list: %w", err)
290+
}
291+
for _, p := range plugins {
292+
pluginName, _, _ := strings.Cut(p.ID, "@")
293+
if pluginName == name {
294+
return true, nil
295+
}
296+
}
297+
return false, nil
298+
}
299+
243300
func runAdd(command string, args ...string) error {
244301
out, err := exec.Command(command, args...).CombinedOutput()
245302
if err == nil {

‎internal/adapters/adapters_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,24 @@ func TestClaudeHookCommandRequiresExecutable(t *testing.T) {
116116
t.Fatal("empty executable was accepted")
117117
}
118118
}
119+
120+
func TestParseInstalledPlugins(t *testing.T) {
121+
output := `[{"id":"elastic-docs-skills@elastic-docs-skills","version":"1.0.11","scope":"user","enabled":true},{"id":"other-plugin@some-marketplace","version":"1.0.0","scope":"user","enabled":false}]`
122+
found, err := parseInstalledPlugins(output, "elastic-docs-skills")
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if !found {
127+
t.Fatal("expected elastic-docs-skills to be found")
128+
}
129+
found, err = parseInstalledPlugins(output, "missing-plugin")
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
if found {
134+
t.Fatal("expected missing-plugin to not be found")
135+
}
136+
if _, err := parseInstalledPlugins("not-json", "any"); err == nil {
137+
t.Fatal("invalid JSON was accepted")
138+
}
139+
}

0 commit comments

Comments
 (0)