On the control node agents are to run as an account that cannot become root (hq ADR 0266). The runtime keeps running as the operator, so the token, the identity and the home-scope items go to the agent's home through a helper started as that account; the licence and the account file follow the move, what was placed in the operator's home goes once the agent holds the licence, and the guard refuses every tool of a session running as anyone else, recording to the journal. An API key is taken only from the agent's home and only when it is one, here and in the licence manager, so the tool cannot carry off the runtime's credential. Unchanged where no agent account is named.
1125 lines
41 KiB
Go
1125 lines
41 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-controller settings set claude-code '{\"managed_settings\": {...}}' [--node <node>], " +
|
|
"typed by the operator at the controller's terminal"
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 and the data it judges with.
|
|
type GuardFiles struct {
|
|
Exe string
|
|
Data GuardData
|
|
}
|
|
|
|
// 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") + `"`
|
|
}
|
|
|
|
// 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}}})
|
|
}
|
|
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
|
|
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 {
|
|
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) {
|
|
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: mesh-controller settings set claude-code " +
|
|
"'{\"" + key + "\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340). The home scope, " +
|
|
"one account's own directory, is still registered here. Nothing was registered"
|
|
}
|
|
|
|
// 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
|
|
}
|