claude-code-arcane 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +8 -8
- package/dist/cli.js +15 -0
- package/docs/SKILLS-CATALOG.md +4 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/templates/skill-test-spec.md +0 -1
- package/output-styles/arcane.md +51 -4
- package/package.json +1 -1
- package/profiles/backend-dotnet.yaml +2 -0
- package/profiles/backend-go.yaml +2 -0
- package/profiles/backend-nestjs.yaml +2 -0
- package/profiles/backend-nextjs.yaml +2 -0
- package/profiles/backend-ts.yaml +2 -0
- package/profiles/backend-typescript.yaml +2 -0
- package/profiles/blender.yaml +1 -0
- package/profiles/core.yaml +3 -0
- package/profiles/infra.yaml +2 -0
- package/profiles/meshy.yaml +1 -0
- package/rules/agent-delegation.md +8 -0
- package/rules/backend-code.md +7 -0
- package/rules/blender-files.md +72 -0
- package/rules/blender-pipeline.md +1 -63
- package/rules/dotnet-code.md +6 -0
- package/rules/frontend-code.md +25 -0
- package/rules/gamedev/ai-code.md +1 -0
- package/rules/gamedev/audio-code.md +1 -0
- package/rules/gamedev/engine-code.md +7 -0
- package/rules/gamedev/gameplay-code.md +8 -0
- package/rules/gamedev/narrative.md +1 -1
- package/rules/gamedev/network-code.md +1 -0
- package/rules/gamedev/shader-code.md +3 -0
- package/rules/gamedev/ui-code.md +7 -0
- package/rules/gamedev/unreal-code.md +6 -0
- package/rules/git-worktrees.md +22 -0
- package/rules/implementation-workflow.md +9 -0
- package/rules/infra-code.md +6 -0
- package/rules/meshy-assets.md +9 -78
- package/rules/meshy-files.md +83 -0
- package/rules/migration-code.md +6 -0
- package/rules/nestjs-code.md +6 -0
- package/rules/verification.md +13 -0
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/references/communication-standard.md +2 -2
- package/skills/ai-security/SKILL.md +4 -0
- package/skills/audit-dev/SKILL.md +1 -1
- package/skills/blender-animation/SKILL.md +1 -1
- package/skills/blender-export/SKILL.md +1 -1
- package/skills/blender-materials/SKILL.md +1 -1
- package/skills/blender-modeling/SKILL.md +1 -1
- package/skills/check/SKILL.md +1 -1
- package/skills/chief-of-staff/SKILL.md +1 -1
- package/skills/cloud-security/SKILL.md +4 -0
- package/skills/isms-audit-expert/SKILL.md +2 -0
- package/skills/meshy-derive/SKILL.md +1 -1
- package/skills/meshy-generate/SKILL.md +1 -1
- package/skills/meshy-setup/SKILL.md +1 -1
- package/skills/owasp-top10-check/SKILL.md +1 -1
- package/skills/qms-audit-expert/SKILL.md +2 -0
- package/skills/security-pen-testing/SKILL.md +1 -1
- package/skills/seo-audit/SKILL.md +1 -1
- package/skills/skill-auditor/SKILL.md +0 -1
- package/skills/skill-improve/SKILL.md +6 -7
- package/skills/skill-test/SKILL.md +3 -3
- package/skills/skill-test/references/static-checks.md +13 -8
- package/skills/standup-report/SKILL.md +1 -1
- package/skills/vn-gdd/SKILL.md +2 -0
- package/skills/web-security/SKILL.md +4 -0
- package/skills/worktree-isolation/README.md +6 -6
- package/skills/worktree-isolation/SKILL.md +5 -2
- package/skills/worktree-isolation-setup/README.md +7 -11
- package/skills/worktree-isolation-setup/SKILL.md +15 -6
- package/skills/worktree-isolation-setup/scripts/apply.py +0 -178
- package/skills/worktree-isolation-setup/scripts/install_hook.py +0 -118
- package/skills/worktree-isolation-setup/scripts/lib.py +0 -371
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
"""Helpers for worktree-isolation: parse compose files, allocate deterministic
|
|
2
|
-
host ports per worktree, render override yaml + .env.worktree.
|
|
3
|
-
|
|
4
|
-
Pure-Python except for PyYAML. Both apply.py and prepare.py import from here.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import hashlib
|
|
10
|
-
import re
|
|
11
|
-
from dataclasses import dataclass, replace
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Iterable
|
|
14
|
-
|
|
15
|
-
import yaml
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
PORT_RANGE_START = 20000
|
|
19
|
-
PORT_RANGE_SIZE = 10000 # → 20000-29999
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass(frozen=True)
|
|
23
|
-
class PortMapping:
|
|
24
|
-
service: str
|
|
25
|
-
host_ip: str | None
|
|
26
|
-
original_host_port: int
|
|
27
|
-
new_host_port: int
|
|
28
|
-
container_port: int
|
|
29
|
-
protocol: str # "tcp" or "udp"
|
|
30
|
-
var_name: str | None # if compose used ${VAR:-default}, the captured VAR
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def worktree_id(cwd: Path) -> str:
|
|
34
|
-
"""8-char stable hash of the absolute path."""
|
|
35
|
-
return hashlib.sha256(str(cwd.resolve()).encode()).hexdigest()[:8]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def allocate_port(worktree: str, service: str, original_host: int, container: int) -> int:
|
|
39
|
-
seed = f"{worktree}:{service}:{original_host}:{container}".encode()
|
|
40
|
-
h = hashlib.sha256(seed).digest()
|
|
41
|
-
offset = int.from_bytes(h[:4], "big") % PORT_RANGE_SIZE
|
|
42
|
-
return PORT_RANGE_START + offset
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
_VAR_PATTERN = re.compile(r"^\$\{(\w+)(?::-([^}]*))?\}$")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _split_port_spec(s: str) -> list[str]:
|
|
49
|
-
"""Split a docker-compose short-form port string on ':' while respecting ${...}.
|
|
50
|
-
|
|
51
|
-
Examples:
|
|
52
|
-
'54399:5432' → ['54399', '5432']
|
|
53
|
-
'127.0.0.1:54399:5432' → ['127.0.0.1', '54399', '5432']
|
|
54
|
-
'${TEST_DB_PORT:-5433}:5432' → ['${TEST_DB_PORT:-5433}', '5432']
|
|
55
|
-
"""
|
|
56
|
-
parts: list[str] = []
|
|
57
|
-
buf: list[str] = []
|
|
58
|
-
depth = 0
|
|
59
|
-
i = 0
|
|
60
|
-
while i < len(s):
|
|
61
|
-
c = s[i]
|
|
62
|
-
if c == "$" and i + 1 < len(s) and s[i + 1] == "{":
|
|
63
|
-
depth += 1
|
|
64
|
-
buf.append(c)
|
|
65
|
-
buf.append(s[i + 1])
|
|
66
|
-
i += 2
|
|
67
|
-
continue
|
|
68
|
-
if c == "}" and depth > 0:
|
|
69
|
-
depth -= 1
|
|
70
|
-
buf.append(c)
|
|
71
|
-
i += 1
|
|
72
|
-
continue
|
|
73
|
-
if c == ":" and depth == 0:
|
|
74
|
-
parts.append("".join(buf))
|
|
75
|
-
buf = []
|
|
76
|
-
i += 1
|
|
77
|
-
continue
|
|
78
|
-
buf.append(c)
|
|
79
|
-
i += 1
|
|
80
|
-
parts.append("".join(buf))
|
|
81
|
-
return parts
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _parse_host_port_token(token: str) -> tuple[int | None, str | None]:
|
|
85
|
-
"""Return (default_port, var_name) for a host-port token.
|
|
86
|
-
Token may be a bare number ("54399") or a substitution ("${PORT:-54399}")."""
|
|
87
|
-
m = _VAR_PATTERN.match(token)
|
|
88
|
-
if m:
|
|
89
|
-
var = m.group(1)
|
|
90
|
-
default = m.group(2)
|
|
91
|
-
if default and default.isdigit():
|
|
92
|
-
return int(default), var
|
|
93
|
-
return None, var
|
|
94
|
-
if token.isdigit():
|
|
95
|
-
return int(token), None
|
|
96
|
-
return None, None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def parse_port_entry(entry, service: str) -> PortMapping | None:
|
|
100
|
-
"""Normalize one ports: list entry into a PortMapping (new_host_port=0 placeholder).
|
|
101
|
-
|
|
102
|
-
Handles short-form strings, bare ints, and long-form dicts.
|
|
103
|
-
"""
|
|
104
|
-
if isinstance(entry, dict):
|
|
105
|
-
host_port_raw = entry.get("published")
|
|
106
|
-
container_port = int(entry["target"])
|
|
107
|
-
host_port: int | None
|
|
108
|
-
var_name: str | None = None
|
|
109
|
-
if host_port_raw is None:
|
|
110
|
-
host_port = container_port
|
|
111
|
-
elif isinstance(host_port_raw, int):
|
|
112
|
-
host_port = host_port_raw
|
|
113
|
-
else:
|
|
114
|
-
host_port, var_name = _parse_host_port_token(str(host_port_raw))
|
|
115
|
-
if host_port is None:
|
|
116
|
-
host_port = container_port
|
|
117
|
-
return PortMapping(
|
|
118
|
-
service=service,
|
|
119
|
-
host_ip=entry.get("host_ip"),
|
|
120
|
-
original_host_port=host_port,
|
|
121
|
-
new_host_port=0,
|
|
122
|
-
container_port=container_port,
|
|
123
|
-
protocol=entry.get("protocol", "tcp"),
|
|
124
|
-
var_name=var_name,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
s = str(entry).strip()
|
|
128
|
-
proto = "tcp"
|
|
129
|
-
if "/" in s:
|
|
130
|
-
s, proto = s.rsplit("/", 1)
|
|
131
|
-
|
|
132
|
-
parts = _split_port_spec(s)
|
|
133
|
-
host_ip: str | None = None
|
|
134
|
-
var_name: str | None = None
|
|
135
|
-
|
|
136
|
-
if len(parts) == 1:
|
|
137
|
-
# Bare port — Docker treats as container port; we still bind a deterministic host port.
|
|
138
|
-
host_port_def, _ = _parse_host_port_token(parts[0])
|
|
139
|
-
container_port = host_port_def or 0
|
|
140
|
-
host_port: int | None = container_port
|
|
141
|
-
elif len(parts) == 2:
|
|
142
|
-
host_token, container_token = parts
|
|
143
|
-
host_port, var_name = _parse_host_port_token(host_token)
|
|
144
|
-
cp_def, _ = _parse_host_port_token(container_token)
|
|
145
|
-
container_port = cp_def or 0
|
|
146
|
-
if host_port is None:
|
|
147
|
-
host_port = container_port
|
|
148
|
-
elif len(parts) == 3:
|
|
149
|
-
host_ip, host_token, container_token = parts
|
|
150
|
-
host_port, var_name = _parse_host_port_token(host_token)
|
|
151
|
-
cp_def, _ = _parse_host_port_token(container_token)
|
|
152
|
-
container_port = cp_def or 0
|
|
153
|
-
if host_port is None:
|
|
154
|
-
host_port = container_port
|
|
155
|
-
else:
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
return PortMapping(
|
|
159
|
-
service=service,
|
|
160
|
-
host_ip=host_ip,
|
|
161
|
-
original_host_port=host_port,
|
|
162
|
-
new_host_port=0,
|
|
163
|
-
container_port=container_port,
|
|
164
|
-
protocol=proto,
|
|
165
|
-
var_name=var_name,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def find_compose_files(cwd: Path) -> list[Path]:
|
|
170
|
-
"""All docker-compose*.yml/yaml in cwd, excluding worktree-generated overrides."""
|
|
171
|
-
out: list[Path] = []
|
|
172
|
-
for ext in ("yml", "yaml"):
|
|
173
|
-
out.extend(cwd.glob(f"docker-compose*.{ext}"))
|
|
174
|
-
out.extend(cwd.glob(f"compose*.{ext}"))
|
|
175
|
-
return sorted({p for p in out if not p.name.endswith((".worktree.yml", ".worktree.yaml"))})
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def collect_port_mappings(compose_path: Path, worktree: str) -> list[PortMapping]:
|
|
179
|
-
with open(compose_path) as f:
|
|
180
|
-
data = yaml.safe_load(f) or {}
|
|
181
|
-
services = data.get("services") or {}
|
|
182
|
-
out: list[PortMapping] = []
|
|
183
|
-
for service_name, service_def in services.items():
|
|
184
|
-
ports = (service_def or {}).get("ports") or []
|
|
185
|
-
for entry in ports:
|
|
186
|
-
pm = parse_port_entry(entry, service_name)
|
|
187
|
-
if pm is None or pm.original_host_port is None:
|
|
188
|
-
continue
|
|
189
|
-
new_port = allocate_port(worktree, service_name, pm.original_host_port, pm.container_port)
|
|
190
|
-
out.append(replace(pm, new_host_port=new_port))
|
|
191
|
-
return out
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def collect_container_names(compose_path: Path) -> dict[str, str]:
|
|
195
|
-
"""service → container_name (only for services that have a hardcoded one)."""
|
|
196
|
-
with open(compose_path) as f:
|
|
197
|
-
data = yaml.safe_load(f) or {}
|
|
198
|
-
services = data.get("services") or {}
|
|
199
|
-
out: dict[str, str] = {}
|
|
200
|
-
for service_name, service_def in services.items():
|
|
201
|
-
cn = (service_def or {}).get("container_name")
|
|
202
|
-
if isinstance(cn, str) and cn:
|
|
203
|
-
out[service_name] = cn
|
|
204
|
-
return out
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
class _OverrideList(list):
|
|
208
|
-
"""Wrapper so we can emit YAML with the `!override` tag — Compose then REPLACES the
|
|
209
|
-
base list instead of appending (merge default for sequences). Compose 2.24+."""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _represent_override_list(dumper: yaml.SafeDumper, data: _OverrideList):
|
|
213
|
-
return dumper.represent_sequence("!override", list(data))
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
yaml.SafeDumper.add_representer(_OverrideList, _represent_override_list)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def render_override_yaml(
|
|
220
|
-
mappings: list[PortMapping],
|
|
221
|
-
container_names: dict[str, str],
|
|
222
|
-
project_name: str,
|
|
223
|
-
worktree: str,
|
|
224
|
-
) -> dict:
|
|
225
|
-
"""Build the override-yaml dict. Top-level `name:` sets COMPOSE_PROJECT_NAME for this stack.
|
|
226
|
-
Per-service: rewrite ports (with !override to replace base) + suffix container_name."""
|
|
227
|
-
by_service: dict[str, list[PortMapping]] = {}
|
|
228
|
-
for m in mappings:
|
|
229
|
-
by_service.setdefault(m.service, []).append(m)
|
|
230
|
-
|
|
231
|
-
services: dict[str, dict] = {}
|
|
232
|
-
all_services = set(by_service) | set(container_names)
|
|
233
|
-
for service in all_services:
|
|
234
|
-
svc: dict = {}
|
|
235
|
-
if service in by_service:
|
|
236
|
-
ports: list[str] = []
|
|
237
|
-
for m in by_service[service]:
|
|
238
|
-
spec = (
|
|
239
|
-
f"{m.host_ip}:{m.new_host_port}:{m.container_port}"
|
|
240
|
-
if m.host_ip
|
|
241
|
-
else f"{m.new_host_port}:{m.container_port}"
|
|
242
|
-
)
|
|
243
|
-
if m.protocol != "tcp":
|
|
244
|
-
spec = f"{spec}/{m.protocol}"
|
|
245
|
-
ports.append(spec)
|
|
246
|
-
svc["ports"] = _OverrideList(ports)
|
|
247
|
-
if service in container_names:
|
|
248
|
-
svc["container_name"] = f"{container_names[service]}-{worktree}"
|
|
249
|
-
services[service] = svc
|
|
250
|
-
|
|
251
|
-
return {"name": project_name, "services": services}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def env_var_name(mapping: PortMapping) -> str:
|
|
255
|
-
"""Generate a stable upper-snake-case env var name for a port mapping.
|
|
256
|
-
|
|
257
|
-
Format: <SERVICE>_<CONTAINER_PORT>_HOST_PORT.
|
|
258
|
-
Example: service "postgres-integration", container 5432 → POSTGRES_INTEGRATION_5432_HOST_PORT.
|
|
259
|
-
"""
|
|
260
|
-
safe = re.sub(r"[^A-Za-z0-9]+", "_", mapping.service).strip("_").upper()
|
|
261
|
-
return f"{safe}_{mapping.container_port}_HOST_PORT"
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def render_env_worktree(
|
|
265
|
-
project_name: str,
|
|
266
|
-
worktree: str,
|
|
267
|
-
compose_chain: list[tuple[Path, Path | None]],
|
|
268
|
-
mappings: list[PortMapping],
|
|
269
|
-
) -> str:
|
|
270
|
-
"""Render the .env.worktree contents.
|
|
271
|
-
|
|
272
|
-
compose_chain: [(base_compose_path, override_path_or_None)] in order.
|
|
273
|
-
"""
|
|
274
|
-
lines: list[str] = [
|
|
275
|
-
"# Auto-generated by ~/.claude/scripts/worktree-isolation/apply.py",
|
|
276
|
-
"# DO NOT COMMIT. Source before running docker compose:",
|
|
277
|
-
"# set -a; source .env.worktree; set +a",
|
|
278
|
-
f"WORKTREE_ID={worktree}",
|
|
279
|
-
f"COMPOSE_PROJECT_NAME={project_name}",
|
|
280
|
-
]
|
|
281
|
-
|
|
282
|
-
chain: list[str] = []
|
|
283
|
-
for base, override in compose_chain:
|
|
284
|
-
chain.append(base.name)
|
|
285
|
-
if override is not None:
|
|
286
|
-
chain.append(override.name)
|
|
287
|
-
if chain:
|
|
288
|
-
# Compose reads COMPOSE_FILE colon-separated on Unix, semicolon on Windows.
|
|
289
|
-
lines.append(f"COMPOSE_FILE={':'.join(chain)}")
|
|
290
|
-
|
|
291
|
-
seen_vars: set[str] = set()
|
|
292
|
-
for m in mappings:
|
|
293
|
-
var = env_var_name(m)
|
|
294
|
-
if var not in seen_vars:
|
|
295
|
-
lines.append(f"{var}={m.new_host_port}")
|
|
296
|
-
seen_vars.add(var)
|
|
297
|
-
# If the compose used ${VAR:-default}, also export VAR so substitution picks up the new port.
|
|
298
|
-
if m.var_name and m.var_name not in seen_vars:
|
|
299
|
-
lines.append(f"{m.var_name}={m.new_host_port}")
|
|
300
|
-
seen_vars.add(m.var_name)
|
|
301
|
-
|
|
302
|
-
return "\n".join(lines) + "\n"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def find_git_dir(cwd: Path) -> Path | None:
|
|
306
|
-
"""Locate the per-checkout git dir.
|
|
307
|
-
|
|
308
|
-
For regular checkouts: <cwd>/.git is a directory.
|
|
309
|
-
For git worktrees: <cwd>/.git is a file like 'gitdir: /abs/path/to/main/.git/worktrees/<name>'.
|
|
310
|
-
Returns the per-checkout dir (where info/exclude lives), or None if not in a repo.
|
|
311
|
-
"""
|
|
312
|
-
git_path = cwd / ".git"
|
|
313
|
-
if git_path.is_dir():
|
|
314
|
-
return git_path
|
|
315
|
-
if git_path.is_file():
|
|
316
|
-
try:
|
|
317
|
-
content = git_path.read_text().strip()
|
|
318
|
-
except OSError:
|
|
319
|
-
return None
|
|
320
|
-
if content.startswith("gitdir:"):
|
|
321
|
-
target = Path(content.split(":", 1)[1].strip())
|
|
322
|
-
if not target.is_absolute():
|
|
323
|
-
target = (cwd / target).resolve()
|
|
324
|
-
return target if target.exists() else None
|
|
325
|
-
# walk up
|
|
326
|
-
for parent in cwd.parents:
|
|
327
|
-
candidate = parent / ".git"
|
|
328
|
-
if candidate.is_dir():
|
|
329
|
-
return candidate
|
|
330
|
-
return None
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def ensure_git_exclude(cwd: Path, patterns: Iterable[str]) -> bool:
|
|
334
|
-
"""Append patterns to <git_dir>/info/exclude (per-worktree, never committed).
|
|
335
|
-
Returns True if any pattern was added."""
|
|
336
|
-
git_dir = find_git_dir(cwd)
|
|
337
|
-
if git_dir is None:
|
|
338
|
-
return False
|
|
339
|
-
info_dir = git_dir / "info"
|
|
340
|
-
info_dir.mkdir(parents=True, exist_ok=True)
|
|
341
|
-
exclude_file = info_dir / "exclude"
|
|
342
|
-
existing_lines: set[str] = set()
|
|
343
|
-
if exclude_file.exists():
|
|
344
|
-
existing_lines = {l.strip() for l in exclude_file.read_text().splitlines() if l.strip()}
|
|
345
|
-
to_add = [p for p in patterns if p not in existing_lines]
|
|
346
|
-
if not to_add:
|
|
347
|
-
return False
|
|
348
|
-
with open(exclude_file, "a") as f:
|
|
349
|
-
if exclude_file.stat().st_size and not exclude_file.read_text().endswith("\n"):
|
|
350
|
-
f.write("\n")
|
|
351
|
-
f.write("# Worktree isolation (auto-added)\n")
|
|
352
|
-
for p in to_add:
|
|
353
|
-
f.write(f"{p}\n")
|
|
354
|
-
return True
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def overrides_up_to_date(
|
|
358
|
-
base: Path, override: Path, env_file: Path
|
|
359
|
-
) -> bool:
|
|
360
|
-
"""True if override + env file exist and are newer than base. Skip regen."""
|
|
361
|
-
if not override.exists() or not env_file.exists():
|
|
362
|
-
return False
|
|
363
|
-
base_mtime = base.stat().st_mtime
|
|
364
|
-
return override.stat().st_mtime >= base_mtime and env_file.stat().st_mtime >= base_mtime
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def override_path_for(base: Path) -> Path:
|
|
368
|
-
"""docker-compose.processing.yml → docker-compose.processing.worktree.yml.
|
|
369
|
-
docker-compose.yml → docker-compose.worktree.yml."""
|
|
370
|
-
stem = base.name[: -len(base.suffix)]
|
|
371
|
-
return base.parent / f"{stem}.worktree{base.suffix}"
|