Merge pull request 'claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin (hq ADR 0216)' (#52) from feat/nox-mesh-plugin into main
This commit was merged in pull request #52.
This commit is contained in:
@@ -22,11 +22,17 @@ 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:<name>`. 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, even one with the
|
||||
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
||||
alone, and one the person deleted stays deleted until the item is unregistered (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 +47,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.<server>` for every node, `<node>.<server>` 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.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` 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 +56,24 @@ 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`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
|
||||
operator account's own `~/.claude` on those nodes):
|
||||
|
||||
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
|
||||
`claude_code_<kind>_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`:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,937 @@
|
||||
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.<kind>.<name> every node running the agent
|
||||
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
|
||||
// home.<node>.<kind>.<name> 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:<name>`.
|
||||
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}$`)
|
||||
|
||||
// nodeName is what a node may be called in a key.
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
|
||||
// the mesh's console or its marketplace by another way.
|
||||
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
|
||||
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
|
||||
|
||||
// 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, `<name>.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 meshOwnedKeys {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; 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 problem := pathProblem(rel); problem != "" {
|
||||
return problem
|
||||
}
|
||||
for other := range it.Files {
|
||||
if strings.HasPrefix(other, rel+"/") {
|
||||
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||
// segment a real name — never empty, `.` or `..`.
|
||||
func pathProblem(rel string) string {
|
||||
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
_, node, _, _, _ := ParseKey(key)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
|
||||
// lands, and the two must agree — a mesh key holding a home item would land in every home.
|
||||
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
|
||||
v.items[key] = *item
|
||||
} else {
|
||||
delete(v.items, key)
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Prune drops what the view holds and the state no longer does: a registration removed while this node
|
||||
// was away is never handed over by the watch, which hands over what is there, not what went.
|
||||
func (v *ConfigView) Prune(present []string) bool {
|
||||
keep := map[string]bool{}
|
||||
for _, k := range present {
|
||||
keep[k] = true
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
for k := range v.items {
|
||||
if !keep[k] {
|
||||
delete(v.items, k)
|
||||
}
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
|
||||
// an older snapshot never lands after a newer one.
|
||||
func (v *ConfigView) writeThroughLocked() 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[:])
|
||||
}
|
||||
|
||||
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
|
||||
// kept until the item is unregistered.
|
||||
const deletedByHand = "deleted-by-hand"
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
|
||||
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
|
||||
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
|
||||
// placed that the person deleted stays deleted until the item is unregistered. 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]
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
case !exists && wasPlaced && ours == deletedByHand:
|
||||
continue
|
||||
case !exists && wasPlaced:
|
||||
placed[rel] = deletedByHand
|
||||
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
|
||||
continue
|
||||
case exists && !wasPlaced:
|
||||
left = append(left, rel+": the person's own, left alone")
|
||||
continue
|
||||
case exists && ours == deletedByHand:
|
||||
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
|
||||
continue
|
||||
case exists && string(current) == content:
|
||||
continue
|
||||
case exists && digest(string(current)) != ours:
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
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))
|
||||
if ours == deletedByHand {
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
|
||||
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
|
||||
delete(placed, rel)
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||
}
|
||||
return done, left
|
||||
}
|
||||
|
||||
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
|
||||
// writing through it would write wherever it points.
|
||||
func linkedParent(dir, rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
at := dir
|
||||
for _, seg := range parts[:len(parts)-1] {
|
||||
at = filepath.Join(at, seg)
|
||||
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return at + " is a symbolic link"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
|
||||
}
|
||||
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 !unregister {
|
||||
switch {
|
||||
case it.Scope == ScopeHome && it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + it.Name
|
||||
case it.Scope == ScopeHome:
|
||||
answer["offered as"] = it.Name + ", from the home"
|
||||
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
|
||||
answer["offered as"] = Plugin + ":" + it.Name
|
||||
case it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + Plugin + ":" + it.Name
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
rendered, err := RenderNow(p, write)
|
||||
answer["rendered here"] = rendered
|
||||
if err != nil {
|
||||
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func verbOf(unregister bool) string {
|
||||
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
}
|
||||
|
||||
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
|
||||
func nodesProblem(nodes []string) string {
|
||||
for _, n := range nodes {
|
||||
if !nodeName.MatchString(n) {
|
||||
return fmt.Sprintf("%q is not a node's name", n)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExpandNodes turns `all` into every node running the module; anything else is kept.
|
||||
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
|
||||
if len(nodes) != 1 || nodes[0] != "all" {
|
||||
return nodes, nil
|
||||
}
|
||||
all, err := running()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no node is known to run claude-code")
|
||||
}
|
||||
return all, 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
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
|
||||
// there, so the view is pruned to the keys the state still holds.
|
||||
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
for _, name := range []string{"kept", "gone"} {
|
||||
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
delete(state, "mesh.command.gone") // removed from another node while this one was off
|
||||
back := NewConfigView(p) // the node starts again from what it last wrote
|
||||
if len(back.Items()) != 2 {
|
||||
t.Fatalf("the view did not start from what was last written: %v", back.Items())
|
||||
}
|
||||
keys, _ := state.Keys()
|
||||
if !back.Prune(keys) || len(back.Items()) != 1 {
|
||||
t.Fatalf("after pruning: %v", back.Items())
|
||||
}
|
||||
}
|
||||
|
||||
// The review's findings, each held by a test.
|
||||
|
||||
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||
for label, files := range map[string]map[string]string{
|
||||
"a dot": {"SKILL.md": "x", ".": "x"},
|
||||
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
|
||||
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
|
||||
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
|
||||
} {
|
||||
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
v := NewConfigView(p)
|
||||
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
|
||||
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
|
||||
t.Fatal("a mesh key holding a home item was taken")
|
||||
}
|
||||
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
|
||||
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
|
||||
v.Take(key, "put", &mesh)
|
||||
}
|
||||
if len(v.Items()) != 1 {
|
||||
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
p, w := node(t, "laptop")
|
||||
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a node name with a dot was used in a key: %v", answer)
|
||||
}
|
||||
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
|
||||
t.Fatal("a registration could deny the mesh's console")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
|
||||
"enabledPlugins": map[string]any{"theirs@market": true},
|
||||
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
|
||||
}}, nil, "/h", nil, Config{})
|
||||
var managed struct {
|
||||
Enabled map[string]any `json:"enabledPlugins"`
|
||||
Known map[string]any `json:"extraKnownMarketplaces"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
// An identical file the person made is not taken over, so unregistering never removes it.
|
||||
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
|
||||
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
|
||||
t.Fatalf("an identical file of the person's was taken over: %v", left)
|
||||
}
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
|
||||
t.Fatal("the person's file was removed")
|
||||
}
|
||||
// A placed file the person deleted stays deleted while it is registered.
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
|
||||
t.Fatal("a file the person deleted was placed again")
|
||||
}
|
||||
// The kind's own directory stays when the mesh's last item in it goes.
|
||||
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
|
||||
t.Fatal("the item's own directory was left")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
|
||||
t.Fatal("the kind's directory was removed")
|
||||
}
|
||||
// Never through a symbolic link.
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
|
||||
t.Skip("no symbolic links here")
|
||||
}
|
||||
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
|
||||
t.Fatal("a file was written through a symbolic link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
w := map[string]string{}
|
||||
failing := func(name, content string) (string, error) {
|
||||
if name == MarketplaceDir+"/" {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
w[name] = content
|
||||
return name + ": written", nil
|
||||
}
|
||||
if _, err := RenderNow(p, failing); err == nil {
|
||||
t.Fatal("the failure was not reported")
|
||||
}
|
||||
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
|
||||
t.Fatalf("the other files were not written: %v", w)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
|
||||
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
|
||||
// for what the module did not place.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// kindTool is how one kind is named in its tools and described to whoever calls them.
|
||||
type kindTool struct {
|
||||
kind, tool, what, lands string
|
||||
}
|
||||
|
||||
var kindTools = []kindTool{
|
||||
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
|
||||
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
|
||||
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
|
||||
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
||||
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
||||
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
||||
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
||||
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
||||
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
||||
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
||||
{KindInstructions, "instructions", "a section of instructions every session reads",
|
||||
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
|
||||
}
|
||||
|
||||
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
|
||||
func strArg(a map[string]any, k string) string {
|
||||
s, _ := a[k].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
||||
func targetNodes(a map[string]any) ([]string, error) {
|
||||
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
||||
}
|
||||
|
||||
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
||||
func scopeOf(a map[string]any) string {
|
||||
if s := strArg(a, "scope"); s != "" {
|
||||
return s
|
||||
}
|
||||
return ScopeMesh
|
||||
}
|
||||
|
||||
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
|
||||
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if raw, ok := a["files"].(map[string]any); ok {
|
||||
for rel, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("files.%s is not text", rel)
|
||||
}
|
||||
out[rel] = s
|
||||
}
|
||||
}
|
||||
if content, ok := a["content"].(string); ok && content != "" {
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
out["SKILL.md"] = content
|
||||
case KindHook:
|
||||
return nil, errors.New("a hook's scripts are given as files")
|
||||
default:
|
||||
out[name+".md"] = content
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
||||
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
||||
var out []stdio.Tool
|
||||
for _, k := range kindTools {
|
||||
k := k
|
||||
input := map[string]any{
|
||||
"name": str("the name: lower-case letters, digits and -"),
|
||||
"scope": scopeArg,
|
||||
"nodes": nodesArg,
|
||||
}
|
||||
switch k.kind {
|
||||
case KindSkill:
|
||||
input["content"] = str("the SKILL.md, when the skill is that one file")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
||||
case KindHook:
|
||||
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
||||
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
||||
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
||||
default:
|
||||
input["content"] = str("the file's content, front matter included")
|
||||
}
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
|
||||
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
||||
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
||||
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
||||
Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name := strArg(a, "name")
|
||||
files, err := filesOf(k.kind, name, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
||||
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
||||
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_settings_get",
|
||||
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
written := map[string]any{}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
||||
var settings Settings
|
||||
_ = readJSON(p.Settings, &settings)
|
||||
c := ConfigOf(view.Items())
|
||||
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
||||
for _, it := range c.Mesh {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for the mesh"] = it.Settings
|
||||
}
|
||||
}
|
||||
for _, it := range c.Node {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for this node"] = it.Settings
|
||||
}
|
||||
}
|
||||
return map[string]any{"written": written, "layers": layers,
|
||||
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_set",
|
||||
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
||||
Input: map[string]any{
|
||||
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
||||
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
||||
"scope": settingsScope, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
given, _ := a["settings"].(map[string]any)
|
||||
if len(given) == 0 {
|
||||
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
if boolArg(a, "replace") {
|
||||
return given
|
||||
}
|
||||
return mergeSettings(held, given)
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_clear",
|
||||
Description: "Remove the Claude Code settings registered at a scope.",
|
||||
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_add",
|
||||
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_remove",
|
||||
Description: "Remove a permission rule added through this module, at its scope.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
perms, _ := held["permissions"].(map[string]any)
|
||||
rules, _ := perms[list].([]any)
|
||||
if len(rules) == 0 {
|
||||
return held // nothing to remove: what the scope holds stays as it is
|
||||
}
|
||||
kept := []any{}
|
||||
for _, r := range rules {
|
||||
if r != rule {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
next := mergeSettings(map[string]any{}, held)
|
||||
np := mergeSettings(map[string]any{}, perms)
|
||||
np[list] = kept
|
||||
next["permissions"] = np
|
||||
return next
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_list",
|
||||
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
||||
Run: func(map[string]any) (any, error) { return List(state, "") }},
|
||||
stdio.Tool{Name: "claude_code_config_show",
|
||||
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
|
||||
Input: map[string]any{"key": str("the key")},
|
||||
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||
stdio.Tool{Name: "claude_code_config_status",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c := ConfigOf(view.Items())
|
||||
names := func(items []Item) []string {
|
||||
out := []string{}
|
||||
for _, it := range items {
|
||||
out = append(out, it.Kind+" "+it.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
var mcp struct {
|
||||
MCPServers Servers `json:"mcpServers"`
|
||||
}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
plugin := []string{}
|
||||
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
|
||||
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
plugin = append(plugin, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return map[string]any{
|
||||
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
|
||||
"plugin": map[string]any{"name": Plugin, "files": plugin},
|
||||
"home": HomeItems(p, c, mcp.MCPServers),
|
||||
"placed": placed,
|
||||
}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_import",
|
||||
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md"),
|
||||
"scope": scopeArg, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Scope = scopeOf(a)
|
||||
if it.Scope == ScopeHome {
|
||||
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
||||
// the key holds. An empty result removes the key.
|
||||
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
||||
state ConfigState, view *ConfigView) (map[string]any, error) {
|
||||
if scope != ScopeMesh && scope != ScopeNode {
|
||||
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
||||
}
|
||||
if scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{"set": false, "reason": problem}, nil
|
||||
}
|
||||
answers := map[string]any{}
|
||||
for _, n := range nodes {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
||||
held := map[string]any{}
|
||||
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
var was Item
|
||||
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
||||
held = was.Settings
|
||||
}
|
||||
}
|
||||
it.Settings = change(held)
|
||||
target := []string(nil)
|
||||
if n != "" {
|
||||
target = []string{n}
|
||||
}
|
||||
var answer map[string]any
|
||||
var err error
|
||||
if len(it.Settings) == 0 {
|
||||
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
||||
} else {
|
||||
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["settings"] = it.Settings
|
||||
answers[it.Key(n)] = answer
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
@@ -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,53 @@ 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 {
|
||||
keys, err := stdio.State("config").Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configView.Prune(keys) {
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering after what was removed while away: %v", err)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
|
||||
return s
|
||||
}
|
||||
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||
// registered here.
|
||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
// One at a time: the watches and the tools all render, and the home's record of what was placed is
|
||||
// read and written whole.
|
||||
renderMu.Lock()
|
||||
defer renderMu.Unlock()
|
||||
var facts Facts
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
@@ -116,21 +124,44 @@ 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]
|
||||
})
|
||||
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
||||
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
||||
var out []string
|
||||
var failed []error
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
failed = append(failed, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
// 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, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -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,27 @@ 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")
|
||||
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
||||
// entry winning: the operator may know more marketplaces and enable more plugins.
|
||||
for key, value := range MarketplaceKeys() {
|
||||
entries := map[string]any{}
|
||||
if held, ok := managed[key].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range value.(map[string]any) {
|
||||
entries[k] = v
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
@@ -127,6 +142,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user