claude-code-arcane 2.11.0 → 2.13.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## What is Arcane?
4
4
 
5
- Arcane is a configuration harness for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It provides a curated library of **437 skills**, **115 agents**, **17 hooks**, and **33 rules** that you selectively deploy into any project. Instead of shipping everything to every project (wasting tokens and degrading performance), you pick a **profile** that matches your stack and only the relevant tools get installed.
5
+ Arcane is a configuration harness for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It provides a curated library of **437 skills**, **115 agents**, **16 hooks**, and **36 rules** that you selectively deploy into any project. Instead of shipping everything to every project (wasting tokens and degrading performance), you pick a **profile** that matches your stack and only the relevant tools get installed.
6
6
 
7
7
  ## Table of Contents
8
8
 
@@ -238,10 +238,10 @@ Every installation automatically loads `core.yaml` first:
238
238
  | Category | Contents |
239
239
  |----------|----------|
240
240
  | **Skills (21)** | commit, create-pr, changelog, check, code-review, context-prime, help, start, fix-issue, hotfix, brainstorm, scope-check, reverse-document, skill-improve, skill-test, tech-debt, arcane-status, arcane-list, arcane-add, arcane-remove, arcane-clean |
241
- | **Rules (3)** | data-files, prototype-code, test-standards |
242
- | **Hooks (15)** | session-start, detect-division, detect-gaps, validate-commit, validate-push, validate-secrets, validate-assets, validate-skill-change, notify, pre-compact, post-compact, session-stop, log-agent, log-agent-stop |
241
+ | **Rules (8)** | data-files, prototype-code, test-standards, implementation-workflow, code-comments, verification, agent-delegation, git-worktrees |
242
+ | **Hooks (15)** | session-start, detect-division, detect-unreal, detect-gaps, check-update, guard, validate-commit, validate-assets, validate-skill-change, notify, pre-compact, post-compact, session-stop, log-agent, log-agent-stop |
243
243
  | **Agents (1 dir)** | quality |
244
- | **Permissions** | 9 allow (git, ls, gh, curl) + 14 deny (rm -rf, force push, sudo, .env, etc.) |
244
+ | **Permissions** | 15 allow (git, ls, gh pr/issue/run and read-only gh, curl) + 13 deny (force push, reset --hard, clean -f, sudo, .env, etc.) |
245
245
 
246
246
  ---
247
247
 
@@ -122,11 +122,16 @@ Los eventos los define `src/installer.ts` al generar `settings.json`, no los pro
122
122
  | Evento | Hooks |
123
123
  |--------|-------|
124
124
  | SessionStart | `session-start.sh` → `detect-division.sh` → `detect-unreal.sh` → `detect-gaps.sh` → `check-update.sh` |
125
- | PreToolUse (`Bash`) | `validate-commit.sh`, `validate-push.sh`, `validate-secrets.sh` |
125
+ | PreToolUse (`Bash`) | `guard.sh` → `validate-commit.sh` |
126
+ | PreToolUse (`Write\|Edit\|MultiEdit\|NotebookEdit`) | `guard.sh` |
126
127
  | PostToolUse (`Write\|Edit`) | `validate-assets.sh`, `validate-skill-change.sh` |
127
128
  | Notification | `notify.sh` |
128
129
  | PreCompact / PostCompact | `pre-compact.sh` / `post-compact.sh` |
129
130
  | Stop | `session-stop.sh` |
130
131
  | SubagentStart / SubagentStop | `log-agent.sh` / `log-agent-stop.sh` |
131
132
 
133
+ Cada comando es `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/<script>"`: dentro de un worktree los hooks corren parados en el worktree, que puede no tener `.claude/hooks/` commiteado, y la variable siempre apunta al proyecto donde arrancó la sesión. `arcane update` migra las instalaciones que tenían la ruta relativa.
134
+
135
+ `guard.sh` corre `guard.py` (Python stdlib): deniega lo que el usuario ejecuta a mano (force push, `--no-verify`, `sudo`, escribir `.env`), pregunta antes de lo destructivo (`rm -rf` fuera de carpetas regenerables, borrar datos, una dependencia nueva) y al editar CI, infra, migraciones existentes, manifests o configs de lint. Sin Python no hace nada y quedan solo los permisos estáticos.
136
+
132
137
  La statusline (`statusline.sh`) se configura aparte, solo con el profile `statusline`.
