Skip to content

Commit e9ba82a

Browse files
illeatmyhatclaude
andauthored
feat(claw-code): add Claw Code platform support to installer (#208)
* feat(claw-code): add Claw Code platform support to installer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(claw-code): sync evolve-lite skills with claude (sharing + updates) Bring claw-code's evolve-lite plugin up to parity with claude's after rebase: port post-rebase updates to learn/recall/save/save-trajectory, add the publish/subscribe/sync/unsubscribe sharing skills, and sync shared lib/ (audit.py, config.py, entity_io.py). SKILL.md command examples are adapted to claw's sh -lc path-resolution pattern instead of \${CLAUDE_PLUGIN_ROOT}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(evolve-lite): address PR review feedback (CodeRabbit on #208) - claw-code retrieve_entities.sh: clarify the PreToolUse hook is optional and not auto-registered by the packaged plugin - entity_io.write_entity_file: initialize target=None so the except handler can't raise UnboundLocalError (claude + claw-code) - save_entities.py: reject malformed payloads where `entities` is not a list (claude + claw-code + codex + bob) - claw-code recall retrieve_entities.py: redact HOOK_*/CLAWD_* env values (HOOK_TOOL_INPUT may carry prompts/secrets); only HOOK_EVENT and HOOK_TOOL_NAME remain logged verbatim - save_trajectory.py: prefer EVOLVE_DIR over CLAUDE_PROJECT_ROOT (matches the documented contract); claim trajectory filenames atomically with O_CREAT|O_EXCL to survive same-second races; require `messages` to be a non-empty list instead of accepting any truthy value (claude + claw-code) - claw-code save-trajectory/SKILL.md: drop the duplicate Step 5 block — keep only the CLAW_CONFIG_HOME-aware variant Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(evolve-lite): address PR #208 round-2 review feedback - config._cast: quoted YAML scalars stay strings — only unquoted scalars coerce to bool/null/int/float/list (claude + claw-code) - config._coerce_repo: reject names that fail is_valid_repo_name so a hand-edited evolve.config.yaml can't smuggle path traversal into downstream clone paths (claude + claw-code) - claw-code recall retrieve_entities.py: guard input_data type before calling .keys(); non-dict JSON no longer aborts recall - subscribe clone: wrap subprocess.run with timeout + capture_output and clean up the partial dest directory on failure (claude + claw-code + codex + bob) - sync._head_hash: route through the existing _git helper so subprocess.TimeoutExpired no longer aborts the whole sync run (claude + claw-code) - sync --config: load_config takes project_root, not filepath — the prior call raised TypeError on every --config usage. Resolve the path to its parent and pass project_root (claude + claw-code + codex) - unsubscribe: add --force; refuse to delete a write-scope clone without it (the clone may hold unpushed publish commits). SKILL.md updated to surface the flag for the agent. (claude + claw-code + codex + bob) - claw-code sync/SKILL.md and claude sync/SKILL.md: collapse the two example blockquotes into one block (markdownlint MD028) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9885f3f commit e9ba82a

39 files changed

Lines changed: 3609 additions & 61 deletions

File tree

platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def main():
5353
sys.exit(1)
5454

5555
new_entities = input_data.get("entities", [])
56+
if not isinstance(new_entities, list):
57+
log(f"Invalid entities payload type: {type(new_entities).__name__}")
58+
print("Error: `entities` must be a list.", file=sys.stderr)
59+
sys.exit(1)
5660
if not new_entities:
5761
log("No entities in input")
5862
print("No entities provided in input.", file=sys.stderr)

platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/scripts/subscribe.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,17 @@ def main():
6363
clone_cmd = ["git", "clone", args.remote, str(dest), "--branch", args.branch]
6464
if args.scope == "read":
6565
clone_cmd += ["--depth", "1"]
66-
subprocess.run(clone_cmd, check=True)
66+
try:
67+
subprocess.run(clone_cmd, check=True, timeout=60, capture_output=True, text=True)
68+
except subprocess.TimeoutExpired:
69+
shutil.rmtree(dest, ignore_errors=True)
70+
print("Error: git clone timed out", file=sys.stderr)
71+
sys.exit(1)
72+
except subprocess.CalledProcessError as exc:
73+
shutil.rmtree(dest, ignore_errors=True)
74+
detail = (exc.stderr or exc.stdout or "").strip() or f"exit {exc.returncode}"
75+
print(f"Error: git clone failed: {detail}", file=sys.stderr)
76+
sys.exit(1)
6777

6878
repos.append(
6979
{

platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ def main():
3636
group = parser.add_mutually_exclusive_group(required=True)
3737
group.add_argument("--list", action="store_true", help="Print repos as JSON array")
3838
group.add_argument("--name", help="Name of repo to remove")
39+
parser.add_argument(
40+
"--force",
41+
action="store_true",
42+
help="Required to remove a write-scope repo (its clone may hold unpushed publishes)",
43+
)
3944
args = parser.parse_args()
4045

4146
evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve"))
@@ -56,11 +61,21 @@ def main():
5661
print(f"Error: invalid subscription name: {name!r}", file=sys.stderr)
5762
sys.exit(1)
5863

59-
new_repos = [r for r in repos if r.get("name") != name]
60-
if len(new_repos) == len(repos):
64+
matched = next((r for r in repos if r.get("name") == name), None)
65+
if matched is None:
6166
print(f"Error: subscription '{name}' not found.", file=sys.stderr)
6267
sys.exit(1)
6368

69+
if matched.get("scope") == "write" and not args.force:
70+
print(
71+
f"Error: '{name}' is a write-scope repo. Removing it would delete the local clone, "
72+
"including any unpushed publish commits. Re-run with --force to confirm.",
73+
file=sys.stderr,
74+
)
75+
sys.exit(1)
76+
77+
new_repos = [r for r in repos if r.get("name") != name]
78+
6479
if dest.exists():
6580
shutil.rmtree(dest)
6681
print(f"Deleted {dest}")

platform-integrations/claude/plugins/evolve-lite/lib/config.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,17 @@ def _parse_yaml(text):
175175

176176

177177
def _cast(value):
178-
"""Cast a YAML scalar string to an appropriate Python type."""
179-
# Strip surrounding quotes first to handle quoted empty strings correctly
178+
"""Cast a YAML scalar string to an appropriate Python type.
179+
180+
Quoted scalars stay strings — that's the whole point of YAML quoting.
181+
Only unquoted scalars get coerced to bool / null / int / float / list.
182+
"""
183+
# Quoted: return the string verbatim (with single-quote unescaping).
180184
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
181185
stripped = value[1:-1]
182-
# Quoted empty string should return empty string, not None
183-
if stripped == "":
184-
return ""
185-
# Un-double single quotes escaped by _scalar (e.g. "a''b" → "a'b")
186186
if value.startswith("'"):
187187
stripped = stripped.replace("''", "'")
188-
value = stripped
188+
return stripped
189189

190190
if value in ("true", "True", "yes"):
191191
return True
@@ -335,6 +335,12 @@ def _coerce_repo(entry):
335335
remote = entry.get("remote")
336336
if not isinstance(name, str) or not name.strip():
337337
return None
338+
if not is_valid_repo_name(name.strip()):
339+
print(
340+
f"evolve-lite: ignoring repo entry {name!r} — invalid name (only A-Z, a-z, 0-9, '.', '_', '-' allowed)",
341+
file=sys.stderr,
342+
)
343+
return None
338344
if not isinstance(remote, str) or not remote.strip():
339345
return None
340346
scope = entry.get("scope", "read")

platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ def write_entity_file(directory, entity):
269269

270270
# Write to a unique temp file first (avoids predictable .tmp collisions)
271271
fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug)
272+
target = None
272273
try:
273274
os.write(fd, content.encode("utf-8"))
274275
os.close(fd)

platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/save_entities.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def main():
4848
sys.exit(1)
4949

5050
new_entities = input_data.get("entities", [])
51+
if not isinstance(new_entities, list):
52+
log(f"Invalid entities payload type: {type(new_entities).__name__}")
53+
print("Error: `entities` must be a list.", file=sys.stderr)
54+
sys.exit(1)
5155
if not new_entities:
5256
log("No entities in input")
5357
print("No entities provided in input.", file=sys.stderr)

platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/save_trajectory.py

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,45 @@ def log(message):
4444

4545

4646
def get_trajectories_dir():
47-
"""Get the trajectories output directory, creating it if needed."""
48-
project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "")
49-
if project_root:
50-
base = Path(project_root) / ".evolve" / "trajectories"
47+
"""Get the trajectories output directory, creating it if needed.
48+
49+
Resolution order:
50+
1. ``EVOLVE_DIR`` env var (matches the documented contract)
51+
2. ``CLAUDE_PROJECT_ROOT`` env var (the agent's project root)
52+
3. ``.evolve/`` in the current working directory
53+
"""
54+
evolve_dir = os.environ.get("EVOLVE_DIR")
55+
if evolve_dir:
56+
base = Path(evolve_dir) / "trajectories"
5157
else:
52-
base = Path(".evolve") / "trajectories"
58+
project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "")
59+
if project_root:
60+
base = Path(project_root) / ".evolve" / "trajectories"
61+
else:
62+
base = Path(".evolve") / "trajectories"
5363

