claude-code: read and remove the person's own home items through the mesh
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

Hand-written items in an account's ~/.claude could only be removed over a
shell on the machine. claude_code_home_show reads one in full (memory,
~/.claude/CLAUDE.md, included); claude_code_home_remove removes one on the
person's word, with a required reason, refusing what the mesh placed and
symbolic links, keeping a dated copy in the module's state and logging it.
This commit is contained in:
jochen
2026-10-07 19:39:27 +02:00
parent c74f407abd
commit 03728728c3
6 changed files with 544 additions and 4 deletions
+289
View File
@@ -0,0 +1,289 @@
package main
// The person's own items in the operator account's agent directory — the ones the mesh did not place (novox/hq
// ADR 0216 rule 6) — read and removed through the mesh rather than over a shell on the machine.
//
// Removing one stays the person's act: the remove tool is the act made explicit. It is called on the person's
// word, takes their reason, refuses anything the mesh placed (unregister owns those), keeps a copy outside the
// agent directory before it deletes, and logs what it removed and why. Nothing here removes on its own.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
// KindMemory is the account's own instruction file, ~/.claude/CLAUDE.md: never placed by the mesh, read by
// every session of the account.
const KindMemory = "memory"
// memoryName is the one name the memory kind has.
const memoryName = "CLAUDE"
// RemovedDir is where, in the module's state, a removed item is kept: one dated folder per day, one folder
// per removal inside it.
const RemovedDir = "removed-from-home"
// homeKinds are the kinds the home tools take: those the status tool lists, and the memory.
var homeKinds = map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
KindOutputStyle: "output-styles", KindInstructions: "rules", KindMemory: ""}
func (p Paths) removedLog() string { return filepath.Join(p.State, RemovedDir, "removed.log") }
// homeKindOf reads a kind as a person may write it: output_style for output-style, rule for instructions.
func homeKindOf(kind string) string {
switch k := strings.ToLower(strings.TrimSpace(kind)); k {
case "output_style":
return KindOutputStyle
case "rule", "rules":
return KindInstructions
case "claude.md":
return KindMemory
default:
return k
}
}
// HomeItemPath is where one item of the home is, relative to the agent directory: a skill is its folder,
// every other kind one file.
func HomeItemPath(kind, name string) (rel string, folder bool, err error) {
kind = homeKindOf(kind)
sub, ok := homeKinds[kind]
if !ok {
return "", false, fmt.Errorf("kind is skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md), not %q", kind)
}
if kind == KindMemory {
if name != "" && name != memoryName && name != memoryName+".md" {
return "", false, fmt.Errorf("the memory is one file, CLAUDE.md; its name is %s or absent", memoryName)
}
return "CLAUDE.md", false, nil
}
name = strings.TrimSuffix(name, ".md")
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, ".") || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) {
return "", false, fmt.Errorf("%q is not an item's name: its folder, or its file without .md, as the status tool lists it", name)
}
if kind == KindSkill {
return sub + "/" + name, true, nil
}
return sub + "/" + name + ".md", false, nil
}
// placedUnder says whether the mesh placed the path, or anything inside it, and still holds it as its own: a
// path it placed that the person deleted and then made again is theirs (PlaceHome leaves it alone).
func placedUnder(p Paths, rel string) string {
var placed Placed
_ = readJSON(p.placed(), &placed)
for at, ours := range placed {
if ours == deletedByHand {
continue
}
if at == rel || strings.HasPrefix(at, rel+"/") {
return at
}
}
return ""
}
// readItem reads one item's files by path inside it, refusing a symbolic link anywhere on the way or in it:
// what it points at is not the home's.
func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.FileMode, error) {
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, nil, errors.New(why)
}
full := filepath.Join(dir, filepath.FromSlash(rel))
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, fmt.Errorf("%s is not in this home", full)
}
return nil, nil, err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, nil, fmt.Errorf("%s is a symbolic link", full)
}
files, modes := map[string]string{}, map[string]fs.FileMode{}
if !folder {
if !info.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%s is not a file", full)
}
raw, err := os.ReadFile(full)
if err != nil {
return nil, nil, err
}
files[filepath.Base(full)], modes[filepath.Base(full)] = string(raw), info.Mode().Perm()
return files, modes, nil
}
if !info.IsDir() {
return nil, nil, fmt.Errorf("%s is not a folder", full)
}
err = filepath.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("%s is a symbolic link", at)
}
if d.IsDir() {
return nil
}
if !d.Type().IsRegular() {
return fmt.Errorf("%s is not a file", at)
}
in, _ := filepath.Rel(full, at)
raw, err := os.ReadFile(at)
if err != nil {
return err
}
fi, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(in)], modes[filepath.ToSlash(in)] = string(raw), fi.Mode().Perm()
return nil
})
if err != nil {
return nil, nil, err
}
return files, modes, nil
}
// ShowHome answers one item of the home in full: where it is, whether the mesh placed it, and its files.
func ShowHome(p Paths, kind, name string) (map[string]any, error) {
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
files, _, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
answer := map[string]any{"kind": homeKindOf(kind), "path": filepath.Join(dir, filepath.FromSlash(rel)),
"placedByTheMesh": placedUnder(p, rel) != "", "files": files}
if !folder {
for _, content := range files {
answer["content"] = content
}
}
return answer, nil
}
// Removal is what the removed-items log keeps of one removal, and the copy's own note.
type Removal struct {
At string `json:"at"`
Node string `json:"node"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why"`
Kept string `json:"keptAt"`
Files []string `json:"files"`
}
// RemoveHome removes one item the person made in the home, on their word and for the reason given: it keeps
// a copy in the module's state first, under a dated folder, checks the copy, then deletes and logs. An item
// the mesh placed is refused — unregistering owns it. Answers where the copy is, so it can be put back.
func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, error) {
why = strings.TrimSpace(why)
if why == "" {
return nil, errors.New("why is required: the person's reason for removing it, kept in the log")
}
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
kind = homeKindOf(kind)
if kind == KindMemory {
name = memoryName
} else {
name = strings.TrimSuffix(name, ".md")
}
if at := placedUnder(p, rel); at != "" {
return nil, fmt.Errorf("the mesh placed %s: remove it with claude_code_%s_unregister at the home scope", at,
strings.ReplaceAll(kind, "-", "_"))
}
dir := filepath.Join(p.Home, ".claude")
files, modes, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
full := filepath.Join(dir, filepath.FromSlash(rel))
// The copy: <state>/removed-from-home/<date>/<time>-<kind>-<name>/<path as it was in the home>.
now = now.UTC()
day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02"))
kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name)
for n := 2; ; n++ {
if _, err := os.Lstat(kept); os.IsNotExist(err) {
break
}
kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n))
}
copyRoot := filepath.Join(kept, filepath.FromSlash(rel))
if !folder {
copyRoot = filepath.Dir(copyRoot)
}
names := make([]string, 0, len(files))
for in, content := range files {
to := filepath.Join(copyRoot, filepath.FromSlash(in))
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if err := os.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
}
names = append(names, in)
}
r := Removal{At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
note, _ := indented(r)
if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err)
}
// The item may have changed while it was copied: only what was copied is removed.
again, _, err := readItem(dir, rel, folder)
if err != nil || len(again) != len(files) {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
for in, content := range again {
if files[in] != content {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
}
if folder {
err = os.RemoveAll(full)
} else {
err = os.Remove(full)
}
if err != nil {
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
}
line, _ := json.Marshal(r)
logged := true
if f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
_, werr := f.Write(append(line, '\n'))
if cerr := f.Close(); werr != nil || cerr != nil {
logged = false
}
} else {
logged = false
}
say("removed %s from the home on the person's word, kept at %s: %s", full, r.Kept, why)
answer := map[string]any{"removed": full, "kind": kind, "name": name, "why": why, "keptAt": r.Kept,
"undo": "copy " + r.Kept + " back to " + full, "log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the removal is noted in " + filepath.Join(kept, "removal.json")
}
return answer, nil
}