A removal kept its copy but nothing could put it back without a shell on the machine. claude_code_home_restore puts a kept copy back when nothing is at its path and the copy matches the digests recorded at removal; claude_code_home_removed lists what is kept.
449 lines
16 KiB
Go
449 lines
16 KiB
Go
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"
|
|
"sort"
|
|
"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 {
|
|
Action string `json:"action"`
|
|
At string `json:"at"`
|
|
Node string `json:"node"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Why string `json:"why,omitempty"`
|
|
Kept string `json:"keptAt"`
|
|
Files map[string]KeptFile `json:"files"`
|
|
}
|
|
|
|
// KeptFile is one file of a kept copy as it was in the home: its digest, checked before it is put back, and
|
|
// its mode.
|
|
type KeptFile struct {
|
|
Digest string `json:"sha256"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
// 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 := map[string]KeptFile{}
|
|
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[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
|
|
}
|
|
r := Removal{Action: "removed", 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)
|
|
}
|
|
|
|
logged := logRemoval(p, r)
|
|
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
|
|
}
|
|
|
|
// logRemoval appends one line to the removed-items log, and answers whether it could.
|
|
func logRemoval(p Paths, r Removal) bool {
|
|
line, _ := json.Marshal(r)
|
|
f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, werr := f.Write(append(line, '\n'))
|
|
return f.Close() == nil && werr == nil
|
|
}
|
|
|
|
// ---- putting a removal back ---------------------------------------------------------------------------
|
|
|
|
// Kept is one removal whose copy the module keeps, as the list of removals shows it.
|
|
type Kept struct {
|
|
Removal
|
|
Restored string `json:"restored,omitempty"`
|
|
}
|
|
|
|
// KeptRemovals lists every removal the module keeps a copy of, newest first, and whether it was put back.
|
|
func KeptRemovals(p Paths) ([]Kept, error) {
|
|
root := filepath.Join(p.State, RemovedDir)
|
|
notes, _ := filepath.Glob(filepath.Join(root, "*", "*", "removal.json"))
|
|
out := []Kept{}
|
|
for _, note := range notes {
|
|
var k Kept
|
|
if !readJSON(note, &k.Removal) {
|
|
continue
|
|
}
|
|
var back Removal
|
|
if readJSON(filepath.Join(filepath.Dir(note), "restored.json"), &back) {
|
|
k.Restored = back.At
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].At != out[j].At {
|
|
return out[i].At > out[j].At
|
|
}
|
|
return out[i].Kept > out[j].Kept
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// removalFolder finds the folder of one removal from what a removal answered as keptAt (or the folder
|
|
// itself): <state>/removed-from-home/<date>/<removal>, and nothing outside it.
|
|
func removalFolder(p Paths, kept string) (string, error) {
|
|
root := filepath.Join(p.State, RemovedDir)
|
|
rel, err := filepath.Rel(root, filepath.Clean(kept))
|
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
|
if err != nil || kept == "" || len(parts) < 2 || parts[0] == ".." || parts[0] == "." {
|
|
return "", fmt.Errorf("%q is not a copy this module kept: give the keptAt a removal answered, as claude_code_home_removed lists it", kept)
|
|
}
|
|
return filepath.Join(root, parts[0], parts[1]), nil
|
|
}
|
|
|
|
// RestoreHome puts a removed item back where it was: only when nothing is at that path now, and only when the
|
|
// kept copy is exactly what was removed, file by file against the digests its note recorded. Logged as the
|
|
// removal was; the copy stays, marked as put back.
|
|
func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
|
|
folder, err := removalFolder(p, kept)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var r Removal
|
|
if !readJSON(filepath.Join(folder, "removal.json"), &r) {
|
|
return nil, fmt.Errorf("%s holds no readable removal.json: nothing to check the copy against, nothing restored", folder)
|
|
}
|
|
// Where it goes is worked out again from its kind and name, never taken from the note alone.
|
|
rel, isFolder, err := HomeItemPath(r.Kind, r.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir := filepath.Join(p.Home, ".claude")
|
|
full := filepath.Join(dir, filepath.FromSlash(rel))
|
|
if r.Path != full || r.Kept != filepath.Join(folder, filepath.FromSlash(rel)) {
|
|
return nil, fmt.Errorf("%s/removal.json does not describe the copy beside it, nothing restored", folder)
|
|
}
|
|
|
|
// The copy's own integrity: the same files, each with the digest recorded when it was removed.
|
|
files, _, err := readItem(folder, rel, isFolder)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the kept copy cannot be read, nothing restored: %w", err)
|
|
}
|
|
if len(files) != len(r.Files) {
|
|
return nil, fmt.Errorf("the kept copy holds %d file(s), its note %d: nothing restored", len(files), len(r.Files))
|
|
}
|
|
for in, content := range files {
|
|
want, ok := r.Files[in]
|
|
if !ok || digest(content) != want.Digest {
|
|
return nil, fmt.Errorf("the kept copy's %s is not what was removed: nothing restored", in)
|
|
}
|
|
}
|
|
|
|
// Nothing may be in the way: whatever is there now is the person's, or the mesh's.
|
|
if why := linkedParent(dir, rel+"/x"); why != "" {
|
|
return nil, errors.New(why + ", nothing restored")
|
|
}
|
|
if _, err := os.Lstat(full); err == nil {
|
|
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
base := full
|
|
if !isFolder {
|
|
base = filepath.Dir(full)
|
|
}
|
|
written := []string{}
|
|
undo := func() {
|
|
for _, w := range written {
|
|
_ = os.Remove(w)
|
|
}
|
|
if isFolder {
|
|
_ = os.RemoveAll(full)
|
|
}
|
|
}
|
|
for in, content := range files {
|
|
to := filepath.Join(base, filepath.FromSlash(in))
|
|
if !isFolder {
|
|
to = full
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
var m uint32
|
|
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
|
|
mode = os.FileMode(m).Perm()
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
|
undo()
|
|
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
|
}
|
|
f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
undo()
|
|
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
|
}
|
|
_, werr := f.WriteString(content)
|
|
if cerr := f.Close(); werr != nil || cerr != nil {
|
|
undo()
|
|
return nil, fmt.Errorf("%s could not be restored", to)
|
|
}
|
|
_ = os.Chmod(to, mode) // the umask may have taken bits the file had
|
|
written = append(written, to)
|
|
}
|
|
|
|
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
|
|
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
|
|
note, _ := indented(back)
|
|
_ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
|
|
logged := logRemoval(p, back)
|
|
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
|
|
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
|
|
"log": p.removedLog()}
|
|
if !logged {
|
|
answer["log"] = "the log could not be written; the restore is noted in " + filepath.Join(folder, "restored.json")
|
|
}
|
|
return answer, nil
|
|
}
|