5464
base.mkdir(parents=True, exist_ok=True, mode=0o700)
5565
return base.resolve()
5666

5767

58-
def generate_filename(trajectories_dir):
59-
"""Generate a timestamped filename, adding a suffix on collision."""
68+
def open_trajectory_file(trajectories_dir):
69+
"""Atomically claim a timestamped trajectory file.
70+
71+
Returns a ``(Path, fd)`` tuple. Uses ``O_CREAT | O_EXCL`` so two saves
72+
racing within the same second pick distinct filenames instead of one
73+
overwriting the other.
74+
"""
6075
now = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
6176
base_name = f"trajectory_{now}"
6277

63-
candidate = trajectories_dir / f"{base_name}.json"
64-
if not candidate.exists():
65-
return candidate
66-
67-
# Handle collisions with _1, _2, etc.
68-
for suffix in range(1, 1000):
69-
candidate = trajectories_dir / f"{base_name}_{suffix}.json"
70-
if not candidate.exists():
71-
return candidate
78+
for suffix in range(0, 1000):
79+
name = f"{base_name}.json" if suffix == 0 else f"{base_name}_{suffix}.json"
80+
candidate = trajectories_dir / name
81+
try:
82+
fd = os.open(str(candidate), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
83+
return candidate, fd
84+
except FileExistsError:
85+
continue
7286

7387
raise RuntimeError(f"Too many trajectory files for timestamp {now}")
7488

@@ -99,21 +113,20 @@ def main():
99113
sys.exit(1)
100114

101115
log(f"Received trajectory with keys: {list(trajectory.keys())}")
102-
messages = trajectory.get("messages", [])
103-
if not messages:
104-
log("No messages in trajectory")
105-
print("No messages in trajectory.", file=sys.stderr)
116+
messages = trajectory.get("messages")
117+
if not isinstance(messages, list) or not messages:
118+
log(f"Invalid messages in trajectory: {type(messages).__name__}")
119+
print("Error: `messages` must be a non-empty list.", file=sys.stderr)
106120
sys.exit(1)
107121

108122
log(f"Trajectory has {len(messages)} messages")
109123

110-
# Determine output path
124+
# Atomically claim a unique output path (handles same-second races)
111125
trajectories_dir = get_trajectories_dir()
112-
output_path = generate_filename(trajectories_dir)
126+
output_path, fd = open_trajectory_file(trajectories_dir)
113127

114-
# Write formatted JSON with owner-only permissions
128+
# Write formatted JSON via the already-opened owner-only fd
115129
try:
116-
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
117130
with os.fdopen(fd, "w", encoding="utf-8") as f:
118131
json.dump(trajectory, f, indent=2, default=str)
119132
f.write("\n")

platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,17 @@ def main():
9595
clone_cmd = ["git", "clone", args.remote, str(dest), "--branch", args.branch]
9696
if args.scope == "read":
9797
clone_cmd += ["--depth", "1"]
98-
subprocess.run(clone_cmd, check=True)
98+
try:
99+
subprocess.run(clone_cmd, check=True, timeout=60, capture_output=True, text=True)
100+
except subprocess.TimeoutExpired:
101+
shutil.rmtree(dest, ignore_errors=True)
102+
print("Error: git clone timed out", file=sys.stderr)
103+
sys.exit(1)
104+
except subprocess.CalledProcessError as exc:
105+
shutil.rmtree(dest, ignore_errors=True)
106+
detail = (exc.stderr or exc.stdout or "").strip() or f"exit {exc.returncode}"
107+
print(f"Error: git clone failed: {detail}", file=sys.stderr)
108+
sys.exit(1)
99109

100110
repos.append(
101111
{

platform-integrations/claude/plugins/evolve-lite/skills/sync/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ python3 ${CLAUDE_PLUGIN_ROOT}/skills/sync/scripts/sync.py
2525
Display the script's stdout verbatim to the user. Example outputs:
2626

2727
> "Synced 2 repo(s): memory [write] (+2 added, 0 updated, 0 removed), bob [read] (+0 added, 1 updated, 0 removed)"
28-
28+
>
2929
> "No subscriptions configured. Add one with /evolve-lite:subscribe to start syncing shared guidelines."
3030
3131
Under `--quiet`, the script exits silently when there's nothing to report.

platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,8 @@ def _git(repo_path, *args, timeout=_GIT_TIMEOUT):
4242

4343

4444
def _head_hash(repo_path):
45-
result = subprocess.run(
46-
["git", "-c", f"safe.directory={repo_path}", "-C", str(repo_path), "rev-parse", "HEAD"],
47-
capture_output=True,
48-
text=True,
49-
timeout=_GIT_TIMEOUT,
50-
)
51-
if result.returncode != 0:
45+
result = _git(repo_path, "rev-parse", "HEAD")
46+
if result is None or result.returncode != 0:
5247
return None
5348
return result.stdout.strip()
5449

@@ -138,7 +133,14 @@ def main():
138133
project_root = str(evolve_dir.parent) if "EVOLVE_DIR" in os.environ else "."
139134

140135
if args.config:
141-
cfg = load_config(filepath=args.config)
136+
config_path = Path(args.config).resolve()
137+
if config_path.name != "evolve.config.yaml":
138+
print(
139+
f"Error: --config must point to an evolve.config.yaml file, got: {config_path}",
140+
file=sys.stderr,
141+
)
142+
sys.exit(1)
143+
cfg = load_config(project_root=str(config_path.parent))
142144
else:
143145
cfg = load_config(project_root)
144146

0 commit comments

Comments
 (0)