The manager binds the node a login was adopted from to that login's licence, switching it if it was bound to another; serves public_key; adopt takes a key sealed to it. claude-code gains claude_code_add_api_key: read a file on this node, seal, hand to adopt, remove the file, optionally switch here.
578 lines
20 KiB
Go
578 lines
20 KiB
Go
package main
|
|
|
|
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
|
|
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
|
|
// ADR 0183, ADR 0201, ADR 0206).
|
|
//
|
|
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
|
|
// sealed to its one recipient.
|
|
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
|
|
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
|
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
|
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
|
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
|
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
|
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
|
// every node, `<node>.<server>` for one — which every node watches.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
|
const (
|
|
Seat = "anthropic-licence-manager"
|
|
Manager = "claude-licence-manager"
|
|
)
|
|
|
|
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
|
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
|
|
|
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
|
|
func Fingerprint(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
// Paths are where this node's files are, from the module's words (ADR 0192).
|
|
type Paths struct {
|
|
State, Facts, Settings, Home, Node string
|
|
}
|
|
|
|
// PathsFrom reads them, or answers false outside a launch.
|
|
func PathsFrom(env func(string) string) (Paths, bool) {
|
|
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
|
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE")}
|
|
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
|
}
|
|
|
|
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
|
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
|
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
|
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
|
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
|
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
|
|
|
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
|
type Ask func(address string, args any) (json.RawMessage, error)
|
|
|
|
// WriteManaged writes one managed file and answers what happened.
|
|
type WriteManaged func(name, content string) (string, error)
|
|
|
|
func readJSON(path string, into any) bool {
|
|
raw, err := os.ReadFile(path)
|
|
return err == nil && json.Unmarshal(raw, into) == nil
|
|
}
|
|
|
|
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
|
|
// moving to this binary keeps the key it had.
|
|
func Keypair(p Paths) (KeyPair, error) {
|
|
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
|
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
|
k, err := GenerateKeyPair()
|
|
if err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
|
return KeyPair{}, err
|
|
}
|
|
}
|
|
a, err1 := os.ReadFile(priv)
|
|
b, err2 := os.ReadFile(pub)
|
|
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
|
}
|
|
|
|
// Registered is what applies here of the servers registered through this module.
|
|
func Registered(p Paths) Servers {
|
|
s := Servers{}
|
|
readJSON(p.registry(), &s)
|
|
return s
|
|
}
|
|
|
|
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
|
// registered here.
|
|
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
|
var facts Facts
|
|
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
|
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
|
}
|
|
var settings Settings
|
|
readJSON(p.Settings, &settings)
|
|
var binding *Binding
|
|
var b Binding
|
|
if readJSON(p.binding(), &b) {
|
|
binding = &b
|
|
}
|
|
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
|
names := make([]string, 0, len(files))
|
|
for n := range files {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
var out []string
|
|
for _, n := range names {
|
|
line, err := write(n, files[n])
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- the licence ----------------------------------------------------------------------------------
|
|
|
|
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
|
|
type BindingState struct {
|
|
Licence string `json:"licence"`
|
|
Kind string `json:"kind"`
|
|
Generation int64 `json:"generation"`
|
|
}
|
|
|
|
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
|
type Current struct {
|
|
Licence string `json:"licence"`
|
|
Kind string `json:"kind"`
|
|
Generation int64 `json:"generation"`
|
|
Sealed *SealedBox `json:"sealed"`
|
|
Identity *Identity `json:"identity"`
|
|
}
|
|
|
|
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
|
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
|
|
type Holdings struct {
|
|
Node string `json:"node"`
|
|
Identity *Identity `json:"identity"`
|
|
Kind *string `json:"kind"`
|
|
Refresh struct {
|
|
Present bool `json:"present"`
|
|
Fingerprint *string `json:"fingerprint"`
|
|
ExpiresAt *int64 `json:"expiresAt"`
|
|
} `json:"refresh"`
|
|
Access *struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
} `json:"access"`
|
|
Licence *string `json:"licence"`
|
|
Generation int64 `json:"generation"`
|
|
ChangedAt *string `json:"changedAt"`
|
|
}
|
|
|
|
// HoldingsOf is what this node holds now.
|
|
func HoldingsOf(p Paths) Holdings {
|
|
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
|
creds := ReadCredentials(p.credentials())
|
|
if info, err := os.Stat(p.credentials()); err == nil {
|
|
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
|
h.ChangedAt = &at
|
|
}
|
|
if rt := RefreshTokenOf(creds); rt != "" {
|
|
fp := Fingerprint(rt)
|
|
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
|
|
}
|
|
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
|
|
h.Refresh.ExpiresAt = &v
|
|
}
|
|
if g := GrantOf(creds); g != nil {
|
|
h.Access = &struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
|
}
|
|
kind := ""
|
|
if _, err := os.Stat(p.apiKey()); err == nil {
|
|
kind = "api-key"
|
|
} else if h.Access != nil {
|
|
kind = "subscription"
|
|
}
|
|
if kind != "" {
|
|
h.Kind = &kind
|
|
}
|
|
var applied Binding
|
|
if readJSON(p.binding(), &applied) && applied.Licence != "" {
|
|
h.Licence, h.Generation = &applied.Licence, applied.Generation
|
|
}
|
|
return h
|
|
}
|
|
|
|
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
|
|
type GrantAnswer struct {
|
|
Sealed *SealedBox `json:"sealed,omitempty"`
|
|
Identity *Identity `json:"identity,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
Waiting *bool `json:"waiting,omitempty"`
|
|
}
|
|
|
|
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
|
|
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
|
|
// file holds no refresh token.
|
|
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
|
creds := ReadCredentials(p.credentials())
|
|
rt := RefreshTokenOf(creds)
|
|
if rt == "" {
|
|
no := false
|
|
return GrantAnswer{Waiting: &no}, nil
|
|
}
|
|
raw, err := json.Marshal(creds.oauth())
|
|
if err != nil {
|
|
return GrantAnswer{}, err
|
|
}
|
|
box, err := Seal(string(raw), managerPublicKey)
|
|
if err != nil {
|
|
return GrantAnswer{}, err
|
|
}
|
|
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
|
}
|
|
|
|
// Pull asks the seat for this node's current token and applies it.
|
|
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
|
keys, err := Keypair(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var c Current
|
|
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
|
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
|
}
|
|
return Apply(p, c, write)
|
|
}
|
|
|
|
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
|
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
|
|
// which lives hours, and says so.
|
|
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
|
if b == nil {
|
|
return "this node's binding was released; it keeps its last token until it expires", nil
|
|
}
|
|
var applied Binding
|
|
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
|
|
return "", nil
|
|
}
|
|
out, err := Pull(p, ask, write)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw, _ := json.Marshal(out)
|
|
return string(raw), nil
|
|
}
|
|
|
|
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
|
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
|
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
|
keys, err := Keypair(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var previous Binding
|
|
had := readJSON(p.binding(), &previous)
|
|
switched := !had || previous.Licence != c.Licence
|
|
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
|
if c.Kind == "api-key" {
|
|
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = os.Chmod(p.helper(), 0o700)
|
|
} else {
|
|
var g Grant
|
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
return nil, err
|
|
}
|
|
local := ReadCredentials(p.credentials())
|
|
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
|
|
// and the refresh token in the file is the one the manager just spent.
|
|
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
|
|
if d.Apply {
|
|
next := WithGrant(local, g)
|
|
if switched {
|
|
next = ReplacedBy(local, g)
|
|
}
|
|
if err := WriteCredentials(p.credentials(), next); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
|
}
|
|
// Away from the API key: it goes, with its helper.
|
|
_ = os.Remove(p.apiKey())
|
|
_ = os.Remove(p.helper())
|
|
}
|
|
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
|
|
changed, err := WriteIdentity(p.account(), *c.Identity)
|
|
if err == nil {
|
|
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
|
}
|
|
}
|
|
gen := c.Generation
|
|
if gen == 0 {
|
|
gen = previous.Generation
|
|
}
|
|
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
|
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
// The key-helper comes or goes with the licence's kind.
|
|
if rendered, err := RenderNow(p, write); err != nil {
|
|
out["rendered"] = map[string]any{"failed": err.Error()}
|
|
} else {
|
|
out["rendered"] = rendered
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
|
|
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
|
|
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
|
|
// never an argument.
|
|
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the key is read from a file on this node: %w", err)
|
|
}
|
|
key := strings.TrimSpace(string(raw))
|
|
if key == "" {
|
|
return nil, fmt.Errorf("%s is empty", file)
|
|
}
|
|
answer, err := ask(SeatVerb("public_key"), map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pk struct {
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
if err := json.Unmarshal(answer, &pk); err != nil || !strings.Contains(pk.PublicKey, "PUBLIC KEY") {
|
|
return nil, errors.New("the licence manager did not say what key to seal to")
|
|
}
|
|
box, err := Seal(key, pk.PublicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adopted, err := ask(SeatVerb("adopt"), map[string]any{"name": name, "sealed": box, "from": p.Node})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{"adopted": json.RawMessage(adopted)}
|
|
// Taken: the key now lives encrypted in the manager's store alone.
|
|
if err := os.Remove(file); err != nil {
|
|
out["file"] = "could not be removed: " + err.Error()
|
|
} else {
|
|
out["file"] = "removed"
|
|
}
|
|
if useHere {
|
|
switched, err := ask(SeatVerb("switch"), map[string]any{"consumer": p.Node, "licence": name})
|
|
if err != nil {
|
|
return out, fmt.Errorf("adopted, and switching this node to it failed: %w", err)
|
|
}
|
|
out["switched"] = json.RawMessage(switched)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- MCP servers ----------------------------------------------------------------------------------
|
|
|
|
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
|
type Registration struct {
|
|
Name string
|
|
Entry map[string]any
|
|
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
|
|
Nodes []string
|
|
}
|
|
|
|
// ServerState is the `servers` state as this module reaches it through the runtime.
|
|
type ServerState interface {
|
|
Put(key string, value any) error
|
|
Delete(key string) error
|
|
Keys() ([]string, error)
|
|
}
|
|
|
|
// ServerChange is one change to the `servers` state, as a watch hands it over.
|
|
type ServerChange struct {
|
|
Key string
|
|
Op string // put | delete
|
|
Value map[string]any
|
|
}
|
|
|
|
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
|
|
func KeyOf(scope, name string) string { return scope + "." + name }
|
|
|
|
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
|
|
// one, kept in memory from the watch and written through to the module's own file whenever what applies
|
|
// here changes, so the managed directory renders without the bus.
|
|
type ServerView struct {
|
|
p Paths
|
|
mu sync.Mutex
|
|
entries map[string]map[string]any
|
|
}
|
|
|
|
// NewServerView is an empty view for this node.
|
|
func NewServerView(p Paths) *ServerView {
|
|
return &ServerView{p: p, entries: map[string]map[string]any{}}
|
|
}
|
|
|
|
// Take takes one change, and answers whether what applies to this node changed.
|
|
func (v *ServerView) Take(c ServerChange) bool {
|
|
scope, name, ok := strings.Cut(c.Key, ".")
|
|
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
|
|
return false
|
|
}
|
|
v.mu.Lock()
|
|
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
|
|
v.entries[c.Key] = c.Value
|
|
} else {
|
|
delete(v.entries, c.Key)
|
|
}
|
|
v.mu.Unlock()
|
|
return v.writeThrough()
|
|
}
|
|
|
|
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
|
|
func (v *ServerView) Effective() Servers {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
out := Servers{}
|
|
for _, scope := range []string{"all", v.p.Node} {
|
|
for key, entry := range v.entries {
|
|
if name, ok := strings.CutPrefix(key, scope+"."); ok {
|
|
out[name] = entry
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (v *ServerView) writeThrough() bool {
|
|
now, _ := indented(v.Effective())
|
|
before, _ := os.ReadFile(v.p.registry())
|
|
if string(before) == string(now) {
|
|
return false
|
|
}
|
|
_ = os.WriteFile(v.p.registry(), now, 0o600)
|
|
return true
|
|
}
|
|
|
|
// OnServerChange takes a change from the watch, and renders when what applies here changed.
|
|
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
|
|
if !v.Take(c) {
|
|
return "", nil
|
|
}
|
|
if _, err := RenderNow(p, write); err != nil {
|
|
return "", err
|
|
}
|
|
what := "registered"
|
|
if c.Op != "put" {
|
|
what = "unregistered"
|
|
}
|
|
return what + " " + c.Key, nil
|
|
}
|
|
|
|
// RegisterServer registers (or, with no entry, unregisters) a server: a put (or delete) per scope in the
|
|
// `servers` state, taken into this node's view at once so the answer says what it did here; every other
|
|
// node takes it from its watch, and a node that joins later from the current state.
|
|
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
|
|
others func() ([]string, error)) (map[string]any, error) {
|
|
if r.Entry != nil {
|
|
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
|
|
return map[string]any{"registered": false, "reason": problem}, nil
|
|
}
|
|
}
|
|
scopes := r.Nodes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{p.Node}
|
|
}
|
|
// Compared before and after rather than read from Take: this node's own watch may hand the view the
|
|
// same change first, and then Take here finds nothing new although this call made it.
|
|
before, _ := json.Marshal(v.Effective())
|
|
for _, scope := range scopes {
|
|
key := KeyOf(scope, r.Name)
|
|
var err error
|
|
if r.Entry != nil {
|
|
err = servers.Put(key, r.Entry)
|
|
} else {
|
|
err = servers.Delete(key)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
op := "put"
|
|
if r.Entry == nil {
|
|
op = "delete"
|
|
}
|
|
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
|
|
}
|
|
after, _ := json.Marshal(v.Effective())
|
|
changed := string(before) != string(after)
|
|
here := false
|
|
for _, s := range scopes {
|
|
here = here || s == "all" || s == p.Node
|
|
}
|
|
verb := "registered"
|
|
if r.Entry == nil {
|
|
verb = "unregistered"
|
|
}
|
|
answer := map[string]any{verb: r.Name, "on": scopes}
|
|
switch {
|
|
case !here:
|
|
answer["here"] = "not this node"
|
|
case changed:
|
|
answer["here"] = "changed"
|
|
default:
|
|
answer["here"] = "already so"
|
|
}
|
|
if changed {
|
|
rendered, err := RenderNow(p, write)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer["rendered"] = rendered
|
|
}
|
|
if r.Entry == nil {
|
|
if _, still := v.Effective()[r.Name]; still {
|
|
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
|
|
}
|
|
}
|
|
if len(r.Nodes) == 0 {
|
|
// The question the operator wanted asked: here only, or more?
|
|
var elsewhere []string
|
|
if nodes, err := others(); err == nil {
|
|
for _, n := range nodes {
|
|
if n != p.Node {
|
|
elsewhere = append(elsewhere, n)
|
|
}
|
|
}
|
|
}
|
|
if len(elsewhere) > 0 {
|
|
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
|
|
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
|
|
} else {
|
|
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
|
|
}
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// stamp is a time as the status answers it.
|
|
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
|