On g14 the node's own watch took a home registration before the tool did, and the answer said 'already so' though the file had just been placed. Compare the view before and after, and render whenever the item applies here, as the MCP server registration already does.
945 lines
31 KiB
Go
945 lines
31 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Compared before and after rather than read from Take: this node's own watch may take the same change
|
|
// first, and then Take here finds nothing new although this call made it.
|
|
before, _ := json.Marshal(v.Items())
|
|
var keys []string
|
|
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
|
|
v.Take(key, op, &item)
|
|
}
|
|
after, _ := json.Marshal(v.Items())
|
|
here := string(before) != string(after)
|
|
for _, k := range keys {
|
|
here = here || v.Applies(k)
|
|
}
|
|
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 here {
|
|
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
|
|
// watch and this call took the change.
|
|
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 {
|
|
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
|
|
}
|