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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -2
- package/dist/cli.js +243 -277
- package/docs/USER-GUIDE.md +4 -4
- package/docs/directory-structure.md +6 -1
- package/hooks/guard.py +445 -0
- package/hooks/guard.sh +18 -0
- package/hooks/validate-commit.sh +11 -3
- package/output-styles/arcane.md +51 -4
- package/package.json +1 -1
- package/profiles/audio.yaml +0 -1
- package/profiles/core.yaml +11 -4
- package/profiles/visual-novel.yaml +0 -1
- package/rules/agent-delegation.md +8 -0
- package/rules/backend-code.md +7 -0
- 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/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/migration-code.md +6 -0
- package/rules/nestjs-code.md +6 -0
- package/rules/verification.md +13 -0
- package/hooks/validate-push.sh +0 -21
- package/hooks/validate-secrets.sh +0 -23
package/docs/USER-GUIDE.md
CHANGED
|
@@ -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**, **
|
|
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 (
|
|
242
|
-
| **Hooks (15)** | session-start, detect-division, detect-
|
|
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** |
|
|
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`) | `
|
|
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
|
package/hooks/validate-commit.sh
CHANGED
|
@@ -5,12 +5,20 @@ main() {
|
|
|
5
5
|
local INPUT
|
|
6
6
|
INPUT=$(cat 2>/dev/null) || true
|
|
7
7
|
|
|
8
|
-
local COMMAND
|
|
9
|
-
|
|
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
|
-
|
|
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
|
package/output-styles/arcane.md
CHANGED
|
@@ -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é
|
|
15
|
-
para
|
|
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
|
-
- **
|
|
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