claude-code-arcane 2.9.9 → 2.11.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +45 -43
  3. package/dist/cli.js +79 -33
  4. package/docs/SKILLS-CATALOG.md +4 -2
  5. package/docs/USER-GUIDE.md +24 -22
  6. package/docs/templates/skill-test-spec.md +0 -1
  7. package/package.json +1 -1
  8. package/profiles/ai.yaml +3 -0
  9. package/profiles/android-native.yaml +2 -0
  10. package/profiles/backend-dotnet.yaml +2 -0
  11. package/profiles/backend-go.yaml +2 -8
  12. package/profiles/backend-nestjs.yaml +2 -0
  13. package/profiles/backend-nextjs.yaml +2 -0
  14. package/profiles/backend-ts.yaml +4 -8
  15. package/profiles/backend-typescript.yaml +53 -0
  16. package/profiles/blender.yaml +1 -0
  17. package/profiles/flutter.yaml +2 -0
  18. package/profiles/frontend-react-vite.yaml +39 -0
  19. package/profiles/frontend.yaml +2 -0
  20. package/profiles/infra.yaml +2 -0
  21. package/profiles/integrations.yaml +6 -1
  22. package/profiles/ios-native.yaml +2 -0
  23. package/profiles/meshy.yaml +1 -0
  24. package/profiles/mobile-android-kotlin.yaml +36 -0
  25. package/profiles/mobile-flutter.yaml +37 -0
  26. package/profiles/mobile-ios-swift.yaml +37 -0
  27. package/profiles/mobile-react-native.yaml +39 -0
  28. package/profiles/mobile.yaml +2 -0
  29. package/rules/blender-files.md +72 -0
  30. package/rules/blender-pipeline.md +1 -63
  31. package/rules/gamedev/narrative.md +1 -1
  32. package/rules/meshy-assets.md +9 -78
  33. package/rules/meshy-files.md +83 -0
  34. package/skills/agent-protocol/SKILL.md +1 -1
  35. package/skills/agent-protocol/references/communication-standard.md +2 -2
  36. package/skills/ai-security/SKILL.md +4 -0
  37. package/skills/arcane-add/SKILL.md +1 -1
  38. package/skills/arcane-list/SKILL.md +3 -3
  39. package/skills/arcane-status/SKILL.md +1 -1
  40. package/skills/audit-dev/SKILL.md +1 -1
  41. package/skills/blender-animation/SKILL.md +1 -1
  42. package/skills/blender-export/SKILL.md +1 -1
  43. package/skills/blender-materials/SKILL.md +1 -1
  44. package/skills/blender-modeling/SKILL.md +1 -1
  45. package/skills/check/SKILL.md +1 -1
  46. package/skills/chief-of-staff/SKILL.md +1 -1
  47. package/skills/cloud-security/SKILL.md +4 -0
  48. package/skills/freelance-kickoff/SKILL.md +1 -1
  49. package/skills/isms-audit-expert/SKILL.md +2 -0
  50. package/skills/meshy-derive/SKILL.md +1 -1
  51. package/skills/meshy-generate/SKILL.md +1 -1
  52. package/skills/meshy-setup/SKILL.md +1 -1
  53. package/skills/owasp-top10-check/SKILL.md +1 -1
  54. package/skills/qms-audit-expert/SKILL.md +2 -0
  55. package/skills/security-pen-testing/SKILL.md +1 -1
  56. package/skills/seo-audit/SKILL.md +1 -1
  57. package/skills/skill-auditor/SKILL.md +0 -1
  58. package/skills/skill-improve/SKILL.md +6 -7
  59. package/skills/skill-test/SKILL.md +3 -3
  60. package/skills/skill-test/references/static-checks.md +13 -8
  61. package/skills/standup-report/SKILL.md +1 -1
  62. package/skills/vn-gdd/SKILL.md +2 -0
  63. package/skills/web-security/SKILL.md +4 -0
  64. package/skills/worktree-isolation/README.md +6 -6
  65. package/skills/worktree-isolation/SKILL.md +5 -2
  66. package/skills/worktree-isolation-setup/README.md +7 -11
  67. package/skills/worktree-isolation-setup/SKILL.md +15 -6
  68. package/skills/worktree-isolation-setup/scripts/apply.py +0 -178
  69. package/skills/worktree-isolation-setup/scripts/install_hook.py +0 -118
  70. 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}"