package/hooks/guard.py ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse guard for Bash and file-writing tools.
3
+
4
+ Reads the hook payload on stdin and answers with a permission decision:
5
+ deny - actions the user runs by hand if they really want them (force push,
6
+ bypassing git hooks, sudo, writing .env files ...)
7
+ ask - destructive or supply-chain-sensitive actions (rm -rf outside build
8
+ output, dropping data, a new dependency, editing CI or lint configs ...)
9
+ none - everything else; the normal permission rules decide.
10
+
11
+ The command is parsed quote-aware and split on && || ; | so a chained or
12
+ quoted command cannot hide a match the old grep-based hooks missed. This is a
13
+ guard against mistakes, not a sandbox: a script or an interpreter one-liner
14
+ can still do anything.
15
+
16
+ It replaces validate-push.sh and validate-secrets.sh, whose substring checks
17
+ also fired on text inside arguments. `guard.py --command` only prints the Bash
18
+ command from the payload; validate-commit.sh uses it instead of a regex.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import shlex
26
+ import sys
27
+ from fnmatch import fnmatch
28
+
29
+ DENY, ASK, NONE = "deny", "ask", None
30
+ RANK = {DENY: 2, ASK: 1, NONE: 0}
31
+
32
+ # Build output that a command regenerates; rm -rf on these alone never asks.
33
+ REGENERABLE = {
34
+ "node_modules", "dist", "build", ".next", "out", "coverage",
35
+ "__pycache__", ".pytest_cache", "Library", "Temp", "obj",
36
+ "Binaries", "Intermediate", "DerivedDataCache",
37
+ }
38
+
39
+ SEPARATORS = {"&&", "||", ";", "|", "&", "(", ")", "|&", ";;"}
40
+ WRAPPERS = {"command", "exec", "time", "nohup", "env", "builtin", "nice"}
41
+ DB_CLIENTS = {
42
+ "psql", "mysql", "mariadb", "sqlite3", "sqlcmd", "mongo", "mongosh",
43
+ "redis-cli", "cockroach", "clickhouse-client", "duckdb",
44
+ }
45
+ DATA_LOSS_SQL = [
46
+ (r"\bDROP\s+(TABLE|DATABASE|SCHEMA)\b", "drops a table, database or schema"),
47
+ (r"\bTRUNCATE\b", "truncates a table"),
48
+ (r"\bFLUSH(ALL|DB)\b", "flushes Redis"),
49
+ (r"\.dropDatabase\s*\(", "drops a MongoDB database"),
50
+ ]
51
+ # (programs that run it, pattern over the segment, reason). Scoped to the program so
52
+ # a commit message that merely mentions "terraform destroy" does not match.
53
+ TEXT_RULES = [
54
+ ({"prisma"}, r"\bmigrate\s+reset\b", "resets the database (prisma migrate reset)"),
55
+ ({"supabase"}, r"\bdb\s+reset\b", "resets the database (supabase db reset)"),
56
+ ({"dotnet"}, r"\bef\s+database\s+drop\b", "drops the database (dotnet ef database drop)"),
57
+ ({"php", "rails", "rake", "bundle", "knex", "sequelize", "sequelize-cli"},
58
+ r"\b(migrate|db):(rollback|reset|fresh|undo|drop)\b|\bdb:migrate:undo\b", "rolls back or drops the database"),
59
+ ({"alembic"}, r"\bdowngrade\b", "rolls back migrations (alembic downgrade)"),
60
+ ({"goose", "migrate"}, r"\sdown\b", "rolls back migrations"),
61
+ ({"docker"}, r"\bsystem\s+prune\b", "deletes Docker data (system prune)"),
62
+ ({"docker"}, r"\bvolume\s+(rm|prune)\b", "deletes Docker volumes"),
63
+ ({"docker", "docker-compose"}, r"\bdown\b.*\s(-v|--volumes)\b", "deletes the compose volumes (database data)"),
64
+ ({"terraform", "tofu", "pulumi"}, r"\bdestroy\b", "destroys infrastructure"),
65
+ ({"kubectl"}, r"\bdelete\b", "deletes Kubernetes resources"),
66
+ ({"helm"}, r"\b(uninstall|delete)\b", "uninstalls a Helm release"),
67
+ ({"npm", "pnpm", "yarn"}, r"^\S+\s+(publish|unpublish)\b", "publishes or unpublishes a package"),
68
+ ]
69
+ # Launchers whose first argument is the real program (npx prisma ..., pnpm exec knex ...).
70
+ LAUNCHERS = {"npx", "pnpx", "bunx", "pnpm", "yarn", "npm", "bun", "uv", "uvx", "poetry"}
71
+
72
+ ENV_FILE = re.compile(r"^\.env(\..+)?$")
73
+ READERS = {"cat", "type", "less", "more", "head", "tail", "bat", "nl", "strings", "grep", "rg"}
74
+ ENV_SAFE_SUFFIXES = (".example", ".sample", ".template", ".dist")
75
+
76
+ # Write/Edit targets, matched against the path relative to the project root.
77
+ ASK_ALWAYS = [
78
+ (".github/workflows/*", "edits a CI workflow, which runs with the repo's secrets"),
79
+ ("infra/*", "edits infrastructure code"),
80
+ ("terraform/*", "edits infrastructure code"),
81
+ ("*.tf", "edits infrastructure code"),
82
+ ("*.tfvars", "edits infrastructure variables"),
83
+ ]
84
+ ASK_IF_EXISTS = [
85
+ ("migrations/*", "edits an existing migration; shipped migrations are immutable"),
86
+ ("*/migrations/*", "edits an existing migration; shipped migrations are immutable"),
87
+ ("alembic/versions/*", "edits an existing migration; shipped migrations are immutable"),
88
+ ("db/migrate/*", "edits an existing migration; shipped migrations are immutable"),
89
+ ]
90
+ MANIFESTS = {
91
+ "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb",
92
+ "requirements.txt", "pyproject.toml", "poetry.lock", "uv.lock", "Pipfile", "Pipfile.lock",
93
+ "go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "Gemfile", "Gemfile.lock",
94
+ "composer.json", "composer.lock", "packages.lock.json",
95
+ }
96
+ MANIFEST_GLOBS = ["requirements*.txt", "*.csproj", "Directory.Packages.props", "Packages/manifest.json"]
97
+ CHECK_CONFIGS = [
98
+ ".eslintrc*", "eslint.config.*", ".prettierrc*", "prettier.config.*", "tsconfig*.json",
99
+ "biome.json", "biome.jsonc", ".golangci.y*ml", ".golangci.toml", "ruff.toml", ".ruff.toml",
100
+ ".flake8", ".stylelintrc*", "stylelint.config.*", ".editorconfig",
101
+ ]
102
+
103
+
104
+ # --- command parsing -------------------------------------------------------
105
+
106
+ def strip_heredoc_bodies(command: str) -> str:
107
+ """Drop heredoc bodies: their text is data (a commit message), not commands."""
108
+ return re.sub(
109
+ r"<<-?\s*(['\"]?)(\w+)\1([^\n]*)\n.*?\n\s*\2\s*(?=\n|$|\))",
110
+ r"<<\2\3",
111
+ command,
112
+ flags=re.S,
113
+ )
114
+
115
+
116
+ def newlines_to_separators(command: str) -> str:
117
+ out, quote = [], None
118
+ for ch in command:
119
+ if quote:
120
+ if ch == quote:
121
+ quote = None
122
+ elif ch in "'\"":
123
+ quote = ch
124
+ elif ch == "\n":
125
+ ch = ";"
126
+ out.append(ch)
127
+ return "".join(out)
128
+
129
+
130
+ def tokenize(command: str) -> list[str]:
131
+ try:
132
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
133
+ lexer.whitespace_split = True
134
+ return list(lexer)
135
+ except ValueError: # unbalanced quotes: fall back to a plain split
136
+ return re.findall(r"&&|\|\||[;|&()]|[^\s;|&()]+", command)
137
+
138
+
139
+ def segments(command: str) -> list[list[str]]:
140
+ command = newlines_to_separators(strip_heredoc_bodies(command))
141
+ result, current = [], []
142
+ for token in tokenize(command):
143
+ if token in SEPARATORS:
144
+ if current:
145
+ result.append(current)
146
+ current = []
147
+ else:
148
+ current.append(token)
149
+ if current:
150
+ result.append(current)
151
+ return result
152
+
153
+
154
+ def nested_commands(tokens: list[str]) -> list[str]:
155
+ """Command strings run by this segment: bash -c "...", eval "...", $(...)."""
156
+ found = []
157
+ for i, token in enumerate(tokens):
158
+ if os.path.basename(token) in ("bash", "sh", "zsh") and i + 2 < len(tokens) and tokens[i + 1] == "-c":
159
+ found.append(tokens[i + 2])
160
+ if token == "eval" and i + 1 < len(tokens):
161
+ found.append(" ".join(tokens[i + 1:]))
162
+ found.extend(re.findall(r"\$\(([^()]*)\)", token))
163
+ found.extend(re.findall(r"`([^`]*)`", token))
164
+ return found
165
+
166
+
167
+ def program(tokens: list[str]) -> tuple[str, list[str]]:
168
+ """Strip env assignments and wrappers; return (basename, args)."""
169
+ i = 0
170
+ while i < len(tokens):
171
+ token = tokens[i]
172
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token) or os.path.basename(token) in WRAPPERS:
173
+ i += 1
174
+ continue
175
+ break
176
+ if i >= len(tokens):
177
+ return "", []
178
+ name = os.path.basename(tokens[i]).lower()
179
+ if name.endswith(".exe"):
180
+ name = name[:-4]
181
+ return name, tokens[i + 1:]
182
+
183
+
184
+ def effective_program(name: str, args: list[str]) -> str:
185
+ """`npx prisma migrate reset` runs prisma; `php artisan migrate:fresh` runs php."""
186
+ if name not in LAUNCHERS:
187
+ return name
188
+ rest = [a for a in args if not a.startswith("-")]
189
+ if rest[:1] and rest[0] in ("exec", "dlx", "run", "x"):
190
+ rest = rest[1:]
191
+ return os.path.basename(rest[0]).lower() if rest else name
192
+
193
+
194
+ def short_flags(args: list[str]) -> set[str]:
195
+ letters = set()
196
+ for arg in args:
197
+ if arg.startswith("-") and not arg.startswith("--") and len(arg) > 1:
198
+ letters.update(arg[1:])
199
+ return letters
200
+
201
+
202
+ def positional(args: list[str], takes_value: set[str] = frozenset()) -> list[str]:
203
+ out, skip = [], False
204
+ for arg in args:
205
+ if skip:
206
+ skip = False
207
+ continue
208
+ if arg in takes_value:
209
+ skip = True
210
+ continue
211
+ if not arg.startswith("-"):
212
+ out.append(arg)
213
+ return out
214
+
215
+
216
+ # --- rules -----------------------------------------------------------------
217
+
218
+ def check_git(args: list[str]) -> tuple[str | None, str]:
219
+ i = 0
220
+ while i < len(args) and args[i].startswith("-"):
221
+ if args[i] == "-c" and i + 1 < len(args):
222
+ if args[i + 1].lower().startswith("core.hookspath"):
223
+ return DENY, "git -c core.hooksPath= bypasses the repository's git hooks"
224
+ i += 2
225
+ elif args[i] in ("-C", "--git-dir", "--work-tree") and i + 1 < len(args):
226
+ i += 2
227
+ else:
228
+ i += 1
229
+ if i >= len(args):
230
+ return NONE, ""
231
+ sub, rest = args[i], args[i + 1:]
232
+ flags = short_flags(rest)
233
+
234
+ if "--no-verify" in rest and sub in ("commit", "push", "merge", "rebase", "cherry-pick", "am"):
235
+ return DENY, "--no-verify skips the git hooks; fix what they report instead"
236
+ if sub == "commit" and "n" in flags:
237
+ return DENY, "git commit -n skips the git hooks; fix what they report instead"
238
+ if sub == "push":
239
+ forced = any(a == "--force" or a.startswith("--force-") for a in rest) or "f" in flags
240
+ if forced or any(a.startswith("+") for a in rest):
241
+ return DENY, ("Force push is blocked. If it is really needed, ask the user to run it "
242
+ "themselves: `! git push --force-with-lease`")
243
+ if "--delete" in rest or "d" in flags or any(a.startswith(":") and len(a) > 1 for a in rest):
244
+ return ASK, "deletes a remote branch or tag"
245
+ if sub == "reset" and "--hard" in rest:
246
+ return DENY, "git reset --hard throws away uncommitted work; the user runs it by hand"
247
+ if sub == "clean" and ("f" in flags or "--force" in rest):
248
+ return DENY, "git clean -f deletes untracked files; the user runs it by hand"
249
+ whole_tree = any(a in (".", ":/", "*", ":/*") for a in rest)
250
+ if sub == "checkout" and (whole_tree or "f" in flags or "--force" in rest):
251
+ return ASK, "discards uncommitted changes (git checkout)"
252
+ if sub == "restore" and whole_tree and not ("--staged" in rest and "--worktree" not in rest):
253
+ return ASK, "discards uncommitted changes (git restore)"
254
+ if sub == "stash" and rest[:1] and rest[0] in ("drop", "clear"):
255
+ return ASK, "deletes stashed work"
256
+ if sub == "branch" and ("D" in flags or ("--delete" in rest and "--force" in rest)):
257
+ return ASK, "force-deletes a branch without checking it is merged"
258
+ if sub == "tag" and ("d" in flags or "--delete" in rest):
259
+ return ASK, "deletes a tag"
260
+ if sub in ("filter-repo", "filter-branch"):
261
+ return ASK, "rewrites the repository history"
262
+ return NONE, ""
263
+
264
+
265
+ def check_rm(args: list[str]) -> tuple[str | None, str]:
266
+ flags = short_flags(args)
267
+ recursive = bool(flags & {"r", "R"}) or "--recursive" in args
268
+ force = "f" in flags or "--force" in args
269
+ if not (recursive and force):
270
+ return NONE, ""
271
+ targets = positional(args)
272
+ safe = targets and all(
273
+ not t.startswith(("/", "~")) and not re.match(r"^[A-Za-z]:", t)
274
+ and ".." not in re.split(r"[/\\]", t) and "*" not in t
275
+ and os.path.basename(t.rstrip("/\\")) in REGENERABLE
276
+ for t in targets
277
+ )
278
+ if safe:
279
+ return NONE, ""
280
+ return ASK, f"recursively force-deletes {', '.join(targets) or 'files'}"
281
+
282
+
283
+ def check_new_dependency(name: str, args: list[str]) -> tuple[str | None, str]:
284
+ packages: list[str] = []
285
+ if name in ("npm", "pnpm", "yarn", "bun") and args:
286
+ sub, rest = args[0], args[1:]
287
+ if sub in ("add",) or (name in ("npm", "pnpm") and sub in ("install", "i", "in", "ins")):
288
+ packages = positional(rest)
289
+ elif name in ("pip", "pip3") and args[:1] == ["install"]:
290
+ packages = pip_packages(args[1:])
291
+ elif name in ("python", "python3", "py") and args[:3] == ["-m", "pip", "install"]:
292
+ packages = pip_packages(args[3:])
293
+ elif name == "uv" and args[:2] == ["pip", "install"]:
294
+ packages = pip_packages(args[2:])
295
+ elif name in ("uv", "poetry", "cargo") and args[:1] == ["add"]:
296
+ packages = positional(args[1:])
297
+ elif name == "go" and args[:1] == ["get"]:
298
+ packages = [p for p in positional(args[1:]) if not p.startswith(".")]
299
+ elif name == "dotnet" and args[:2] == ["add", "package"]:
300
+ packages = positional(args[2:])
301
+ if not packages:
302
+ return NONE, ""
303
+ return ASK, (f"adds a new dependency: {', '.join(packages)}. Check the exact name before "
304
+ "installing: a one-letter typo can install a malicious package")
305
+
306
+
307
+ def pip_packages(args: list[str]) -> list[str]:
308
+ takes_value = {"-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i",
309
+ "--index-url", "--extra-index-url", "-t", "--target", "--prefix", "-f", "--find-links"}
310
+ return [p for p in positional(args, takes_value)
311
+ if not (p.startswith((".", "/")) or p.endswith((".whl", ".tar.gz", ".zip")))]
312
+
313
+
314
+ def writes_env_file(tokens: list[str]) -> bool:
315
+ targets = []
316
+ for i, token in enumerate(tokens):
317
+ if token in (">", ">>") and i + 1 < len(tokens):
318
+ targets.append(tokens[i + 1])
319
+ name, args = program(tokens)
320
+ if name == "tee":
321
+ targets.extend(positional(args))
322
+ return any(is_env_file(t) for t in targets)
323
+
324
+
325
+ def is_env_file(path: str) -> bool:
326
+ base = os.path.basename(path.replace("\\", "/"))
327
+ return bool(ENV_FILE.match(base)) and not base.endswith(ENV_SAFE_SUFFIXES)
328
+
329
+
330
+ def check_segment(tokens: list[str], depth: int = 0) -> tuple[str | None, str]:
331
+ name, args = program(tokens)
332
+ text = " ".join(tokens)
333
+ decisions = []
334
+
335
+ if name == "sudo":
336
+ decisions.append((DENY, "sudo is blocked; the user runs privileged commands by hand"))
337
+ if name == "chmod" and any(a in ("777", "0777") for a in args):
338
+ decisions.append((DENY, "chmod 777 makes files writable by everyone"))
339
+ if writes_env_file(tokens):
340
+ decisions.append((DENY, "writing a .env file is blocked; the user edits secrets by hand"))
341
+ if name in READERS and any(is_env_file(a) for a in positional(args)):
342
+ decisions.append((DENY, "reading a .env file would put its secrets in the conversation"))
343
+ if name == "git":
344
+ decisions.append(check_git(args))
345
+ if name == "rm":
346
+ decisions.append(check_rm(args))
347
+ if name == "gh":
348
+ if args[:2] in (["repo", "delete"], ["release", "delete"], ["secret", "delete"], ["secret", "remove"]):
349
+ decisions.append((ASK, f"gh {' '.join(args[:2])} deletes data on GitHub"))
350
+ if args[:1] == ["api"] and re.search(r"(-X|--method)[ =]?\s*DELETE\b", text, re.I):
351
+ decisions.append((ASK, "gh api DELETE request"))
352
+ decisions.append(check_new_dependency(name, args))
353
+ if DB_CLIENTS & {os.path.basename(t).lower() for t in tokens}:
354
+ for pattern, reason in DATA_LOSS_SQL:
355
+ if re.search(pattern, text, re.I):
356
+ decisions.append((ASK, reason))
357
+ if re.search(r"\bDELETE\s+FROM\b", text, re.I) and not re.search(r"\bWHERE\b", text, re.I):
358
+ decisions.append((ASK, "DELETE without WHERE removes every row"))
359
+ runs = {name, effective_program(name, args)}
360
+ for programs, pattern, reason in TEXT_RULES:
361
+ if runs & programs and re.search(pattern, text, re.I):
362
+ decisions.append((ASK, reason))
363
+ if depth < 3:
364
+ for inner in nested_commands(tokens):
365
+ decisions.append(check_command(inner, depth + 1))
366
+ return worst(decisions)
367
+
368
+
369
+ def check_command(command: str, depth: int = 0) -> tuple[str | None, str]:
370
+ return worst([check_segment(seg, depth) for seg in segments(command)])
371
+
372
+
373
+ def worst(decisions: list[tuple[str | None, str]]) -> tuple[str | None, str]:
374
+ best = (NONE, "")
375
+ for decision in decisions:
376
+ if RANK[decision[0]] > RANK[best[0]]:
377
+ best = decision
378
+ return best
379
+
380
+
381
+ def check_file(path: str, root: str) -> tuple[str | None, str]:
382
+ if not path:
383
+ return NONE, ""
384
+ absolute = os.path.abspath(os.path.join(root, path))
385
+ rel = os.path.relpath(absolute, root).replace("\\", "/")
386
+ if rel.startswith("../"):
387
+ return NONE, ""
388
+ rel = re.sub(r"^\.claude/worktrees/[^/]+/", "", rel)
389
+ base = os.path.basename(rel)
390
+
391
+ if is_env_file(base):
392
+ return DENY, "writing a .env file is blocked; the user edits secrets by hand"
393
+ for pattern, reason in ASK_ALWAYS:
394
+ if fnmatch(rel, pattern):
395
+ return ASK, reason
396
+ if not os.path.exists(absolute):
397
+ return NONE, ""
398
+ for pattern, reason in ASK_IF_EXISTS:
399
+ if fnmatch(rel, pattern):
400
+ return ASK, reason
401
+ if base in MANIFESTS or any(fnmatch(rel, g) or fnmatch(base, g) for g in MANIFEST_GLOBS):
402
+ return ASK, "edits a dependency manifest or lockfile"
403
+ if any(fnmatch(base, g) for g in CHECK_CONFIGS):
404
+ return ASK, "edits a lint, format or type-check config; fix the code instead of loosening the check"
405
+ return NONE, ""
406
+
407
+
408
+ # --- entry point -----------------------------------------------------------
409
+
410
+ def decide(payload: dict) -> tuple[str | None, str]:
411
+ tool = payload.get("tool_name", "")
412
+ tool_input = payload.get("tool_input") or {}
413
+ if tool == "Bash":
414
+ return check_command(tool_input.get("command", ""))
415
+ if tool in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
416
+ root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
417
+ return check_file(tool_input.get("file_path") or tool_input.get("notebook_path", ""), root)
418
+ return NONE, ""
419
+
420
+
421
+ def main() -> int:
422
+ try:
423
+ payload = json.loads(sys.stdin.read() or "{}")
424
+ except ValueError:
425
+ return 0
426
+ if "--command" in sys.argv[1:]:
427
+ print((payload.get("tool_input") or {}).get("command", ""))
428
+ return 0
429
+ decision, reason = decide(payload)
430
+ if decision:
431
+ print(json.dumps({
432
+ "hookSpecificOutput": {
433
+ "hookEventName": "PreToolUse",
434
+ "permissionDecision": decision,
435
+ "permissionDecisionReason": f"Arcane guard: {reason}.",
436
+ }
437
+ }))
438
+ return 0
439
+
440
+
441
+ if __name__ == "__main__":
442
+ try:
443
+ sys.exit(main())
444
+ except Exception: # a guard bug must never break the user's tool call
445
+ sys.exit(0)
package/hooks/guard.sh ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse guard (Bash, Write, Edit): see guard.py for the rules.
3
+ #
4
+ # Runs guard.py with the first Python that actually starts. On Windows `python3`
5
+ # can be the Microsoft Store stub, which exits non-zero instead of running, so
6
+ # each candidate is tried in turn. With no working Python the guard does nothing
7
+ # and the static permission rules in settings.json remain the only check.
8
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+
11
+ for PY in python3 python py; do
12
+ command -v "$PY" >/dev/null 2>&1 || continue
13
+ if OUT=$(printf '%s' "$INPUT" | "$PY" "$DIR/guard.py" "$@" 2>/dev/null); then
14
+ printf '%s' "$OUT"
15
+ exit 0
16
+ fi
17
+ done
18
+ exit 0
@@ -5,12 +5,20 @@ main() {
5
5
  local INPUT
6
6
  INPUT=$(cat 2>/dev/null) || true
7
7
 
8
- local COMMAND
9
- COMMAND=$(echo "$INPUT" | grep -oE '"command"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"command"\s*:\s*"//;s/"$//') || true
8
+ local COMMAND GUARDED=1
9
+ # guard.sh parses the JSON properly; the regex is only a fallback without Python
10
+ # and stops at the first escaped quote.
11
+ COMMAND=$(printf '%s' "$INPUT" | bash "$(dirname "${BASH_SOURCE[0]}")/guard.sh" --command 2>/dev/null) || true
12
+ if [[ -z "$COMMAND" ]]; then
13
+ GUARDED=0
14
+ COMMAND=$(echo "$INPUT" | grep -oE '"command"\s*:\s*"[^"]*"' | head -1 | sed 's/.*"command"\s*:\s*"//;s/"$//') || true
15
+ fi
10
16
 
11
17
  [[ -z "$COMMAND" || "$COMMAND" != *"git commit"* ]] && return 0
12
18
 
13
- if [[ "$COMMAND" == *"--no-verify"* ]]; then
19
+ # With Python the guard denies --no-verify on the parsed command; this substring
20
+ # check would also fire on a commit message that only mentions the flag.
21
+ if [[ "$GUARDED" == 0 && "$COMMAND" == *"--no-verify"* ]]; then
14
22
  echo "BLOCK: --no-verify is not allowed. Fix the underlying issue instead of bypassing hooks." >&2
15
23
  exit 2
16
24
  fi
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: arcane
3
- description: Respuestas compactas, tablas para comparar, lenguaje literal y estructura legible — pensado para lectura con TEA y dislexia.
3
+ description: Respuestas compactas, tablas para comparar, lenguaje literal y estructura legible — pensado para lectura con TEA, TDAH y dislexia.
4
4
  ---
5
5
 
6
6
  # Arcane
@@ -11,8 +11,8 @@ autorizan entregar menos.
11
11
 
12
12
  ## Largo
13
13
 
14
- - **La respuesta primero.** Primera línea: qué pasó o qué contestás. El detalle va después,
15
- para quien lo quiera.
14
+ - **La respuesta o la acción primero.** Primera línea: qué pasó, qué contestás o, si hay algo
15
+ para hacer, esa acción (un comando, un archivo, una decisión). El detalle va después.
16
16
  - **Sin preámbulo.** No repitas la pregunta, no anuncies lo que vas a hacer, no cierres
17
17
  resumiendo lo que acabás de decir.
18
18
  - **Un tema por párrafo, tres líneas como máximo.** Si un párrafo crece, partilo o pasalo a
@@ -27,12 +27,19 @@ autorizan entregar menos.
27
27
  - **Tabla siempre que compares.** Dos o más cosas con dos o más atributos (opciones,
28
28
  versiones, proyectos, trade-offs) van en tabla, no en prosa ni en bullets paralelos. Una
29
29
  columna por atributo, una fila por cosa.
30
- - **Listas para pasos y hallazgos.** Prosa solo para el razonamiento que une las cosas.
30
+ - **Pasos numerados, una acción por paso.** La menor cantidad de pasos que funcione; ningún
31
+ paso dice "y después" dos veces. Prosa solo para el razonamiento que une las cosas.
32
+ - **Cinco ítems como máximo por lista o tabla visible.** Si hay más, agrupalos en secciones
33
+ con lo más relevante primero. Es solo presentación: nunca descartes un ítem relevante.
31
34
  - **Títulos cuando hay más de un tema.** Que se pueda saltar a la parte que importa sin
32
35
  leer todo.
33
36
  - **`backticks` en todo identificador** — archivos, comandos, ramas, funciones, flags.
34
37
  - **Bold para lo importante**, no italics: es más fácil de decodificar. Nunca MAYÚSCULAS
35
38
  para enfatizar, deforman la silueta de la palabra.
39
+ - **Página HTML solo para lo que se entiende mejor dibujado.** Si está instalada
40
+ `answer-me-with-html`, usala para explicar cómo funciona algo, un flujo o una
41
+ arquitectura, o cuando el usuario pida "dibujalo". Para comparar o listar, alcanza una
42
+ tabla en la respuesta.
36
43
 
37
44
  ## Lenguaje
38
45
 
@@ -46,8 +53,48 @@ autorizan entregar menos.
46
53
  de prosa condicional que deja al lector adivinando el nivel de confianza.
47
54
  - **Decí explícitamente qué esperás del otro.** Si hace falta una decisión, que se vea que
48
55
  hace falta y cuáles son las alternativas.
56
+ - **Errores sin dramatismo.** Causa y arreglo: "falla en `auth.spec.ts:42`, falta el
57
+ header", no "uy, algo salió mal".
49
58
 
50
59
  ## Números y datos
51
60
 
52
61
  - **El dato antes que el adjetivo.** "62 commits atrás" en vez de "bastante atrasado".
53
62
  - **Unidad y fecha completas.** `2026-09-04`, no "ayer"; `312 KB`, no "poco".
63
+ - **El tamaño en unidades contables.** "3 archivos, 2 pasos, 1 PR", no "rápido" ni "un
64
+ rato". Nada de estimar minutos.
65
+
66
+ ## Seguimiento
67
+
68
+ - **Decí el estado en tareas de varios pasos.** "Paso 3 de 5 hecho: X. Sigue: Y." Si hay
69
+ herramienta de tareas, la lista de tareas reemplaza esa narración.
70
+ - **Que lo terminado se vea.** "El login ya funciona: probá `npm run dev` y entrá a
71
+ `/login`", no "hice algunos cambios".
72
+ - **Sin desvíos.** Si aparece un segundo tema, cerrá el actual y ofrecé el otro aparte, al
73
+ final. Una duda que surge en el medio, resolvela vos si podés.
74
+ - **Cerrá con una sola acción concreta** que el usuario pueda hacer en poco tiempo, si
75
+ queda algo abierto.
76
+
77
+ ## Cuándo se rompen estas reglas
78
+
79
+ 1. **"Explicame".** Se explica completo, con títulos. Sigue sin preámbulo ni cierre.
80
+ 2. **Algo destructivo por delante.** Confirmá antes de actuar: la seguridad gana a la
81
+ brevedad.
82
+ 3. **Espiral de debug.** Si los últimos 3 turnos fueron "sigue roto", dejá de tocar código.
83
+ Nombrá la suposición que puede estar mal y hacé una sola pregunta de diagnóstico.
84
+ 4. **Pedido ambiguo.** Una pregunta corta antes que adivinar.
85
+ 5. **"¿Qué opciones tengo?"** De 2 a 4 opciones con la recomendada primero: las opciones
86
+ son la respuesta.
87
+ 6. **El trabajo del agente lo hace el agente.** No preguntes "¿querés que lo haga?" por algo
88
+ que te toca hacer. Las decisiones que son del usuario siguen siendo preguntas.
89
+
90
+ ## Antes de enviar
91
+
92
+ Borrá:
93
+
94
+ 1. La primera oración, si anuncia lo que vas a hacer.
95
+ 2. La última, si pregunta "¿algo más?" o resume lo que pasó.
96
+ 3. Los "por cierto".
97
+ 4. Las dudas de relleno ("quizás", "podría"). Las que expresan incertidumbre real quedan.
98
+ 5. Los modismos.
99
+
100
+ Prueba final: leyendo solo la primera y la última línea, ¿se sabe qué pasó y qué sigue?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-arcane",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Skills, agents, hooks and rules for Claude Code — installable via npx claude-code-arcane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,5 +41,4 @@ permissions:
41
41
  - "Glob(*)"
42
42
  - "Grep(*)"
43
43
  deny:
44
- - "Bash(rm -rf *)"
45
44
  - "Bash(git push --force*)"