It could not be changed at all: propose refused a name set there and never replaced one. The ask now says plainly that it replaces or removes the registered section and binds its text's digest; on Approve every node renders the new text in the place of the old (a removal leaves it out); a replacement keeps the section's own heading form (issue 340 note 14). Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
1198 lines
45 KiB
Go
1198 lines
45 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, 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)
|
|
//
|
|
// **What a session runs or obeys is not registered here** (novox/hq issue 340). A hook runs a command, and the
|
|
// managed settings say what runs without asking, in every session on every node the registration reaches, as
|
|
// whoever starts it — the operator, whose account holds root. The tool runner acts for any caller of the mesh MCP
|
|
// server and is not told who called, so a registration through these tools could come from an agent confined on
|
|
// another node. Hooks, permissions and the rest of the managed settings are the operator's, set at the
|
|
// controller's terminal in this module's `managed_settings` setting (the controller refuses that key through a
|
|
// verb); the kinds `hook` and `settings` are refused at registration and never taken from the state, and an
|
|
// item that would run something itself — a skill's or a command's shell lines, a hook, a server or a mode in its
|
|
// front matter, tools it pre-approves — is refused too. What may still be registered is text a session reads.
|
|
//
|
|
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// 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}$`)
|
|
|
|
// AtTheTerminal is how a hook, a permission rule or any other managed setting is set now (novox/hq issue 340):
|
|
// in this module's `managed_settings` setting, at the controller's terminal, which refuses it through a verb.
|
|
const AtTheTerminal = "mesh-cli settings set claude-code '{\"managed_settings\": {...}}' [--node <node>], " +
|
|
"typed by the operator at the controller's terminal: mesh-cli (nox) on the control-node (novox/hq ADR 0272)"
|
|
|
|
// RunsInASession says why a kind is never registered through this module: what it holds runs in, or is obeyed
|
|
// by, every session it reaches, as whoever starts that session (novox/hq issue 340).
|
|
func RunsInASession(kind string) string {
|
|
what := map[string]string{
|
|
KindHook: "a hook runs its command in every session it reaches",
|
|
KindSettings: "the managed settings say what runs in every session without asking — permissions, hooks, the status line, the environment",
|
|
}[kind]
|
|
return what + ", as whoever starts that session — the operator, whose account holds root — and the tool runner is " +
|
|
"not told who called it, so an agent on another node could register one. It is set at the controller's " +
|
|
"terminal only: " + AtTheTerminal + " (novox/hq issue 340)"
|
|
}
|
|
|
|
// runsFrontMatter are the front matter keys of a skill, a subagent or a command that make it run something
|
|
// itself or loosen what runs (novox/hq issue 340): a hook, a tool server, a permission mode, tools it
|
|
// pre-approves. Compared lower-case with - and _ taken out.
|
|
var runsFrontMatter = map[string]string{
|
|
"hooks": "hooks run a command when the item is used",
|
|
"mcpservers": "a tool server starts a program",
|
|
"permissionmode": "a permission mode changes what runs without asking",
|
|
"allowedtools": "allowed-tools lets the tools it names run without asking",
|
|
}
|
|
|
|
// shellLine is a skill's or a command's shell line, run when it is loaded and its output put in the prompt:
|
|
// !`command` inline, or a fenced block opened with ```!.
|
|
var shellLine = regexp.MustCompile("!`[^`\n]+`|(?m)^[ \t]*```!")
|
|
|
|
// fileRef is a file the agent may read into the prompt when it loads a skill or a command: `@` and a path, at the
|
|
// start of a line or after a space, a bracket or a quote. Any such word counts, a path without a dot or a slash
|
|
// (`@Makefile`) as much as one with: refused unless it is one of the item's own files, rather than guessed at.
|
|
var fileRef = regexp.MustCompile(`(?:^|[\s(\[{"'<])@([^\s` + "`" + `"')\]}>]+)`)
|
|
|
|
// runsProblem says why an item's text would run something in a session by itself, or "": the shell lines the
|
|
// agent runs when it loads a skill or a command, the front matter keys that start, run or pre-approve, front
|
|
// matter the module cannot read for certain, and a file outside the item that the agent would read in.
|
|
func runsProblem(it Item) string {
|
|
if it.Kind != KindSkill && it.Kind != KindAgent && it.Kind != KindCommand {
|
|
return ""
|
|
}
|
|
rels := make([]string, 0, len(it.Files))
|
|
for rel := range it.Files {
|
|
rels = append(rels, rel)
|
|
}
|
|
sort.Strings(rels)
|
|
for _, rel := range rels {
|
|
if !strings.HasSuffix(rel, ".md") {
|
|
continue // a script beside a skill runs only when the agent runs it, through the session's own rules
|
|
}
|
|
content := strings.TrimPrefix(it.Files[rel], "\ufeff")
|
|
if shellLine.MatchString(content) {
|
|
return fmt.Sprintf("%s holds a shell line (!`…` or ```!), which the agent runs when it loads the %s, "+
|
|
"before any permission is asked; nothing registered through this module runs by itself in a "+
|
|
"session (novox/hq issue 340)", rel, it.Kind)
|
|
}
|
|
keys, problem := frontMatterKeys(content)
|
|
if problem != "" {
|
|
return fmt.Sprintf("%s: %s, so what it asks of a session cannot be judged; it is refused rather than "+
|
|
"guessed at (novox/hq issue 340)", rel, problem)
|
|
}
|
|
for _, key := range keys {
|
|
norm := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(key))
|
|
if why, ok := runsFrontMatter[norm]; ok {
|
|
return fmt.Sprintf("%s says %q in its front matter: %s. Nothing registered through this module "+
|
|
"runs or loosens what runs in a session; that is the operator's, at the controller's "+
|
|
"terminal (novox/hq issue 340)", rel, key, why)
|
|
}
|
|
}
|
|
if it.Kind == KindSkill || it.Kind == KindCommand {
|
|
for _, m := range fileRef.FindAllStringSubmatch(content, -1) {
|
|
ref := strings.TrimRight(m[1], ".,;:!?")
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
if _, own := it.Files[ref]; !own || pathProblem(ref) != "" {
|
|
return fmt.Sprintf("%s refers to @%s, a file outside the %s that the agent reads into its "+
|
|
"prompt when it loads it; a registered item refers only to its own files (novox/hq issue 340)",
|
|
rel, ref, it.Kind)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// frontMatterKeys are the top-level keys of a markdown file's front matter, read as YAML: the block between a
|
|
// first line `---` (after a byte-order mark) and the next. A block that does not close, does not parse, or is not
|
|
// a mapping is a problem: the agent's own reader may take from it what this one cannot see.
|
|
func frontMatterKeys(content string) ([]string, string) {
|
|
content = strings.ReplaceAll(strings.TrimPrefix(content, "\ufeff"), "\r\n", "\n")
|
|
lines := strings.Split(content, "\n")
|
|
first := 0
|
|
for first < len(lines) && strings.TrimSpace(lines[first]) == "" {
|
|
first++
|
|
}
|
|
if first == len(lines) || strings.TrimSpace(lines[first]) != "---" {
|
|
return nil, ""
|
|
}
|
|
end := -1
|
|
for i := first + 1; i < len(lines); i++ {
|
|
if t := strings.TrimSpace(lines[i]); t == "---" || t == "..." {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
if end < 0 {
|
|
return nil, "its front matter does not close"
|
|
}
|
|
block := strings.Join(lines[first+1:end], "\n")
|
|
if strings.TrimSpace(block) == "" {
|
|
return nil, ""
|
|
}
|
|
var node yaml.Node
|
|
if err := yaml.Unmarshal([]byte(block), &node); err != nil {
|
|
return nil, "its front matter is not YAML (" + err.Error() + ")"
|
|
}
|
|
var top map[string]any
|
|
if err := node.Decode(&top); err != nil {
|
|
return nil, "its front matter is not a mapping of keys (" + err.Error() + ")"
|
|
}
|
|
keys := make([]string, 0, len(top))
|
|
for k := range top {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys, ""
|
|
}
|
|
|
|
// 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 problem := it.shapeProblem(); problem != "" {
|
|
return problem
|
|
}
|
|
return runsProblem(it)
|
|
}
|
|
|
|
// shapeProblem says why an item is not well formed, or "": its kind, scope, name, paths, files and size, without
|
|
// judging what its text asks of a session — which the operator's own text, set at the terminal, may.
|
|
func (it Item) shapeProblem() 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 == KindHook || it.Kind == KindSettings {
|
|
return RunsInASession(it.Kind)
|
|
}
|
|
if !itemName.MatchString(it.Name) {
|
|
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
|
}
|
|
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 {
|
|
var kept map[string]Item
|
|
_ = readJSON(p.config(), &kept)
|
|
return &ConfigView{p: p, items: Taken(kept)}
|
|
}
|
|
|
|
// Taken is what of the items kept in the module's own file may be rendered: each judged again as the state's watch
|
|
// judges it, so what was written through before this module refused some kinds — a hook, settings, an item that
|
|
// runs something by itself — is never rendered (novox/hq issue 340).
|
|
func Taken(kept map[string]Item) map[string]Item {
|
|
out := map[string]Item{}
|
|
for key, it := range kept {
|
|
_, node, _, _, ok := ParseKey(key)
|
|
if ok && it.Problem() == "" && it.Key(node) == key {
|
|
out[key] = it
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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, _ := fsys.ReadFile(v.p.config())
|
|
if string(before) == string(now) {
|
|
return false
|
|
}
|
|
_ = fsys.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
|
|
// Terminal is the shared text the operator set at the controller's terminal, in this module's settings
|
|
// (novox/hq issue 340): laid over what was registered for the mesh and the node, for every session here.
|
|
Terminal []Item
|
|
// Approved is the sections the operator approved through the operator channel (novox/hq ADR 0274), as this
|
|
// node proved and keeps them, that reach this node.
|
|
Approved []ApprovedSection
|
|
}
|
|
|
|
// 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 — or, for a binary, the
|
|
// file it is copied from (the guard's, which is this module's own executable).
|
|
type PluginFile struct {
|
|
Content string
|
|
Executable bool
|
|
From string `json:",omitempty"`
|
|
}
|
|
|
|
// GuardDir is where the guard on the agent's shell lives in the plugin (novox/hq ADR 0245): its binary and
|
|
// what it is given. Outside hooks/, so no registered hook's name can be it.
|
|
const GuardDir = "guard"
|
|
|
|
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
|
|
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
|
|
// 1744, 1743). Empty WaitsDir places no such hook.
|
|
type GuardFiles struct {
|
|
Exe string
|
|
Data GuardData
|
|
WaitsDir string
|
|
}
|
|
|
|
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
|
|
// edits and let every other tool through; the rule of the account agents run as (novox/hq ADR 0266) refuses
|
|
// every tool of a session running as another, reading and the mesh's tools included, so that session can do
|
|
// nothing at all.
|
|
const GuardMatcher = "*"
|
|
|
|
// GuardCommand is the hook's command line: by its path under the managed directory, root's — never the
|
|
// copy the agent may keep of a plugin, which the session could change.
|
|
func GuardCommand() string {
|
|
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
|
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
|
|
}
|
|
|
|
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
|
|
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
|
|
func SessionStartCommand(waitsDir string) string {
|
|
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
|
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
|
|
}
|
|
|
|
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
|
|
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
|
|
func StopCommand(waitsDir string) string {
|
|
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
|
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
|
|
}
|
|
|
|
// 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 { return MarketplaceWith(c, nil) }
|
|
|
|
// MarketplaceWith is the marketplace with the guard on the agent's shell, when there is one to place: its
|
|
// hook first, before any registered one.
|
|
func MarketplaceWith(c Config, guard *GuardFiles) 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, c.Terminal} {
|
|
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{}
|
|
if guard != nil && guard.Exe != "" {
|
|
out[root+GuardDir+"/guard"] = PluginFile{From: guard.Exe, Executable: true}
|
|
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
|
|
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
|
|
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
|
|
if guard.WaitsDir != "" {
|
|
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
|
|
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
|
|
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
|
|
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
|
|
hooks["Stop"] = append(hooks["Stop"], map[string]any{
|
|
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
|
|
}
|
|
}
|
|
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"]}
|
|
}
|
|
// A hook is never rendered from a registration (novox/hq issue 340): the plugin's hooks are the mesh's own
|
|
// guard alone, and any other is the operator's, in the managed settings set at the controller's terminal.
|
|
}
|
|
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},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// The approved sections that replace or remove a registered one, by its name and the digest of its text, and the
|
|
// proposals already placed where the registered section stood.
|
|
over, placed := map[string]ApprovedSection{}, map[string]bool{}
|
|
for _, s := range c.Approved {
|
|
if s.Over != "" {
|
|
over[s.Name+"|"+s.Over] = s
|
|
}
|
|
}
|
|
registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting."
|
|
for _, part := range []struct {
|
|
title, note string
|
|
items []Item
|
|
}{{"Instructions for every node", registered, c.Mesh}, {"Instructions for this node", registered, c.Node},
|
|
{"Instructions set at the controller's terminal", "Set in the `claude-code` module's `instructions` setting, at the controller's terminal.", c.Terminal}} {
|
|
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\n%s\n", part.title, part.note)
|
|
for _, it := range sections {
|
|
text := it.Files[it.Name+".md"]
|
|
if s, ok := over[it.Name+"|"+textDigest(text)]; ok {
|
|
// What the operator approved over this registered section stands in its place, or leaves it out.
|
|
placed[s.Proposal] = true
|
|
if s.Removes {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "\n### %s\n\n*Replaced on the operator's approval (proposed from %s; approved %s).*\n\n%s\n", it.Name, s.From,
|
|
s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text))
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(text))
|
|
}
|
|
}
|
|
var approved []ApprovedSection
|
|
for _, s := range c.Approved {
|
|
if !s.Removes && !placed[s.Proposal] {
|
|
approved = append(approved, s)
|
|
}
|
|
}
|
|
if len(approved) > 0 {
|
|
b.WriteString("\n## Instructions the operator approved\n\nProposed by an agent through the `claude-code` module and " +
|
|
"approved by the operator on a channel that proves who answered (novox/hq ADR 0274). Changed or removed only the " +
|
|
"same way: `claude_code_instructions_propose`.\n")
|
|
for _, s := range approved {
|
|
fmt.Fprintf(&b, "\n### %s\n\n*Proposed from %s; approved %s.*\n\n%s\n", s.Name, s.From,
|
|
s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text))
|
|
}
|
|
}
|
|
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) {
|
|
return placeHomeIn(filepath.Join(p.Home, ".claude"), p.placed(), want)
|
|
}
|
|
|
|
// placeHomeIn is PlaceHome for one agent directory and its own record of what was placed there.
|
|
func placeHomeIn(dir, record string, want map[string]string) (done []string, left []string) {
|
|
var placed Placed
|
|
if !readJSON(record, &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 := fsys.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 := fsys.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 := fsys.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 := fsys.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 := fsys.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 := fsys.WriteFile(record, 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 := fsys.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 := fsys.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 := fsys.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, rule files and its own memory (CLAUDE.md), 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, c.Terminal} {
|
|
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 := fsys.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
body, err := fsys.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 := fsys.ReadDir(filepath.Join(dir, sub))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
|
continue
|
|
}
|
|
body, _ := fsys.ReadFile(filepath.Join(dir, sub, e.Name()))
|
|
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
|
}
|
|
}
|
|
if body, err := fsys.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
|
add(KindMemory, memoryName, "CLAUDE.md", 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 := fsys.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 := fsys.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 := fsys.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)
|
|
}
|
|
|
|
// SharedTextAtTheTerminal is why text for every node or some is not registered through this module (novox/hq issue
|
|
// 340, the operator's decision of 2026-10-09): an instruction section, an output style, a skill, a command or a
|
|
// subagent at the mesh or node scope reaches every session on those nodes as the mesh's own word, and the tool
|
|
// runner cannot tell an agent from the operator. Until a surface of the operator's own updates it, it is set at
|
|
// the controller's terminal, in this module's settings; a person's own home stays theirs to register into.
|
|
func SharedTextAtTheTerminal(kind string) string {
|
|
key := map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
|
|
KindOutputStyle: "output_styles", KindInstructions: "instructions"}[kind]
|
|
return "a " + kind + " for every node or some reaches every session there as the mesh's own word, and this tool " +
|
|
"cannot tell an agent from the operator. It is set at the controller's terminal only, under `" + key +
|
|
"` in this module's settings, its text as a list of lines, on the control-node: mesh-cli settings set claude-code " +
|
|
"'{\"" + key + "\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340)." +
|
|
proposeInstead(kind) + " The home scope, " +
|
|
"one account's own directory, is still registered here. Nothing was registered"
|
|
}
|
|
|
|
// proposeInstead is, for an instruction section, the way an agent may still bring one to every node: proposed for
|
|
// the operator's approval (novox/hq ADR 0274).
|
|
func proposeInstead(kind string) string {
|
|
if kind != KindInstructions {
|
|
return ""
|
|
}
|
|
return " An agent may instead propose it for the operator's approval with claude_code_instructions_propose: it " +
|
|
"becomes active only when the operator approves it on a channel that proves who answered (novox/hq ADR 0274)."
|
|
}
|
|
|
|
// 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.Scope != ScopeHome && it.Kind != KindHook && it.Kind != KindSettings && knownKind(it.Kind) {
|
|
return nil, errors.New(SharedTextAtTheTerminal(it.Kind))
|
|
}
|
|
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 {
|
|
// Every target judged before anything is put: a list is refused whole (novox/hq issue 340).
|
|
for _, n := range nodes {
|
|
if why := HomeScopeRefused(p, n); why != "" {
|
|
return nil, errors.New(why)
|
|
}
|
|
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
|
|
}
|