diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 935bec3..ea92d70 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -22,11 +22,16 @@ whenever the node's tool runtime collects the module's tools: | file | holds | |---|---| | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | -| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence | -| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions | +| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin | +| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one | +| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:`. Replaced whole, staged beside and swapped in | -Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands -this node a subscription token. Nothing else under the home is read or written. +Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a +subscription token; and what is registered at the **home** scope for this node — a skill, subagent, +command, output style, or instructions as a rule file — each path recorded in the module's state +(`home-placed.json`). It writes, changes and removes only those, never a path the person made, and leaves a +placed file alone once it was changed by hand (hq ADR 0182). The status tool reads the rest of the home's +items to report them; nothing else is read or written. ## Over NATS @@ -41,6 +46,7 @@ must see, a node that joins later included — kept, so it carries no secret eit | a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it | | what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back | | an MCP server registered through this module | a key in the module's `servers` state — `all.` for every node, `.` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime | +| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh..` for every node, `node...` for one, `home...` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name | ## Tools @@ -49,6 +55,23 @@ manager), `claude_code_mcp_list`, `claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`. +The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node` +(`nodes`, or this node) or `home` (the operator account's own `~/.claude` on `nodes`, or this node): + +- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` — + `claude_code__list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a + lone SKILL.md); a hook is an `event`, a `matcher`, a `command` and its scripts as `files`, with + `${HOOK_DIR}` in the command naming their directory. Hooks and settings take no home scope; +- `claude_code_settings_get`, `_set` (merged into the scope, or `replace`), `_clear`; + `claude_code_permission_add` and `_remove` for one allow, ask or deny rule. The agent refuses to loosen + its own settings: these are the operator's to call; +- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin + as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which + call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope; + the original stays). + +A new session takes a change; a running one at `/reload-plugins`. + ## Settings Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`: diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index d5cc035..5ed5fe6 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) { for _, file := range []string{"managed-mcp.json", "managed-settings.json"} { var a, b any _ = json.Unmarshal([]byte(got[file]), &a) + // The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same. + if m, ok := a.(map[string]any); ok && file == "managed-settings.json" { + for k := range MarketplaceKeys() { + delete(m, k) + } + } _ = json.Unmarshal([]byte(want[file]), &b) if !reflect.DeepEqual(a, b) { t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file]) } } } - same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey) - same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain) + same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey) + same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain) } func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) { out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"}, - Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil) + Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{}) var mcp struct { MCPServers map[string]map[string]any `json:"mcpServers"` } @@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { t.Fatalf("%v", mcp.MCPServers) } - if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) { + if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) { t.Fatal("rendering is not deterministic") } } @@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) { }} read := func(binding *Binding) map[string]any { var m map[string]any - out := Render(Facts{Console: "x"}, settings, binding, "/h", nil) + out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{}) if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil { t.Fatal(err) } diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go new file mode 100644 index 0000000..680fce7 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/config.go @@ -0,0 +1,806 @@ +package main + +// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8). +// +// Every registration is one key in the module's `config` state (ADR 0201): +// +// mesh.. every node running the agent +// node... one node — a list of nodes is one key each +// home... the operator account's own agent directory on one node +// +// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind, +// the one place the vendor honours for it: +// +// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory +// (at the home scope: the home's own directories) +// instructions sections of the managed instruction file (home: a rule file) +// settings the managed settings file, mesh then node, under the mesh's keys +// +// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file +// would block a plugin's. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:`. +const Plugin = "nox-mesh" + +// MarketplaceDir is where the marketplace is written, inside the managed directory. +const MarketplaceDir = "marketplace" + +// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit. +const MaxItemBytes = 256 << 10 + +// The kinds a registration may be. +const ( + KindSkill = "skill" + KindAgent = "agent" + KindCommand = "command" + KindHook = "hook" + KindOutputStyle = "output-style" + KindInstructions = "instructions" + KindSettings = "settings" +) + +// Kinds is every kind, in the order a list shows them. +var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings} + +// The scopes. +const ( + ScopeMesh = "mesh" + ScopeNode = "node" + ScopeHome = "home" +) + +// SettingsName is the one name a settings registration has: a scope holds one set of settings. +const SettingsName = "settings" + +var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + +// hookEvents are the events a hook may be registered for. +var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true, + "Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true} + +// Item is one registration, as the state keeps it. +type Item struct { + Kind string `json:"kind"` + Name string `json:"name"` + Scope string `json:"scope"` + // Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside + // it; for the one-file kinds, `.md`; a hook's scripts. + Files map[string]string `json:"files,omitempty"` + // A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in. + Event string `json:"event,omitempty"` + Matcher string `json:"matcher,omitempty"` + Command string `json:"command,omitempty"` + // Settings, for the settings kind: keys of the vendor's settings file. + Settings map[string]any `json:"settings,omitempty"` + // Where it was registered from, and when. + By string `json:"by,omitempty"` + At string `json:"at,omitempty"` +} + +// Key is where an item lives in the state, for one node (ignored at the mesh scope). +func (it Item) Key(node string) string { + if it.Scope == ScopeMesh { + return ScopeMesh + "." + it.Kind + "." + it.Name + } + return it.Scope + "." + node + "." + it.Kind + "." + it.Name +} + +// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name. +func ParseKey(key string) (scope, node, kind, name string, ok bool) { + parts := strings.Split(key, ".") + switch { + case len(parts) == 3 && parts[0] == ScopeMesh: + return ScopeMesh, "", parts[1], parts[2], true + case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome): + return parts[0], parts[1], parts[2], parts[3], true + } + return "", "", "", "", false +} + +func knownKind(k string) bool { + for _, x := range Kinds { + if x == k { + return true + } + } + return false +} + +// Problem says why an item cannot be registered, or "". +func (it Item) Problem() string { + if !knownKind(it.Kind) { + return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", ")) + } + if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome { + return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope) + } + if it.Kind == KindSettings { + if it.Scope == ScopeHome { + return "settings take the mesh and node scopes only: the home's settings file is the person's" + } + if it.Name != SettingsName { + return "a scope holds one set of settings, named " + SettingsName + } + if len(it.Settings) == 0 { + return "no settings given" + } + for _, key := range []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins"} { + if _, ok := it.Settings[key]; ok { + return fmt.Sprintf("%q is one of the mesh's own keys; a registration cannot set it", key) + } + } + } else if !itemName.MatchString(it.Name) { + return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name) + } + if it.Kind == KindHook { + if it.Scope == ScopeHome { + return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file" + } + if !hookEvents[it.Event] { + return fmt.Sprintf("%q is not a hook event the agent knows", it.Event) + } + if strings.TrimSpace(it.Command) == "" { + return "a hook needs a command" + } + } + for rel := range it.Files { + if rel == "" || path.IsAbs(rel) || strings.Contains(rel, "\\") || path.Clean(rel) != rel || strings.HasPrefix(rel, "..") { + return fmt.Sprintf("%q is not a path inside the item", rel) + } + } + switch it.Kind { + case KindSkill: + if _, ok := it.Files["SKILL.md"]; !ok { + return "a skill needs a SKILL.md" + } + case KindAgent, KindCommand, KindOutputStyle, KindInstructions: + if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" { + return "this kind is one file, " + it.Name + ".md, with content" + } + } + if n := it.size(); n > MaxItemBytes { + return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes) + } + return "" +} + +func (it Item) size() int { + raw, _ := json.Marshal(it) + return len(raw) +} + +// ---- the view ------------------------------------------------------------------------------------- + +// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items +// for this node — kept in memory from the watch and written through to the module's own file, so the +// managed directory renders without the bus. +type ConfigView struct { + p Paths + mu sync.Mutex + items map[string]Item +} + +// NewConfigView is the view as last written through, or empty. +func NewConfigView(p Paths) *ConfigView { + v := &ConfigView{p: p, items: map[string]Item{}} + _ = readJSON(p.config(), &v.items) + return v +} + +// Applies says whether a key is this node's to take. +func (v *ConfigView) Applies(key string) bool { + scope, node, _, _, ok := ParseKey(key) + return ok && (scope == ScopeMesh || node == v.p.Node) +} + +// Take takes one change, and answers whether what applies to this node changed. +func (v *ConfigView) Take(key, op string, item *Item) bool { + if !v.Applies(key) { + return false + } + v.mu.Lock() + if op == "put" && item != nil && item.Problem() == "" { + v.items[key] = *item + } else { + delete(v.items, key) + } + v.mu.Unlock() + return v.writeThrough() +} + +// Items is what applies here, by key. +func (v *ConfigView) Items() map[string]Item { + v.mu.Lock() + defer v.mu.Unlock() + out := make(map[string]Item, len(v.items)) + for k, it := range v.items { + out[k] = it + } + return out +} + +func (v *ConfigView) writeThrough() bool { + now, _ := indented(v.Items()) + before, _ := os.ReadFile(v.p.config()) + if string(before) == string(now) { + return false + } + _ = os.WriteFile(v.p.config(), now, 0o600) + return true +} + +// Config is what applies to this node, sorted for rendering. +type Config struct { + Mesh, Node, Home []Item +} + +// ConfigOf sorts the items that apply here by scope, each scope by kind and name. +func ConfigOf(items map[string]Item) Config { + var c Config + for _, it := range items { + switch it.Scope { + case ScopeMesh: + c.Mesh = append(c.Mesh, it) + case ScopeNode: + c.Node = append(c.Node, it) + case ScopeHome: + c.Home = append(c.Home, it) + } + } + for _, list := range [][]Item{c.Mesh, c.Node, c.Home} { + sort.Slice(list, func(i, j int) bool { + if list[i].Kind != list[j].Kind { + return list[i].Kind < list[j].Kind + } + return list[i].Name < list[j].Name + }) + } + return c +} + +// ---- rendering -------------------------------------------------------------------------------------- + +// PluginFile is one file of the marketplace: its content and whether it is run. +type PluginFile struct { + Content string + Executable bool +} + +// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid +// over a mesh item of the same kind and name: the node's wins. +func Marketplace(c Config) map[string]PluginFile { + root := "plugins/" + Plugin + "/" + out := map[string]PluginFile{ + ".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{ + "name": Plugin, + "owner": map[string]any{"name": "the mesh"}, + "plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin, + "description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}}, + })}, + root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{ + "name": Plugin, "version": "1.0.0", + "description": "What the mesh registered for the agent: written by the claude-code module, never by hand.", + "author": map[string]any{"name": "the mesh"}, + })}, + } + chosen := map[string]Item{} + for _, layer := range [][]Item{c.Mesh, c.Node} { + for _, it := range layer { + chosen[it.Kind+"/"+it.Name] = it + } + } + keys := make([]string, 0, len(chosen)) + for k := range chosen { + keys = append(keys, k) + } + sort.Strings(keys) + hooks := map[string][]any{} + for _, k := range keys { + it := chosen[k] + switch it.Kind { + case KindSkill: + for rel, content := range it.Files { + out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)} + } + case KindAgent: + out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]} + case KindCommand: + out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]} + case KindOutputStyle: + out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]} + case KindHook: + for rel, content := range it.Files { + out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true} + } + command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`) + entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}} + if it.Matcher != "" { + entry["matcher"] = it.Matcher + } + hooks[it.Event] = append(hooks[it.Event], entry) + } + } + if len(hooks) > 0 { + out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})} + } + return out +} + +func isScript(rel, content string) bool { + return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh") +} + +// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin. +func MarketplaceKeys() map[string]any { + return map[string]any{ + "extraKnownMarketplaces": map[string]any{Plugin: map[string]any{ + "source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}}, + "enabledPlugins": map[string]any{Plugin + "@" + Plugin: true}, + } +} + +// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over. +func RegisteredSettings(c Config) map[string]any { + out := map[string]any{} + for _, layer := range [][]Item{c.Mesh, c.Node} { + for _, it := range layer { + if it.Kind == KindSettings { + out = mergeSettings(out, it.Settings) + } + } + } + return out +} + +// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a +// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's. +func mergeSettings(a, b map[string]any) map[string]any { + out := map[string]any{} + for k, v := range a { + out[k] = v + } + for k, v := range b { + switch nb := v.(type) { + case map[string]any: + if na, ok := out[k].(map[string]any); ok { + out[k] = mergeSettings(na, nb) + continue + } + case []any: + if la, ok := out[k].([]any); ok { + joined := append([]any{}, la...) + seen := map[string]bool{} + for _, x := range la { + raw, _ := json.Marshal(x) + seen[string(raw)] = true + } + for _, x := range nb { + raw, _ := json.Marshal(x) + if !seen[string(raw)] { + seen[string(raw)] = true + joined = append(joined, x) + } + } + out[k] = joined + continue + } + } + out[k] = v + } + return out +} + +// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections +// registered for the mesh, then those for this node. +func InstructionSections(c Config) string { + var b strings.Builder + for _, part := range []struct { + title string + items []Item + }{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} { + var sections []Item + for _, it := range part.items { + if it.Kind == KindInstructions { + sections = append(sections, it) + } + } + if len(sections) == 0 { + continue + } + fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title) + for _, it := range sections { + fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"])) + } + } + return b.String() +} + +// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to +// that directory. +func HomeFiles(c Config) map[string]string { + out := map[string]string{} + for _, it := range c.Home { + switch it.Kind { + case KindSkill: + for rel, content := range it.Files { + out["skills/"+it.Name+"/"+rel] = content + } + case KindAgent: + out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"] + case KindCommand: + out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"] + case KindOutputStyle: + out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"] + case KindInstructions: + out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"] + } + } + return out +} + +// ---- the home --------------------------------------------------------------------------------------- + +// Placed is what this module placed in the home, by path relative to the agent directory, with the digest +// of what it wrote (ADR 0182: the mesh owns what it places, and only that). +type Placed map[string]string + +func digest(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// PlaceHome brings the home in line with what the home scope wants: writes what is wanted and absent or +// its own, never a path the person made, and removes what it placed and is no longer wanted — unless the +// person changed it since. Answers what it did and what it left alone, and why. +func PlaceHome(p Paths, want map[string]string) (done []string, left []string) { + dir := filepath.Join(p.Home, ".claude") + var placed Placed + if !readJSON(p.placed(), &placed) { + placed = Placed{} + } + paths := make([]string, 0, len(want)) + for rel := range want { + paths = append(paths, rel) + } + sort.Strings(paths) + for _, rel := range paths { + full := filepath.Join(dir, filepath.FromSlash(rel)) + content := want[rel] + current, err := os.ReadFile(full) + exists := err == nil + ours, wasPlaced := placed[rel] + switch { + case exists && !wasPlaced: + if string(current) == content { + placed[rel] = digest(content) // the same bytes: taken over, nothing changes + continue + } + left = append(left, rel+": the person's own, left alone") + continue + case exists && wasPlaced && digest(string(current)) != ours && digest(string(current)) != digest(content): + left = append(left, rel+": changed by hand since it was placed, left alone") + continue + case exists && string(current) == content: + placed[rel] = digest(content) + continue + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + left = append(left, rel+": "+err.Error()) + continue + } + mode := os.FileMode(0o644) + if isScript(rel, content) { + mode = 0o755 + } + if err := os.WriteFile(full, []byte(content), mode); err != nil { + left = append(left, rel+": "+err.Error()) + continue + } + placed[rel] = digest(content) + done = append(done, rel+": placed") + } + for rel, ours := range placed { + if _, wanted := want[rel]; wanted { + continue + } + full := filepath.Join(dir, filepath.FromSlash(rel)) + current, err := os.ReadFile(full) + if err == nil && digest(string(current)) != ours { + left = append(left, rel+": no longer registered, but changed by hand, left alone") + delete(placed, rel) + continue + } + _ = os.Remove(full) + removeEmptyParents(dir, filepath.Dir(full)) + delete(placed, rel) + done = append(done, rel+": removed") + } + raw, _ := indented(placed) + _ = os.WriteFile(p.placed(), raw, 0o600) + return done, left +} + +// removeEmptyParents removes empty directories from dir up to, not including, root. +func removeEmptyParents(root, dir string) { + for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) { + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + +// HomeConflict says whether registering an item at the home scope here would collide with something the +// person made: "" when it would not. +func HomeConflict(p Paths, it Item) string { + var placed Placed + _ = readJSON(p.placed(), &placed) + for rel := range HomeFiles(Config{Home: []Item{it}}) { + if _, ours := placed[rel]; ours { + continue + } + if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil { + return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel) + } + } + return "" +} + +// ---- what the module did not place ------------------------------------------------------------------ + +// HomeItem is one item found in the home. +type HomeItem struct { + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + Placed bool `json:"placedByTheMesh"` + Notes []string `json:"notes,omitempty"` +} + +var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`) + +// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh +// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here. +func HomeItems(p Paths, c Config, loaded Servers) []HomeItem { + dir := filepath.Join(p.Home, ".claude") + var placed Placed + _ = readJSON(p.placed(), &placed) + inPlugin := map[string]bool{} + for _, layer := range [][]Item{c.Mesh, c.Node} { + for _, it := range layer { + inPlugin[it.Kind+"/"+it.Name] = true + } + } + var out []HomeItem + add := func(kind, name, rel, body string) { + h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))} + _, h.Placed = placed[rel] + if inPlugin[kind+"/"+name] { + h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name) + } + seen := map[string]bool{} + for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) { + if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil { + seen[server] = true + h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node") + } + } + out = append(out, h) + } + if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md")) + if err != nil { + continue // the vendor's own synced folders carry none at their top + } + add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body)) + } + } + for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} { + entries, err := os.ReadDir(filepath.Join(dir, sub)) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name())) + add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body)) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].Name < out[j].Name + }) + return out +} + +// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions. +func ImportFromHome(p Paths, kind, name string) (Item, error) { + dir := filepath.Join(p.Home, ".claude") + it := Item{Kind: kind, Name: name, Files: map[string]string{}} + switch kind { + case KindSkill: + root := filepath.Join(dir, "skills", name) + err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(root, full) + raw, err := os.ReadFile(full) + if err != nil { + return err + } + it.Files[filepath.ToSlash(rel)] = string(raw) + return nil + }) + if err != nil { + return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err) + } + case KindAgent, KindCommand, KindOutputStyle, KindInstructions: + sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind] + raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md")) + if err != nil { + return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err) + } + it.Files[name+".md"] = string(raw) + default: + return Item{}, fmt.Errorf("a %s is not imported from a home", kind) + } + return it, nil +} + +// ---- registering ------------------------------------------------------------------------------------ + +// ConfigState is the `config` state as this module reaches it through the runtime. +type ConfigState interface { + Put(key string, value any) error + Delete(key string) error + Keys() ([]string, error) + Get(key string) (json.RawMessage, bool, error) +} + +// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the +// node and home scopes one key per node — this node when none is given. Taken into this node's view at once, +// so the answer says what it did here. +func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) { + if !unregister { + it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339) + if problem := it.Problem(); problem != "" { + return map[string]any{"registered": false, "reason": problem}, nil + } + } else if !knownKind(it.Kind) { + return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil + } + if it.Scope == ScopeMesh { + nodes = []string{""} + } else if len(nodes) == 0 { + nodes = []string{p.Node} + } + if !unregister && it.Scope == ScopeHome { + for _, n := range nodes { + if n == p.Node { + if conflict := HomeConflict(p, it); conflict != "" { + return map[string]any{"registered": false, "reason": conflict}, nil + } + } + } + } + var keys []string + changed := false + for _, n := range nodes { + key := it.Key(n) + keys = append(keys, key) + var err error + if unregister { + err = state.Delete(key) + } else { + err = state.Put(key, it) + } + if err != nil { + return nil, err + } + op := "put" + if unregister { + op = "delete" + } + item := it + changed = v.Take(key, op, &item) || changed + } + verb := map[bool]string{false: "registered", true: "unregistered"}[unregister] + answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys} + if it.Kind != KindSettings && it.Scope != ScopeHome && !unregister { + answer["offered as"] = Plugin + ":" + it.Name + } + if changed { + rendered, err := RenderNow(p, write) + if err != nil { + return nil, err + } + answer["rendered here"] = rendered + answer["sessions"] = "a new session takes it; a running one at /reload-plugins" + } else if v.Applies(keys[0]) || len(keys) > 1 { + answer["here"] = "already so" + } else { + answer["here"] = "not this node: each node it is for takes it from the bus" + } + return answer, nil +} + +// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state. +func List(state ConfigState, kind string) (map[string]any, error) { + keys, err := state.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + out := map[string]any{} + for _, key := range keys { + _, _, k, _, ok := ParseKey(key) + if !ok || (kind != "" && k != kind) { + continue + } + raw, found, err := state.Get(key) + if err != nil || !found { + continue + } + var it Item + if json.Unmarshal(raw, &it) != nil { + continue + } + files := make([]string, 0, len(it.Files)) + for f := range it.Files { + files = append(files, f) + } + sort.Strings(files) + summary := map[string]any{"by": it.By, "at": it.At} + if len(files) > 0 { + summary["files"] = files + } + if it.Kind == KindHook { + summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command + } + if it.Kind == KindSettings { + summary["settings"] = it.Settings + } + out[key] = summary + } + return out, nil +} + +// Show is one registration in full: its files' content included. +func Show(state ConfigState, key string) (any, error) { + raw, found, err := state.Get(key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("nothing is registered at %s", key) + } + var it Item + if err := json.Unmarshal(raw, &it); err != nil { + return nil, err + } + return it, nil +} diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go new file mode 100644 index 0000000..9dcefa1 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -0,0 +1,320 @@ +package main + +// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the +// record's "How it is checked". + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it. +type memConfig map[string]json.RawMessage + +func (m memConfig) Put(key string, value any) error { + raw, err := json.Marshal(value) + m[key] = raw + return err +} +func (m memConfig) Delete(key string) error { delete(m, key); return nil } +func (m memConfig) Keys() ([]string, error) { + out := []string{} + for k := range m { + out = append(out, k) + } + return out, nil +} +func (m memConfig) Get(key string) (json.RawMessage, bool, error) { + raw, ok := m[key] + return raw, ok, nil +} + +// deliver hands every key of the state to a node's view, as its watch would. +func deliver(m memConfig, v *ConfigView) { + for key, raw := range m { + var it Item + _ = json.Unmarshal(raw, &it) + v.Take(key, "put", &it) + } +} + +func one(name, content string) map[string]string { return map[string]string{name + ".md": content} } + +func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile { + t.Helper() + var files map[string]PluginFile + if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil { + t.Fatalf("no marketplace written: %v", err) + } + return files +} + +func TestEachKindLandsInItsOnePlace(t *testing.T) { + p, w := node(t, "laptop") + state, view := memConfig{}, NewConfigView(p) + register := func(it Item) { + t.Helper() + answer, err := Register(p, it, nil, false, state, view, writer(w)) + if err != nil || answer["registered"] == false { + t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err) + } + } + register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh, + Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}}) + register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")}) + register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")}) + register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")}) + register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash", + Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}}) + register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")}) + register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, + Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}}) + + plugin := pluginOf(t, w) + root := "plugins/" + Plugin + "/" + for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json", + root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md", + root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} { + if _, ok := plugin[want]; !ok { + t.Errorf("the plugin lacks %s", want) + } + } + if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable { + t.Error("a script is not executable") + } + var hooks struct { + Hooks map[string][]struct { + Matcher string `json:"matcher"` + Hooks []struct { + Command string `json:"command"` + } `json:"hooks"` + } `json:"hooks"` + } + _ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks) + if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" || + pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` { + t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content) + } + if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") { + t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"]) + } + var managed map[string]any + _ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed) + if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true { + t.Errorf("managed settings: %v", managed) + } + if _, inPlugin := plugin[root+"settings.json"]; inPlugin { + t.Error("settings were put in the plugin, which drops them") + } +} + +func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) { + a, wa := node(t, "laptop") + b, wb := node(t, "server") + state := memConfig{} + va, vb := NewConfigView(a), NewConfigView(b) + for _, r := range []struct { + it Item + nodes []string + }{ + {Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil}, + {Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}}, + {Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}}, + } { + if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil { + t.Fatal(err) + } + } + deliver(state, vb) + if _, err := RenderNow(b, writer(wb)); err != nil { + t.Fatal(err) + } + root := "plugins/" + Plugin + "/commands/" + pa, pb := pluginOf(t, wa), pluginOf(t, wb) + if _, ok := pa[root+"everywhere.md"]; !ok { + t.Error("the mesh item is missing on the laptop") + } + if _, ok := pb[root+"everywhere.md"]; !ok { + t.Error("the mesh item is missing on the server") + } + if _, ok := pa[root+"server-only.md"]; ok { + t.Error("the server's item reached the laptop") + } + if _, ok := pb[root+"server-only.md"]; !ok { + t.Error("the server's item is missing on the server") + } + if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil { + t.Error("the home item was not placed in the laptop's home") + } + if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil { + t.Error("the laptop's home item reached the server's home") + } + if _, ok := pa[root+"at-home.md"]; ok { + t.Error("a home item went into the plugin") + } +} + +func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) { + for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} { + it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}} + if it.Problem() == "" { + t.Errorf("a registration could set %s", key) + } + } + out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}}, + nil, "/h", nil, Config{}) + var managed map[string]any + _ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed) + if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true { + t.Error("the operator's setting turned the mesh's plugin off") + } +} + +func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) { + c := Config{ + Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{ + "permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}}, + Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{ + "permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}}, + } + out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}}, + nil, "/h", nil, c) + var managed struct { + Permissions map[string][]string `json:"permissions"` + Model string `json:"model"` + } + _ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed) + if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" { + t.Fatalf("%+v", managed) + } +} + +func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) { + p, w := node(t, "laptop") + state, view := memConfig{}, NewConfigView(p) + mine := filepath.Join(p.Home, ".claude", "agents", "mine.md") + _ = os.MkdirAll(filepath.Dir(mine), 0o755) + writeFile(t, mine, "the person's own") + + answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w)) + if answer["registered"] != false { + t.Fatalf("a name the person uses was taken: %v", answer) + } + if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" { + t.Fatal("the person's file was overwritten") + } + + placed := filepath.Join(p.Home, ".claude", "agents", "placed.md") + if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(placed); string(raw) != "v1" { + t.Fatal("the home item was not placed") + } + if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(placed); err == nil { + t.Fatal("unregistering did not remove what the mesh placed") + } + if _, err := os.Stat(mine); err != nil { + t.Fatal("unregistering removed the person's file") + } + + // Changed by hand after it was placed: left alone when unregistered. + if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil { + t.Fatal(err) + } + edited := filepath.Join(p.Home, ".claude", "agents", "edited.md") + writeFile(t, edited, "changed by hand") + if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" { + t.Fatal("a placed file changed by hand was removed") + } +} + +func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) { + big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}} + cases := map[string]Item{ + "too big": big, + "a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")}, + "a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}}, + "a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}}, + "a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"}, + "settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}}, + "an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"}, + } + for label, it := range cases { + if it.Problem() == "" { + t.Errorf("%s was accepted", label) + } + } +} + +func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) { + p, _ := node(t, "laptop") + dir := filepath.Join(p.Home, ".claude") + _ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755) + writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it") + writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n") + it, err := ImportFromHome(p, KindSkill, "old") + if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" { + t.Fatalf("%+v %v", it, err) + } + items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{}) + if len(items) != 1 || len(items[0].Notes) != 2 { + t.Fatalf("%+v", items) + } +} + +// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. +func TestTheManifestListsEveryToolServed(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Tools []string `json:"tools"` + } + _ = json.Unmarshal(raw, &m) + listed := map[string]bool{} + for _, n := range m.Tools { + listed[n] = true + } + p, _ := node(t, "laptop") + served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p)) + for _, tool := range served { + if !listed[tool.Name] { + t.Errorf("%s is served and not in the manifest", tool.Name) + } + delete(listed, tool.Name) + } + for n := range listed { + t.Errorf("%s is in the manifest and not served", n) + } +} + +// The tree writer's comparison: a directory holding exactly the files given, executable bits included. +func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) { + dir := t.TempDir() + files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}} + _ = os.MkdirAll(filepath.Join(dir, "s"), 0o755) + writeFile(t, filepath.Join(dir, "a.md"), "a") + writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n") + if sameTree(dir, files) { + t.Fatal("a script without its executable bit counted as the same") + } + _ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755) + if !sameTree(dir, files) { + t.Fatal("the same tree counted as different") + } + writeFile(t, filepath.Join(dir, "extra.md"), "x") + if sameTree(dir, files) { + t.Fatal("an extra file counted as the same") + } +} diff --git a/modules/claude-code/cmd/claude-code/config_tools.go b/modules/claude-code/cmd/claude-code/config_tools.go new file mode 100644 index 0000000..71ac125 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/config_tools.go @@ -0,0 +1,312 @@ +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// (home: ~/.claude/skills//)"}, + {KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)", + "the plugin's agents/.md (home: ~/.claude/agents/.md)"}, + {KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)", + "the plugin's commands/.md, run as /" + Plugin + ": (home: ~/.claude/commands/.md, run as /)"}, + {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// (mesh and node scopes only)"}, + {KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)", + "the plugin's output-styles/.md (home: ~/.claude/output-styles/.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/.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) +} + +// 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: a comma-separated list of nodes; 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..…` one node, `home..…` 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")} + return Register(p, it, nodesOf(a["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)} + return Register(p, it, nodesOf(a["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) + return SetSettings(p, scopeOf(a), nodesOf(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)} + return Register(p, it, nodesOf(a["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") + } + return SetSettings(p, scopeOf(a), nodesOf(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") + return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { + perms, _ := held["permissions"].(map[string]any) + rules, _ := perms[list].([]any) + 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") + } + return Register(p, it, nodesOf(a["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} + } + 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 +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index ce692ef..8927739 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -30,6 +30,9 @@ func say(format string, args ...any) { // writeManaged writes one managed file as root, only when its content changed. From a staged file, never // /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment). func writeManaged(name, content string) (string, error) { + if strings.HasSuffix(name, "/") { + return writeManagedTree(strings.TrimSuffix(name, "/"), content) + } path := filepath.Join(ManagedDir, name) if was, err := os.ReadFile(path); err == nil && string(was) == content { return name + ": unchanged", nil @@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) { return name + ": written", nil } +// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR +// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged +// beside, then swapped in as root, so a session never reads half of it. +func writeManagedTree(name, content string) (string, error) { + var files map[string]PluginFile + if err := json.Unmarshal([]byte(content), &files); err != nil { + return "", err + } + target := filepath.Join(ManagedDir, name) + if sameTree(target, files) { + return name + "/: unchanged", nil + } + staged, err := os.MkdirTemp("", "claude-code-tree-") + if err != nil { + return "", err + } + defer os.RemoveAll(staged) + for rel, f := range files { + full := filepath.Join(staged, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return "", err + } + mode := os.FileMode(0o644) + if f.Executable { + mode = 0o755 + } + if err := os.WriteFile(full, []byte(f.Content), mode); err != nil { + return "", err + } + _ = os.Chmod(full, mode) + } + _ = os.Chmod(staged, 0o755) + script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next"; +if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"` + args := []string{"sh", "-c", script, "sh", staged, target} + if os.Geteuid() != 0 { + args = append([]string{"sudo", "-n"}, args...) + } + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo", + name, ManagedDir, strings.TrimSpace(string(out))) + } + return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil +} + +// sameTree says whether a directory holds exactly these files, with these contents and executable bits. +func sameTree(dir string, files map[string]PluginFile) bool { + found := 0 + err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(dir, full) + f, ok := files[filepath.ToSlash(rel)] + if !ok { + return errors.New("not wanted") + } + raw, err := os.ReadFile(full) + info, ierr := d.Info() + if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable { + return errors.New("differs") + } + found++ + return nil + }) + return err == nil && found == len(files) +} + // ask is a tool on the bus, through the runtime: its answer is the tool's value. func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) } @@ -63,6 +134,13 @@ type stateOf struct{ s stdio.KeptState } func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err } func (s stateOf) Delete(key string) error { return s.s.Delete(key) } func (s stateOf) Keys() ([]string, error) { return s.s.Keys() } +func (s stateOf) Get(key string) (json.RawMessage, bool, error) { + e, err := s.s.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} // nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register // tool's question. @@ -152,9 +230,9 @@ func nodesOf(v any) []string { return out } -func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool { +func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool { nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`) - return []stdio.Tool{ + out := []stdio.Tool{ {Name: "claude_code_status", Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.", Run: func(map[string]any) (any, error) { return status(p), nil }}, @@ -239,6 +317,7 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool { return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe) }}, } + return append(out, configTools(p, config, configView)...) } // persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module. @@ -283,15 +362,44 @@ func main() { } servers := stateOf{stdio.State("servers")} view := NewServerView(p) - go run(p, view) - if err := stdio.Serve("", tools(p, servers, view)); err != nil { + config := stateOf{stdio.State("config")} + configView := NewConfigView(p) + go run(p, view, configView) + if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil { say("%v", err) os.Exit(1) } } // run is the module's long-running half, beside the tools (ADR 0198). -func run(p Paths, view *ServerView) { +func run(p Paths, view *ServerView, configView *ConfigView) { + // The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change. + go persist("watching the agent's configuration", func() error { + return stdio.State("config").Watch("", func(c stdio.StateChange) error { + var item *Item + if c.Op == "put" { + item = &Item{} + if json.Unmarshal(c.Value, item) != nil { + item = nil + } + } + if !configView.Take(c.Key, c.Op, item) { + return nil + } + if out, err := RenderNow(p, writeManaged); err != nil { + say("taking %s %s: %v", c.Op, c.Key, err) + } else { + say("took %s %s", c.Op, c.Key) + for _, line := range out { + if !strings.HasSuffix(line, "unchanged") { + say("%s", line) + } + } + } + return nil + }) + }, func(n int) { say("watching the agent's configuration%s", refusals(n)) }) + // Every node's MCP servers: the whole current set first, then each change (ADR 0201). go persist("watching the MCP servers", func() error { return stdio.State("servers").Watch("", func(c stdio.StateChange) error { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 88647b1..45bda9e 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -62,6 +62,8 @@ func (p Paths) binding() string { return filepath.Join(p.State, "licence.jso func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") } func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") } func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") } +func (p Paths) config() string { return filepath.Join(p.State, "config.json") } +func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") } // Ask is a tool on the bus: its address and arguments in, its JSON answer out. type Ask func(address string, args any) (json.RawMessage, error) @@ -116,12 +118,23 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { if readJSON(p.binding(), &b) { binding = &b } - files := Render(facts, settings, binding, p.helper(), Registered(p)) + var items map[string]Item + _ = readJSON(p.config(), &items) + config := ConfigOf(items) + files := Render(facts, settings, binding, p.helper(), Registered(p), config) + tree, _ := json.Marshal(Marketplace(config)) + files[MarketplaceDir+"/"] = string(tree) names := make([]string, 0, len(files)) for n := range files { names = append(names, n) } - sort.Strings(names) + // The marketplace first: the settings that enable its plugin must never name one that is not there yet. + sort.Slice(names, func(i, j int) bool { + if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") { + return names[i] == MarketplaceDir+"/" + } + return names[i] < names[j] + }) var out []string for _, n := range names { line, err := write(n, files[n]) @@ -130,6 +143,14 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) { } out = append(out, line) } + // The home scope: what this module places in the account's own agent directory (ADR 0182). + done, left := PlaceHome(p, HomeFiles(config)) + for _, line := range done { + out = append(out, "home "+line) + } + for _, line := range left { + out = append(out, "home "+line) + } return out, nil } diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index ac346a8..58e5aa9 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -96,8 +96,10 @@ func jsonFile(v any) string { } // Render composes the three files. registered — what was registered through this module and applies -// here — is laid over the servers the operator set in its settings. -func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string { +// here — is laid over the servers the operator set in its settings; config is the rest of what was +// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections +// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's. +func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string { servers := map[string]any{} for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} { for name, entry := range layer { @@ -109,14 +111,16 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string, } servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console} - managed := map[string]any{} - for key, value := range settings.ManagedSettings { - managed[key] = value - } + // The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node. + managed := mergeSettings(map[string]any{}, settings.ManagedSettings) + managed = mergeSettings(managed, RegisteredSettings(config)) // The mesh's own keys are laid last: a setting never replaces them. managed["attribution"] = map[string]any{"commit": "", "pr": ""} managed["allowAllClaudeAiMcps"] = true delete(managed, "apiKeyHelper") + for key, value := range MarketplaceKeys() { + managed[key] = value + } if binding != nil && binding.Kind == "api-key" { managed["apiKeyHelper"] = helperPath } @@ -127,6 +131,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string, return map[string]string{ "managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}), "managed-settings.json": jsonFile(managed), - "CLAUDE.md": instructionsText(facts.Node, role), + "CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config), } } diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 8931a63..c172367 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -13,7 +13,8 @@ }, "state": [ "servers", - "holdings" + "holdings", + "config" ], "reads": [ "claude-licence-manager.bindings" @@ -26,7 +27,34 @@ "claude_code_add_api_key", "claude_code_mcp_list", "claude_code_mcp_register", - "claude_code_mcp_unregister" + "claude_code_mcp_unregister", + "claude_code_skill_list", + "claude_code_skill_register", + "claude_code_skill_unregister", + "claude_code_agent_list", + "claude_code_agent_register", + "claude_code_agent_unregister", + "claude_code_command_list", + "claude_code_command_register", + "claude_code_command_unregister", + "claude_code_hook_list", + "claude_code_hook_register", + "claude_code_hook_unregister", + "claude_code_output_style_list", + "claude_code_output_style_register", + "claude_code_output_style_unregister", + "claude_code_instructions_list", + "claude_code_instructions_register", + "claude_code_instructions_unregister", + "claude_code_settings_get", + "claude_code_settings_set", + "claude_code_settings_clear", + "claude_code_permission_add", + "claude_code_permission_remove", + "claude_code_config_list", + "claude_code_config_show", + "claude_code_config_status", + "claude_code_config_import" ], "resources": [ {