Refuse paths with empty, dot or parent segments and a file that is also a directory; take an item only when its key says what it is; write the view under its lock; expand nodes all and check node names; merge the plugin's entries into the operator's own; render every file past one that fails; in the home, never take over the person's file, never write through a symbolic link, keep a deleted file deleted, keep the kind's directory; and refuse settings that would deny the console or the marketplace.
354 lines
16 KiB
Go
354 lines
16 KiB
Go
package main
|
|
|
|
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
|
|
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
|
|
// for what the module did not place.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// kindTool is how one kind is named in its tools and described to whoever calls them.
|
|
type kindTool struct {
|
|
kind, tool, what, lands string
|
|
}
|
|
|
|
var kindTools = []kindTool{
|
|
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
|
|
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
|
|
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
|
|
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
|
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
|
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
|
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
|
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
|
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
|
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
|
{KindInstructions, "instructions", "a section of instructions every session reads",
|
|
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
|
|
}
|
|
|
|
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
|
|
func strArg(a map[string]any, k string) string {
|
|
s, _ := a[k].(string)
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
|
func targetNodes(a map[string]any) ([]string, error) {
|
|
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
|
}
|
|
|
|
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
|
func scopeOf(a map[string]any) string {
|
|
if s := strArg(a, "scope"); s != "" {
|
|
return s
|
|
}
|
|
return ScopeMesh
|
|
}
|
|
|
|
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
|
|
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
|
out := map[string]string{}
|
|
if raw, ok := a["files"].(map[string]any); ok {
|
|
for rel, v := range raw {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("files.%s is not text", rel)
|
|
}
|
|
out[rel] = s
|
|
}
|
|
}
|
|
if content, ok := a["content"].(string); ok && content != "" {
|
|
switch kind {
|
|
case KindSkill:
|
|
out["SKILL.md"] = content
|
|
case KindHook:
|
|
return nil, errors.New("a hook's scripts are given as files")
|
|
default:
|
|
out[name+".md"] = content
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
|
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
|
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
|
var out []stdio.Tool
|
|
for _, k := range kindTools {
|
|
k := k
|
|
input := map[string]any{
|
|
"name": str("the name: lower-case letters, digits and -"),
|
|
"scope": scopeArg,
|
|
"nodes": nodesArg,
|
|
}
|
|
switch k.kind {
|
|
case KindSkill:
|
|
input["content"] = str("the SKILL.md, when the skill is that one file")
|
|
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
|
case KindHook:
|
|
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
|
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
|
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
|
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
|
default:
|
|
input["content"] = str("the file's content, front matter included")
|
|
}
|
|
out = append(out,
|
|
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
|
|
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
|
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
|
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
|
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
|
Input: input,
|
|
Run: func(a map[string]any) (any, error) {
|
|
name := strArg(a, "name")
|
|
files, err := filesOf(k.kind, name, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
|
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Register(p, it, nodes, false, state, view, writeManaged)
|
|
}},
|
|
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
|
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
|
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Register(p, it, nodes, true, state, view, writeManaged)
|
|
}},
|
|
)
|
|
}
|
|
|
|
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
|
out = append(out,
|
|
stdio.Tool{Name: "claude_code_settings_get",
|
|
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
|
Run: func(map[string]any) (any, error) {
|
|
written := map[string]any{}
|
|
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
|
var settings Settings
|
|
_ = readJSON(p.Settings, &settings)
|
|
c := ConfigOf(view.Items())
|
|
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
|
for _, it := range c.Mesh {
|
|
if it.Kind == KindSettings {
|
|
layers["registered for the mesh"] = it.Settings
|
|
}
|
|
}
|
|
for _, it := range c.Node {
|
|
if it.Kind == KindSettings {
|
|
layers["registered for this node"] = it.Settings
|
|
}
|
|
}
|
|
return map[string]any{"written": written, "layers": layers,
|
|
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
|
}},
|
|
stdio.Tool{Name: "claude_code_settings_set",
|
|
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
|
Input: map[string]any{
|
|
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
|
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
|
"scope": settingsScope, "nodes": nodesArg,
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
given, _ := a["settings"].(map[string]any)
|
|
if len(given) == 0 {
|
|
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
|
}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
|
if boolArg(a, "replace") {
|
|
return given
|
|
}
|
|
return mergeSettings(held, given)
|
|
}, state, view)
|
|
}},
|
|
stdio.Tool{Name: "claude_code_settings_clear",
|
|
Description: "Remove the Claude Code settings registered at a scope.",
|
|
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Register(p, it, nodes, true, state, view, writeManaged)
|
|
}},
|
|
stdio.Tool{Name: "claude_code_permission_add",
|
|
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
|
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
list, rule := strArg(a, "list"), strArg(a, "rule")
|
|
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
|
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
|
}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
|
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
|
}, state, view)
|
|
}},
|
|
stdio.Tool{Name: "claude_code_permission_remove",
|
|
Description: "Remove a permission rule added through this module, at its scope.",
|
|
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
list, rule := strArg(a, "list"), strArg(a, "rule")
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
|
perms, _ := held["permissions"].(map[string]any)
|
|
rules, _ := perms[list].([]any)
|
|
if len(rules) == 0 {
|
|
return held // nothing to remove: what the scope holds stays as it is
|
|
}
|
|
kept := []any{}
|
|
for _, r := range rules {
|
|
if r != rule {
|
|
kept = append(kept, r)
|
|
}
|
|
}
|
|
next := mergeSettings(map[string]any{}, held)
|
|
np := mergeSettings(map[string]any{}, perms)
|
|
np[list] = kept
|
|
next["permissions"] = np
|
|
return next
|
|
}, state, view)
|
|
}},
|
|
stdio.Tool{Name: "claude_code_config_list",
|
|
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
|
Run: func(map[string]any) (any, error) { return List(state, "") }},
|
|
stdio.Tool{Name: "claude_code_config_show",
|
|
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
|
|
Input: map[string]any{"key": str("the key")},
|
|
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
|
stdio.Tool{Name: "claude_code_config_status",
|
|
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
|
Run: func(map[string]any) (any, error) {
|
|
c := ConfigOf(view.Items())
|
|
names := func(items []Item) []string {
|
|
out := []string{}
|
|
for _, it := range items {
|
|
out = append(out, it.Kind+" "+it.Name)
|
|
}
|
|
return out
|
|
}
|
|
var mcp struct {
|
|
MCPServers Servers `json:"mcpServers"`
|
|
}
|
|
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
|
|
var placed Placed
|
|
_ = readJSON(p.placed(), &placed)
|
|
plugin := []string{}
|
|
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
|
|
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() {
|
|
rel, _ := filepath.Rel(root, full)
|
|
plugin = append(plugin, rel)
|
|
}
|
|
return nil
|
|
})
|
|
return map[string]any{
|
|
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
|
|
"plugin": map[string]any{"name": Plugin, "files": plugin},
|
|
"home": HomeItems(p, c, mcp.MCPServers),
|
|
"placed": placed,
|
|
}, nil
|
|
}},
|
|
stdio.Tool{Name: "claude_code_config_import",
|
|
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
|
|
Input: map[string]any{
|
|
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
|
|
"name": str("its name in the home: the skill's folder, or the file without .md"),
|
|
"scope": scopeArg, "nodes": nodesArg,
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
it.Scope = scopeOf(a)
|
|
if it.Scope == ScopeHome {
|
|
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
|
}
|
|
nodes, err := targetNodes(a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Register(p, it, nodes, false, state, view, writeManaged)
|
|
}},
|
|
)
|
|
return out
|
|
}
|
|
|
|
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
|
// the key holds. An empty result removes the key.
|
|
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
|
state ConfigState, view *ConfigView) (map[string]any, error) {
|
|
if scope != ScopeMesh && scope != ScopeNode {
|
|
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
|
}
|
|
if scope == ScopeMesh {
|
|
nodes = []string{""}
|
|
} else if len(nodes) == 0 {
|
|
nodes = []string{p.Node}
|
|
} else if problem := nodesProblem(nodes); problem != "" {
|
|
return map[string]any{"set": false, "reason": problem}, nil
|
|
}
|
|
answers := map[string]any{}
|
|
for _, n := range nodes {
|
|
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
|
held := map[string]any{}
|
|
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
|
return nil, err
|
|
} else if found {
|
|
var was Item
|
|
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
|
held = was.Settings
|
|
}
|
|
}
|
|
it.Settings = change(held)
|
|
target := []string(nil)
|
|
if n != "" {
|
|
target = []string{n}
|
|
}
|
|
var answer map[string]any
|
|
var err error
|
|
if len(it.Settings) == 0 {
|
|
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
|
} else {
|
|
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer["settings"] = it.Settings
|
|
answers[it.Key(n)] = answer
|
|
}
|
|
return answers, nil
|
|
}
|