Files
mesh-catalog/modules/claude-code/cmd/claude-code/config_tools.go
T
jochen e139fb4dd9 claude-code: set shared agent text at the controller's terminal, for now
Instructions, output styles, skills, commands and subagents for every node
or some reach every session as the mesh's own word, and the tools cannot
tell an agent from the operator; the operator decided they are set at the
terminal until a surface of their own updates them (hq issue 340). They
are settings keys, each text a list of lines; the home scope stays free.
2026-10-09 09:41:52 +02:00

296 lines
17 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 (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
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 — refused (novox/hq issue 340): a hook runs its command in every session it reaches, so it is the operator's, in this module's managed_settings setting at the controller's terminal",
"no file any more (novox/hq issue 340): a hook registered before is listed and may be unregistered, and is never rendered"},
{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:
// Refused whatever is given (novox/hq issue 340): no arguments but the name and scope are described.
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 + " in an account's own directory (scope home) on this node or the nodes given. For every node (mesh, the default) or some (node) it is refused and says why (novox/hq issue 340): shared text is set at the controller's terminal, in this module's settings, until a surface of the operator's own updates it. Kept on the bus. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).",
Input: input,
Run: func(a map[string]any) (any, error) {
if k.kind == KindHook {
return nil, errors.New(RunsInASession(KindHook) + ". Nothing was registered")
}
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}
if problem := runsProblem(it); problem != "" {
return nil, errors.New(problem + ". Nothing was registered")
}
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 they came from: the operator's `managed_settings` setting, set at the controller's terminal (ADR 0213, novox/hq issue 340), then the mesh's own keys, which always win. Settings registered through this module before registering was refused are listed, and not rendered.",
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)
left, err := List(state, KindSettings)
if err != nil {
return nil, err
}
return map[string]any{"written": written,
"managed_settings setting": settings.ManagedSettings,
"order": "the managed_settings setting, then the mesh's own keys",
"registered before, not rendered": left}, nil
}},
stdio.Tool{Name: "claude_code_settings_set",
Description: "Refused, and says why (novox/hq issue 340): the managed settings say what runs without asking in every session they reach — permissions, hooks, the status line, the environment — and this tool cannot tell an agent from the operator. They are the operator's, in this module's managed_settings setting at the controller's terminal.",
Input: map[string]any{
"settings": map[string]any{"type": "object", "description": "settings keys"},
"scope": settingsScope, "nodes": nodesArg,
},
Run: func(map[string]any) (any, error) {
return nil, errors.New(RunsInASession(KindSettings) + ". Nothing was registered")
}},
stdio.Tool{Name: "claude_code_settings_clear",
Description: "Remove settings registered at a scope before registering them was refused (novox/hq issue 340). They are not rendered any more; this removes what is left of them on the bus.",
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: "Refused, and says why (novox/hq issue 340): a permission rule says what runs without asking in every session it reaches, and this tool cannot tell an agent from the operator. Permission rules are the operator's, under `permissions` in this module's managed_settings setting at the controller's terminal.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(map[string]any) (any, error) {
return nil, errors.New(RunsInASession(KindSettings) + ". Nothing was registered")
}},
stdio.Tool{Name: "claude_code_permission_remove",
Description: "Refused, and says why (novox/hq issue 340): permission rules are the operator's, in this module's managed_settings setting at the controller's terminal, and rules registered through this module before are not rendered; claude_code_settings_clear removes what is left of them.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(map[string]any) (any, error) {
return nil, errors.New("permission rules are set and removed at the controller's terminal only, in this module's " +
"managed_settings setting: " + AtTheTerminal + ". Rules registered through this module before are not " +
"rendered; claude_code_settings_clear removes what is left of them (novox/hq issue 340). Nothing was changed")
}},
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, rule files and memory (CLAUDE.md) — 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: "Refused for now, and says why (novox/hq issue 340): it registered an item of this node's own ~/.claude for every node or some, and text for every node or some is set at the controller's terminal, in this module's settings. Read the item with claude_code_home_show and set it there.",
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")
}
if problem := runsProblem(it); problem != "" {
return nil, errors.New(problem + ". Nothing was registered")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, false, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_home_show",
Description: "One item of this node's operator account's own ~/.claude in full, as the status tool lists it — a skill (every file in its folder), subagent, command, output style or rule file — or the account's own memory, ~/.claude/CLAUDE.md: where it is, whether the mesh placed it, and its content.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
},
Run: func(a map[string]any) (any, error) { return ShowHome(p, strArg(a, "kind"), strArg(a, "name")) }},
stdio.Tool{Name: "claude_code_home_remove",
Description: "Remove one item the person made in this node's operator account's own ~/.claude — a skill, subagent, command, output style, rule file, or the memory ~/.claude/CLAUDE.md — on the person's word: removing it is the person's act, and this tool is that act made explicit. Called only when the person asked for that item to go, never on the agent's own judgement. `why` is required. An item the mesh placed is refused (its unregister owns it). A copy is kept first in the module's state, under removed-from-home/<date>/, and the removal and its reason are logged there; the answer says where the copy is, so it can be put back.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
"why": str("the person's reason for removing it, kept in the log"),
},
Run: func(a map[string]any) (any, error) {
answer, err := RemoveHome(p, strArg(a, "kind"), strArg(a, "name"), strArg(a, "why"), time.Now())
if err != nil {
return nil, err
}
// A home registration of the same name was held back by the person's file; it is placed now.
for _, it := range ConfigOf(view.Items()).Home {
if it.Kind == answer["kind"] && it.Name == answer["name"] {
answer["then"] = "the mesh registers a " + it.Kind + " of this name for this home; it is placed at the next render"
}
}
return answer, nil
}},
stdio.Tool{Name: "claude_code_home_removed",
Description: "Every item removed from this node's home through claude_code_home_remove whose copy the module keeps, newest first: what it was, where it was, why it went, its keptAt, and whether it was put back.",
Run: func(map[string]any) (any, error) { return KeptRemovals(p) }},
stdio.Tool{Name: "claude_code_home_restore",
Description: "Undo a removal made with claude_code_home_remove: put the kept copy back at the path it was removed from. Refused when something is at that path now, or when the copy is not exactly what was removed (checked file by file against the digests its removal.json recorded). Logged as the removal was; the copy stays.",
Input: map[string]any{"kept": str("the keptAt the removal answered, as claude_code_home_removed lists it")},
Run: func(a map[string]any) (any, error) { return RestoreHome(p, strArg(a, "kept"), time.Now()) }},
)
return out
}