Merge pull request 'claude-code: read and remove the person's own home items through the mesh' (#108) from feat/claude-code-home-show-remove into main
This commit was merged in pull request #108.
This commit is contained in:
@@ -70,7 +70,19 @@ operator account's own `~/.claude` on those nodes):
|
||||
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
||||
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
||||
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
||||
the original stays).
|
||||
the original stays);
|
||||
- `claude_code_home_show` (`kind`, `name`): one item of this node's home in full — a skill, subagent,
|
||||
command, output style, rule file (`instructions`), or the account's own memory `~/.claude/CLAUDE.md`
|
||||
(`memory`) — and whether the mesh placed it;
|
||||
- `claude_code_home_remove` (`kind`, `name`, `why`): removes one item the person made, on their word —
|
||||
removing it is the person's act, and this tool is that act made explicit. `why` is required; what the
|
||||
mesh placed is refused (its `_unregister` owns it), and so is a symbolic link. A copy is kept first in
|
||||
the module's state, `removed-from-home/<date>/<time>-<kind>-<name>/`, with a `removal.json` note, and
|
||||
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy;
|
||||
- `claude_code_home_removed`: every kept removal, newest first, with its `keptAt` and whether it was put back;
|
||||
- `claude_code_home_restore` (`kept`): puts a removal's copy back at its path — refused when something is
|
||||
there now, or when the copy is not, file by file, what its `removal.json` digests say was removed.
|
||||
Logged to `removed.log` and the journal; the copy stays, marked with a `restored.json`.
|
||||
|
||||
A new session takes a change; a running one at `/reload-plugins`.
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ type HomeItem struct {
|
||||
|
||||
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||
// 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")
|
||||
@@ -718,6 +718,9 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
if body, err := os.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
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -247,7 +248,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
Input: map[string]any{"key": str("the key")},
|
||||
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||
stdio.Tool{Name: "claude_code_config_status",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles, rule files and memory (CLAUDE.md) — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c := ConfigOf(view.Items())
|
||||
names := func(items []Item) []string {
|
||||
@@ -301,6 +302,40 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_home_show",
|
||||
Description: "One item of this node's operator account's own ~/.claude in full, as the status tool lists it — a skill (every file in its folder), subagent, command, output style or rule file — or the account's own memory, ~/.claude/CLAUDE.md: where it is, whether the mesh placed it, and its content.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) { return ShowHome(p, strArg(a, "kind"), strArg(a, "name")) }},
|
||||
stdio.Tool{Name: "claude_code_home_remove",
|
||||
Description: "Remove one item the person made in this node's operator account's own ~/.claude — a skill, subagent, command, output style, rule file, or the memory ~/.claude/CLAUDE.md — on the person's word: removing it is the person's act, and this tool is that act made explicit. Called only when the person asked for that item to go, never on the agent's own judgement. `why` is required. An item the mesh placed is refused (its unregister owns it). A copy is kept first in the module's state, under removed-from-home/<date>/, and the removal and its reason are logged there; the answer says where the copy is, so it can be put back.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
|
||||
"why": str("the person's reason for removing it, kept in the log"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
answer, err := RemoveHome(p, strArg(a, "kind"), strArg(a, "name"), strArg(a, "why"), time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A home registration of the same name was held back by the person's file; it is placed now.
|
||||
for _, it := range ConfigOf(view.Items()).Home {
|
||||
if it.Kind == answer["kind"] && it.Name == answer["name"] {
|
||||
answer["then"] = "the mesh registers a " + it.Kind + " of this name for this home; it is placed at the next render"
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_home_removed",
|
||||
Description: "Every item removed from this node's home through claude_code_home_remove whose copy the module keeps, newest first: what it was, where it was, why it went, its keptAt, and whether it was put back.",
|
||||
Run: func(map[string]any) (any, error) { return KeptRemovals(p) }},
|
||||
stdio.Tool{Name: "claude_code_home_restore",
|
||||
Description: "Undo a removal made with claude_code_home_remove: put the kept copy back at the path it was removed from. Refused when something is at that path now, or when the copy is not exactly what was removed (checked file by file against the digests its removal.json recorded). Logged as the removal was; the copy stays.",
|
||||
Input: map[string]any{"kept": str("the keptAt the removal answered, as claude_code_home_removed lists it")},
|
||||
Run: func(a map[string]any) (any, error) { return RestoreHome(p, strArg(a, "kept"), time.Now()) }},
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package main
|
||||
|
||||
// The person's own items in the home, read and removed on their word (novox/hq ADR 0216 rule 6: removing the
|
||||
// original is the person's act — the remove tool is that act made explicit).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var at = time.Date(2026, 10, 7, 9, 30, 0, 0, time.UTC)
|
||||
|
||||
func TestEveryKindIsShownInFull(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# my memory\n")
|
||||
|
||||
rule, err := ShowHome(p, "instructions", "hal-era")
|
||||
if err != nil || rule["content"] != "# old rule\n" || rule["placedByTheMesh"] != false {
|
||||
t.Fatalf("%v %v", rule, err)
|
||||
}
|
||||
if again, err := ShowHome(p, "rule", "hal-era.md"); err != nil || again["content"] != "# old rule\n" {
|
||||
t.Fatalf("a rule named as a person writes it: %v %v", again, err)
|
||||
}
|
||||
skill, err := ShowHome(p, KindSkill, "old")
|
||||
if files, _ := skill["files"].(map[string]string); err != nil || len(files) != 2 || files["scripts/a.sh"] != "#!/bin/sh\n" {
|
||||
t.Fatalf("%v %v", skill, err)
|
||||
}
|
||||
memory, err := ShowHome(p, KindMemory, "")
|
||||
if err != nil || memory["content"] != "# my memory\n" {
|
||||
t.Fatalf("%v %v", memory, err)
|
||||
}
|
||||
if _, err := ShowHome(p, KindAgent, "absent"); err == nil {
|
||||
t.Fatal("an absent item was shown")
|
||||
}
|
||||
for _, name := range []string{"../../etc/passwd", "..", ".credentials", "a/b"} {
|
||||
if _, err := ShowHome(p, KindInstructions, name); err == nil {
|
||||
t.Errorf("%q reached outside its kind's folder", name)
|
||||
}
|
||||
}
|
||||
if _, err := ShowHome(p, KindHook, "x"); err == nil {
|
||||
t.Fatal("a kind the home does not hold was taken")
|
||||
}
|
||||
// The status tool lists the memory beside the rest.
|
||||
found := false
|
||||
for _, it := range HomeItems(p, Config{}, Servers{}) {
|
||||
found = found || (it.Kind == KindMemory && it.Name == memoryName && !it.Placed)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the status does not list the home's memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalNeedsItsReason(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "keep.md"), "x")
|
||||
if _, err := RemoveHome(p, KindInstructions, "keep", " ", at); err == nil {
|
||||
t.Fatal("removed without a reason")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "keep.md")); err != nil {
|
||||
t.Fatal("the file went although the removal was refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalKeepsACopyAndLogsWhy(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
|
||||
|
||||
answer, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "hal-era.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("the rule file is still there")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules")); err != nil {
|
||||
t.Fatal("the kind's own folder went with it")
|
||||
}
|
||||
kept, _ := answer["keptAt"].(string)
|
||||
want := filepath.Join(p.State, RemovedDir, "2026-10-07", "093000-instructions-hal-era", "rules", "hal-era.md")
|
||||
if kept != want {
|
||||
t.Fatalf("kept at %s, not %s", kept, want)
|
||||
}
|
||||
if !strings.HasPrefix(kept, p.State) || strings.HasPrefix(kept, dir) {
|
||||
t.Fatal("the copy is inside the agent directory")
|
||||
}
|
||||
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
|
||||
t.Fatalf("the copy holds %q", raw)
|
||||
}
|
||||
|
||||
// A second removal in the same second does not overwrite the first's copy.
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# made again\n")
|
||||
second, err := RemoveHome(p, KindInstructions, "hal-era", "again", at)
|
||||
if err != nil || second["keptAt"] == kept {
|
||||
t.Fatalf("%v %v", second, err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
|
||||
t.Fatal("the first copy was overwritten")
|
||||
}
|
||||
|
||||
// A skill goes as its whole folder, kept with its modes.
|
||||
skill, err := RemoveHome(p, KindSkill, "old", "superseded by the plugin's", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "old")); !os.IsNotExist(err) {
|
||||
t.Fatal("the skill's folder is still there")
|
||||
}
|
||||
script := filepath.Join(skill["keptAt"].(string), "scripts", "a.sh")
|
||||
if info, err := os.Stat(script); err != nil || info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("the script's copy lost its executable bit: %v", err)
|
||||
}
|
||||
|
||||
// The log holds every removal, each with its reason.
|
||||
raw, err := os.ReadFile(p.removedLog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("%d lines logged", len(lines))
|
||||
}
|
||||
var r Removal
|
||||
if err := json.Unmarshal([]byte(lines[0]), &r); err != nil || r.Why != "HAL is retired" || r.Node != "laptop" || r.Kept != kept {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMemoryIsRemovedAndKept(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# HAL era\n")
|
||||
answer, err := RemoveHome(p, KindMemory, "", "the managed file says it now", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("the memory is still there")
|
||||
}
|
||||
if raw, _ := os.ReadFile(answer["keptAt"].(string)); string(raw) != "# HAL era\n" {
|
||||
t.Fatal("the memory was not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheMeshPlacedIsRefused(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := filepath.Join(p.Home, ".claude", "agents", "placed.md")
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
t.Fatal("the home item was not placed")
|
||||
}
|
||||
shown, err := ShowHome(p, KindAgent, "placed")
|
||||
if err != nil || shown["placedByTheMesh"] != true {
|
||||
t.Fatalf("%v %v", shown, err)
|
||||
}
|
||||
if _, err := RemoveHome(p, KindAgent, "placed", "tidy", at); err == nil || !strings.Contains(err.Error(), "claude_code_agent_unregister") {
|
||||
t.Fatalf("what the mesh placed was not refused: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
t.Fatal("the mesh's item was removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, RemovedDir)); !os.IsNotExist(err) {
|
||||
t.Fatal("a refused removal left a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASymbolicLinkIsLeftAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
elsewhere := t.TempDir()
|
||||
writeFile(t, filepath.Join(elsewhere, "target.md"), "not the home's")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
if err := os.Symlink(filepath.Join(elsewhere, "target.md"), filepath.Join(dir, "rules", "linked.md")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := RemoveHome(p, KindInstructions, "linked", "x", at); err == nil {
|
||||
t.Fatal("a symbolic link was removed")
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "skills")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(elsewhere, "s"), 0o755)
|
||||
writeFile(t, filepath.Join(elsewhere, "s", "SKILL.md"), "x")
|
||||
if _, err := RemoveHome(p, KindSkill, "s", "x", at); err == nil {
|
||||
t.Fatal("a skill was removed through a linked folder")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "s", "SKILL.md")); err != nil {
|
||||
t.Fatal("what the link points at was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalIsListedAndPutBack(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "rules", "hal-era.md"), 0o644)
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
|
||||
|
||||
rule, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := RemoveHome(p, KindSkill, "old", "superseded", at.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := KeptRemovals(p)
|
||||
if err != nil || len(kept) != 2 || kept[0].Name != "old" || kept[1].Kept != rule["keptAt"] || kept[1].Restored != "" {
|
||||
t.Fatalf("%+v %v", kept, err)
|
||||
}
|
||||
|
||||
back, err := RestoreHome(p, rule["keptAt"].(string), at.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := filepath.Join(dir, "rules", "hal-era.md")
|
||||
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" || back["restored"] != full {
|
||||
t.Fatalf("%v: %q", back, raw)
|
||||
}
|
||||
if info, _ := os.Stat(full); info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("restored as %v, removed as 0644", info.Mode().Perm())
|
||||
}
|
||||
if _, err := RestoreHome(p, skill["keptAt"].(string), at.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, "skills", "old", "scripts", "a.sh")); err != nil || info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("the skill's script came back without its executable bit: %v", err)
|
||||
}
|
||||
|
||||
// Listed as put back; logged with the removals.
|
||||
kept, _ = KeptRemovals(p)
|
||||
for _, k := range kept {
|
||||
if k.Restored == "" {
|
||||
t.Errorf("%s is not listed as put back", k.Kept)
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile(p.removedLog())
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
var last Removal
|
||||
if len(lines) != 4 || json.Unmarshal([]byte(lines[2]), &last) != nil || last.Action != "restored" || last.Path != full {
|
||||
t.Fatalf("%d lines, %+v", len(lines), last)
|
||||
}
|
||||
|
||||
// Something at the path now: refused, and left as it is.
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("restored over what is there now")
|
||||
}
|
||||
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" {
|
||||
t.Fatal("what was there was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACopyThatChangedIsNotPutBack(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "s"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "rules", "r.md"), "original")
|
||||
writeFile(t, filepath.Join(dir, "skills", "s", "SKILL.md"), "x")
|
||||
rule, _ := RemoveHome(p, KindInstructions, "r", "why", at)
|
||||
skill, _ := RemoveHome(p, KindSkill, "s", "why", at)
|
||||
|
||||
writeFile(t, rule["keptAt"].(string), "tampered")
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a changed copy was put back")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "r.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("something was written from a changed copy")
|
||||
}
|
||||
// A file added to a kept skill: not what was removed.
|
||||
writeFile(t, filepath.Join(skill["keptAt"].(string), "extra.md"), "x")
|
||||
if _, err := RestoreHome(p, skill["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a copy with an extra file was put back")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); !os.IsNotExist(err) {
|
||||
t.Fatal("the skill's folder was made from a changed copy")
|
||||
}
|
||||
// A note pointing elsewhere than its own kind and name: refused.
|
||||
note := filepath.Join(filepath.Dir(filepath.Dir(rule["keptAt"].(string))), "removal.json")
|
||||
var r Removal
|
||||
_ = readJSON(note, &r)
|
||||
writeFile(t, rule["keptAt"].(string), "original")
|
||||
r.Path = "/etc/passwd"
|
||||
raw, _ := json.Marshal(r)
|
||||
writeFile(t, note, string(raw))
|
||||
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
|
||||
t.Fatal("a note naming another path was followed")
|
||||
}
|
||||
// Nothing outside the module's kept copies.
|
||||
for _, kept := range []string{"", "/etc/passwd", filepath.Join(p.State, RemovedDir), filepath.Join(p.State, RemovedDir, "..", "config.json")} {
|
||||
if _, err := RestoreHome(p, kept, at); err == nil {
|
||||
t.Errorf("%q was taken as a kept copy", kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,11 @@
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import"
|
||||
"claude_code_config_import",
|
||||
"claude_code_home_show",
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
|
||||
Reference in New Issue
Block a user