claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin
Skills, subagents, commands and hooks had no machine-wide place, so they were copied into homes by hand and drifted. Each is now registered once through the module's tools, kept in its config state, and written per node: the plugin in the managed directory, settings and instructions in the managed files, or the account's own directory, touching only what the module placed. hq ADR 0216.
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
package main
|
||||
|
||||
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
|
||||
//
|
||||
// Every registration is one key in the module's `config` state (ADR 0201):
|
||||
//
|
||||
// mesh.<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}$`)
|
||||
|
||||
// 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 []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins"} {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys; a registration cannot set it", key)
|
||||
}
|
||||
}
|
||||
} else if !itemName.MatchString(it.Name) {
|
||||
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
if it.Scope == ScopeHome {
|
||||
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
|
||||
}
|
||||
if !hookEvents[it.Event] {
|
||||
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
|
||||
}
|
||||
if strings.TrimSpace(it.Command) == "" {
|
||||
return "a hook needs a command"
|
||||
}
|
||||
}
|
||||
for rel := range it.Files {
|
||||
if rel == "" || path.IsAbs(rel) || strings.Contains(rel, "\\") || path.Clean(rel) != rel || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
}
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
if _, ok := it.Files["SKILL.md"]; !ok {
|
||||
return "a skill needs a SKILL.md"
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
|
||||
return "this kind is one file, " + it.Name + ".md, with content"
|
||||
}
|
||||
}
|
||||
if n := it.size(); n > MaxItemBytes {
|
||||
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (it Item) size() int {
|
||||
raw, _ := json.Marshal(it)
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// ---- the view -------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
|
||||
// for this node — kept in memory from the watch and written through to the module's own file, so the
|
||||
// managed directory renders without the bus.
|
||||
type ConfigView struct {
|
||||
p Paths
|
||||
mu sync.Mutex
|
||||
items map[string]Item
|
||||
}
|
||||
|
||||
// NewConfigView is the view as last written through, or empty.
|
||||
func NewConfigView(p Paths) *ConfigView {
|
||||
v := &ConfigView{p: p, items: map[string]Item{}}
|
||||
_ = readJSON(p.config(), &v.items)
|
||||
return v
|
||||
}
|
||||
|
||||
// Applies says whether a key is this node's to take.
|
||||
func (v *ConfigView) Applies(key string) bool {
|
||||
scope, node, _, _, ok := ParseKey(key)
|
||||
return ok && (scope == ScopeMesh || node == v.p.Node)
|
||||
}
|
||||
|
||||
// Take takes one change, and answers whether what applies to this node changed.
|
||||
func (v *ConfigView) Take(key, op string, item *Item) bool {
|
||||
if !v.Applies(key) {
|
||||
return false
|
||||
}
|
||||
v.mu.Lock()
|
||||
if op == "put" && item != nil && item.Problem() == "" {
|
||||
v.items[key] = *item
|
||||
} else {
|
||||
delete(v.items, key)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
return v.writeThrough()
|
||||
}
|
||||
|
||||
// Items is what applies here, by key.
|
||||
func (v *ConfigView) Items() map[string]Item {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make(map[string]Item, len(v.items))
|
||||
for k, it := range v.items {
|
||||
out[k] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *ConfigView) writeThrough() bool {
|
||||
now, _ := indented(v.Items())
|
||||
before, _ := os.ReadFile(v.p.config())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.config(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
// Config is what applies to this node, sorted for rendering.
|
||||
type Config struct {
|
||||
Mesh, Node, Home []Item
|
||||
}
|
||||
|
||||
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
|
||||
func ConfigOf(items map[string]Item) Config {
|
||||
var c Config
|
||||
for _, it := range items {
|
||||
switch it.Scope {
|
||||
case ScopeMesh:
|
||||
c.Mesh = append(c.Mesh, it)
|
||||
case ScopeNode:
|
||||
c.Node = append(c.Node, it)
|
||||
case ScopeHome:
|
||||
c.Home = append(c.Home, it)
|
||||
}
|
||||
}
|
||||
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Kind != list[j].Kind {
|
||||
return list[i].Kind < list[j].Kind
|
||||
}
|
||||
return list[i].Name < list[j].Name
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- rendering --------------------------------------------------------------------------------------
|
||||
|
||||
// PluginFile is one file of the marketplace: its content and whether it is run.
|
||||
type PluginFile struct {
|
||||
Content string
|
||||
Executable bool
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile {
|
||||
root := "plugins/" + Plugin + "/"
|
||||
out := map[string]PluginFile{
|
||||
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin,
|
||||
"owner": map[string]any{"name": "the mesh"},
|
||||
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
|
||||
})},
|
||||
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin, "version": "1.0.0",
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
|
||||
"author": map[string]any{"name": "the mesh"},
|
||||
})},
|
||||
}
|
||||
chosen := map[string]Item{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
chosen[it.Kind+"/"+it.Name] = it
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(chosen))
|
||||
for k := range chosen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
hooks := map[string][]any{}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
|
||||
}
|
||||
case KindAgent:
|
||||
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindCommand:
|
||||
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindOutputStyle:
|
||||
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindHook:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
|
||||
}
|
||||
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
|
||||
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
|
||||
if it.Matcher != "" {
|
||||
entry["matcher"] = it.Matcher
|
||||
}
|
||||
hooks[it.Event] = append(hooks[it.Event], entry)
|
||||
}
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isScript(rel, content string) bool {
|
||||
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
|
||||
}
|
||||
|
||||
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
|
||||
func MarketplaceKeys() map[string]any {
|
||||
return map[string]any{
|
||||
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
|
||||
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
|
||||
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
|
||||
func RegisteredSettings(c Config) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
if it.Kind == KindSettings {
|
||||
out = mergeSettings(out, it.Settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
|
||||
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
|
||||
func mergeSettings(a, b map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range a {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range b {
|
||||
switch nb := v.(type) {
|
||||
case map[string]any:
|
||||
if na, ok := out[k].(map[string]any); ok {
|
||||
out[k] = mergeSettings(na, nb)
|
||||
continue
|
||||
}
|
||||
case []any:
|
||||
if la, ok := out[k].([]any); ok {
|
||||
joined := append([]any{}, la...)
|
||||
seen := map[string]bool{}
|
||||
for _, x := range la {
|
||||
raw, _ := json.Marshal(x)
|
||||
seen[string(raw)] = true
|
||||
}
|
||||
for _, x := range nb {
|
||||
raw, _ := json.Marshal(x)
|
||||
if !seen[string(raw)] {
|
||||
seen[string(raw)] = true
|
||||
joined = append(joined, x)
|
||||
}
|
||||
}
|
||||
out[k] = joined
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
|
||||
// registered for the mesh, then those for this node.
|
||||
func InstructionSections(c Config) string {
|
||||
var b strings.Builder
|
||||
for _, part := range []struct {
|
||||
title string
|
||||
items []Item
|
||||
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
|
||||
var sections []Item
|
||||
for _, it := range part.items {
|
||||
if it.Kind == KindInstructions {
|
||||
sections = append(sections, it)
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
|
||||
for _, it := range sections {
|
||||
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
|
||||
// that directory.
|
||||
func HomeFiles(c Config) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, it := range c.Home {
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out["skills/"+it.Name+"/"+rel] = content
|
||||
}
|
||||
case KindAgent:
|
||||
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindCommand:
|
||||
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindOutputStyle:
|
||||
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindInstructions:
|
||||
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the home ---------------------------------------------------------------------------------------
|
||||
|
||||
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
|
||||
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
|
||||
type Placed map[string]string
|
||||
|
||||
func digest(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants: writes what is wanted and absent or
|
||||
// its own, never a path the person made, and removes what it placed and is no longer wanted — unless the
|
||||
// person changed it since. Answers what it did and what it left alone, and why.
|
||||
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
if !readJSON(p.placed(), &placed) {
|
||||
placed = Placed{}
|
||||
}
|
||||
paths := make([]string, 0, len(want))
|
||||
for rel := range want {
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
content := want[rel]
|
||||
current, err := os.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
case exists && !wasPlaced:
|
||||
if string(current) == content {
|
||||
placed[rel] = digest(content) // the same bytes: taken over, nothing changes
|
||||
continue
|
||||
}
|
||||
left = append(left, rel+": the person's own, left alone")
|
||||
continue
|
||||
case exists && wasPlaced && digest(string(current)) != ours && digest(string(current)) != digest(content):
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
continue
|
||||
case exists && string(current) == content:
|
||||
placed[rel] = digest(content)
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if isScript(rel, content) {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
placed[rel] = digest(content)
|
||||
done = append(done, rel+": placed")
|
||||
}
|
||||
for rel, ours := range placed {
|
||||
if _, wanted := want[rel]; wanted {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
current, err := os.ReadFile(full)
|
||||
if err == nil && digest(string(current)) != ours {
|
||||
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(full)
|
||||
removeEmptyParents(dir, filepath.Dir(full))
|
||||
delete(placed, rel)
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
_ = os.WriteFile(p.placed(), raw, 0o600)
|
||||
return done, left
|
||||
}
|
||||
|
||||
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||
func removeEmptyParents(root, dir string) {
|
||||
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// HomeConflict says whether registering an item at the home scope here would collide with something the
|
||||
// person made: "" when it would not.
|
||||
func HomeConflict(p Paths, it Item) string {
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
for rel := range HomeFiles(Config{Home: []Item{it}}) {
|
||||
if _, ours := placed[rel]; ours {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- what the module did not place ------------------------------------------------------------------
|
||||
|
||||
// HomeItem is one item found in the home.
|
||||
type HomeItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Placed bool `json:"placedByTheMesh"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
|
||||
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
inPlugin := map[string]bool{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
inPlugin[it.Kind+"/"+it.Name] = true
|
||||
}
|
||||
}
|
||||
var out []HomeItem
|
||||
add := func(kind, name, rel, body string) {
|
||||
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
|
||||
_, h.Placed = placed[rel]
|
||||
if inPlugin[kind+"/"+name] {
|
||||
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
|
||||
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
|
||||
seen[server] = true
|
||||
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
|
||||
}
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||
if err != nil {
|
||||
continue // the vendor's own synced folders carry none at their top
|
||||
}
|
||||
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
|
||||
}
|
||||
}
|
||||
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
|
||||
func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
root := filepath.Join(dir, "skills", name)
|
||||
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
raw, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
it.Files[filepath.ToSlash(rel)] = string(raw)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
|
||||
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
|
||||
}
|
||||
it.Files[name+".md"] = string(raw)
|
||||
default:
|
||||
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// ---- registering ------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigState is the `config` state as this module reaches it through the runtime.
|
||||
type ConfigState interface {
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
Get(key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
|
||||
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
|
||||
// so the answer says what it did here.
|
||||
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
|
||||
if !unregister {
|
||||
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
|
||||
if problem := it.Problem(); problem != "" {
|
||||
return map[string]any{"registered": false, "reason": problem}, nil
|
||||
}
|
||||
} else if !knownKind(it.Kind) {
|
||||
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
|
||||
}
|
||||
if it.Scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
}
|
||||
if !unregister && it.Scope == ScopeHome {
|
||||
for _, n := range nodes {
|
||||
if n == p.Node {
|
||||
if conflict := HomeConflict(p, it); conflict != "" {
|
||||
return map[string]any{"registered": false, "reason": conflict}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var keys []string
|
||||
changed := false
|
||||
for _, n := range nodes {
|
||||
key := it.Key(n)
|
||||
keys = append(keys, key)
|
||||
var err error
|
||||
if unregister {
|
||||
err = state.Delete(key)
|
||||
} else {
|
||||
err = state.Put(key, it)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "put"
|
||||
if unregister {
|
||||
op = "delete"
|
||||
}
|
||||
item := it
|
||||
changed = v.Take(key, op, &item) || changed
|
||||
}
|
||||
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
|
||||
if it.Kind != KindSettings && it.Scope != ScopeHome && !unregister {
|
||||
answer["offered as"] = Plugin + ":" + it.Name
|
||||
}
|
||||
if changed {
|
||||
rendered, err := RenderNow(p, write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["rendered here"] = rendered
|
||||
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||
} else if v.Applies(keys[0]) || len(keys) > 1 {
|
||||
answer["here"] = "already so"
|
||||
} else {
|
||||
answer["here"] = "not this node: each node it is for takes it from the bus"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
|
||||
func List(state ConfigState, kind string) (map[string]any, error) {
|
||||
keys, err := state.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := map[string]any{}
|
||||
for _, key := range keys {
|
||||
_, _, k, _, ok := ParseKey(key)
|
||||
if !ok || (kind != "" && k != kind) {
|
||||
continue
|
||||
}
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
var it Item
|
||||
if json.Unmarshal(raw, &it) != nil {
|
||||
continue
|
||||
}
|
||||
files := make([]string, 0, len(it.Files))
|
||||
for f := range it.Files {
|
||||
files = append(files, f)
|
||||
}
|
||||
sort.Strings(files)
|
||||
summary := map[string]any{"by": it.By, "at": it.At}
|
||||
if len(files) > 0 {
|
||||
summary["files"] = files
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
summary["settings"] = it.Settings
|
||||
}
|
||||
out[key] = summary
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Show is one registration in full: its files' content included.
|
||||
func Show(state ConfigState, key string) (any, error) {
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("nothing is registered at %s", key)
|
||||
}
|
||||
var it Item
|
||||
if err := json.Unmarshal(raw, &it); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
Reference in New Issue
Block a user